changes
This commit is contained in:
17
README.md
17
README.md
@@ -1,14 +1,13 @@
|
|||||||
# Telegram UserAuth UI
|
# Telegram UserAuth UI
|
||||||
|
|
||||||
Reusable Angular-hosted UI for the Telegram login dialog.
|
Reusable Angular-hosted UI for Telegram login.
|
||||||
|
|
||||||
The app now contains a single standalone page based on the extracted dialog design. It preserves the same visual states and the same state-switcher behavior from the provided HTML:
|
The app now boots directly into the live `userauth` flow instead of a demo dialog. On load it:
|
||||||
|
|
||||||
- `ready`
|
- checks `GET /userauth/session`
|
||||||
- `loading`
|
- creates a QR session with `POST /userauth/qr/create`
|
||||||
- `checking`
|
- polls `GET /userauth/qr/poll?token=...` every 5 seconds
|
||||||
- `expired`
|
- falls back to session re-check polling if QR creation or polling fails
|
||||||
- `error`
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -51,9 +50,9 @@ Expected authenticated session payload:
|
|||||||
|
|
||||||
Runtime expectations preserved by the UI:
|
Runtime expectations preserved by the UI:
|
||||||
|
|
||||||
- QR polling every 3 seconds
|
- QR polling every 5 seconds
|
||||||
- QR expiry after 100 checks on the frontend
|
- QR expiry after 100 checks on the frontend
|
||||||
- direct Telegram login button support
|
- direct Telegram open button using the same deep link returned by QR creation
|
||||||
- fallback session re-check if QR creation fails
|
- fallback session re-check if QR creation fails
|
||||||
|
|
||||||
Cookie requirements expected by consumers:
|
Cookie requirements expected by consumers:
|
||||||
|
|||||||
327
TELEGRAM_USERAUTH_BACKEND.md
Normal file
327
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.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideBrowserGlobalErrorListeners()]
|
providers: [provideBrowserGlobalErrorListeners(), provideHttpClient()]
|
||||||
};
|
};
|
||||||
|
|||||||
155
src/app/app.html
155
src/app/app.html
@@ -1,120 +1,59 @@
|
|||||||
<main class="page">
|
<main class="page">
|
||||||
<section class="panel info">
|
<section class="login-card">
|
||||||
<h1>Telegram Login Dialog</h1>
|
<div class="login-icon">
|
||||||
<p>
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
Standalone extraction of the current login popup: same layout, same visual states,
|
<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>
|
||||||
same QR flow, but with reusable neutral endpoint names for moving into a separate repo.
|
</svg>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="state-switcher" aria-label="Dialog state switcher">
|
|
||||||
@for (dialogState of states; track dialogState) {
|
|
||||||
<button
|
|
||||||
[class.active]="state() === dialogState"
|
|
||||||
[attr.data-state-btn]="dialogState"
|
|
||||||
type="button"
|
|
||||||
(click)="setState(dialogState)"
|
|
||||||
>
|
|
||||||
{{ dialogState === 'ready' ? 'Ready' : dialogState === 'loading' ? 'QR Loading' : dialogState === 'checking' ? 'Checking' : dialogState === 'expired' ? 'Expired' : 'Fallback' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-grid">
|
@if (session(); as currentSession) {
|
||||||
<div class="api-card">
|
<h1>Authenticated</h1>
|
||||||
<strong>Start QR session</strong>
|
<p class="status-copy">{{ statusMessage() }}</p>
|
||||||
<code>POST /userauth/qr/create</code>
|
|
||||||
<p>Returns a one-time token and Telegram deeplink for the QR image.</p>
|
<div class="session-card">
|
||||||
|
<strong>{{ currentSession.displayName }}</strong>
|
||||||
|
<span>
|
||||||
|
@if (currentSession.username) {
|
||||||
|
@{{ currentSession.username }}
|
||||||
|
} @else {
|
||||||
|
Telegram user {{ currentSession.telegramUserId }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>Session {{ currentSession.sessionId }}</span>
|
||||||
|
<span>Expires {{ currentSession.expiresAt | date: 'medium' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="api-card">
|
} @else {
|
||||||
<strong>Poll QR confirmation</strong>
|
<h1>Continue With Telegram</h1>
|
||||||
<code>GET /userauth/qr/poll?token=...</code>
|
<p class="status-copy">{{ statusMessage() }}</p>
|
||||||
<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">
|
<button class="telegram-btn" type="button" (click)="openTelegram()" [disabled]="!telegramUrl() && state() === 'loading'">
|
||||||
<strong>Behavior kept intact</strong>
|
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<ul>
|
<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>
|
||||||
<li>Open Telegram directly from the primary button.</li>
|
</svg>
|
||||||
<li>Show QR immediately and poll every 3 seconds.</li>
|
Open Telegram
|
||||||
<li>Expire the QR after 100 checks and allow manual refresh.</li>
|
</button>
|
||||||
<li>Re-check cookie session if QR creation fails.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
<div class="qr-section">
|
||||||
<div class="login-overlay">
|
@if (state() === 'loading') {
|
||||||
<div class="login-dialog">
|
<div class="qr-container qr-loading" aria-live="polite">
|
||||||
<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" [attr.data-state]="state()" 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>
|
<div class="spinner"></div>
|
||||||
<span>Checking...</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (state() === 'expired' || state() === 'error') {
|
||||||
<div class="action-block">
|
<button class="qr-container qr-expired" type="button" (click)="refreshQr()" aria-label="Refresh QR code">
|
||||||
<button class="telegram-btn" type="button">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
<path d="M1 4v6h6M23 20v-6h-6"></path>
|
||||||
<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>
|
<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>
|
</svg>
|
||||||
Log in with Telegram
|
<span>Refresh QR code</span>
|
||||||
</button>
|
</button>
|
||||||
|
} @else if (qrImageUrl()) {
|
||||||
<div class="qr-section">
|
<div class="qr-container qr-ready">
|
||||||
<p class="qr-hint">Or scan the QR code</p>
|
<img [src]="qrImageUrl()" alt="Telegram login QR code" width="220" height="220" loading="eager" />
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
|
<button class="secondary-btn" type="button" (click)="refreshQr()">Generate new QR</button>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
319
src/app/app.scss
319
src/app/app.scss
@@ -1,7 +1,6 @@
|
|||||||
:host {
|
:host {
|
||||||
--bg-page: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
|
--bg-page: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-hover: #f0f0f0;
|
|
||||||
--text-primary: #1a1a1a;
|
--text-primary: #1a1a1a;
|
||||||
--text-secondary: #666666;
|
--text-secondary: #666666;
|
||||||
--accent-color: #497671;
|
--accent-color: #497671;
|
||||||
@@ -24,137 +23,22 @@
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(320px, 448px) minmax(320px, 560px);
|
|
||||||
gap: 32px;
|
|
||||||
padding: 40px 32px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 40px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.login-card {
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
padding: 24px;
|
padding: 32px 28px;
|
||||||
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
|
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
|
||||||
backdrop-filter: blur(14px);
|
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;
|
max-width: 400px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
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 {
|
.login-icon {
|
||||||
@@ -169,14 +53,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-dialog h2 {
|
h1 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 20px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
line-height: 1.1;
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-desc {
|
.status-copy {
|
||||||
margin: 0 0 24px;
|
margin: 0 0 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -200,28 +83,39 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telegram-btn:hover {
|
.telegram-btn:enabled:hover {
|
||||||
background: var(--telegram-hover);
|
background: var(--telegram-hover);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.telegram-btn:active {
|
.telegram-btn:disabled {
|
||||||
transform: translateY(0);
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-icon {
|
.secondary-btn {
|
||||||
flex-shrink: 0;
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #cfd8e3;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-section {
|
.qr-section {
|
||||||
margin-top: 20px;
|
display: flex;
|
||||||
}
|
justify-content: center;
|
||||||
|
margin: 20px 0 16px;
|
||||||
.qr-hint {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #999;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-container {
|
.qr-container {
|
||||||
@@ -240,14 +134,8 @@
|
|||||||
.qr-loading {
|
.qr-loading {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 204px;
|
width: 244px;
|
||||||
height: 204px;
|
height: 244px;
|
||||||
}
|
|
||||||
|
|
||||||
.qr-loading .spinner,
|
|
||||||
.login-status .spinner {
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-loading .spinner {
|
.qr-loading .spinner {
|
||||||
@@ -255,6 +143,8 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border: 3px solid #e0e0e0;
|
border: 3px solid #e0e0e0;
|
||||||
border-top-color: var(--accent-color);
|
border-top-color: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-expired {
|
.qr-expired {
|
||||||
@@ -262,11 +152,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 204px;
|
width: 244px;
|
||||||
height: 204px;
|
height: 244px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #999;
|
color: #999;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-expired:hover {
|
.qr-expired:hover {
|
||||||
@@ -277,139 +168,59 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-note {
|
.tg-icon {
|
||||||
margin: 16px 0 0;
|
flex-shrink: 0;
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-status {
|
.session-card {
|
||||||
display: none;
|
display: grid;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
justify-content: center;
|
padding: 18px;
|
||||||
gap: 10px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
background: rgba(255, 255, 255, 0.8);
|
||||||
color: var(--text-secondary);
|
border: 1px solid #e2e8f0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card span {
|
||||||
font-size: 14px;
|
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);
|
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 {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 640px) {
|
||||||
.page {
|
.page {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
padding: 24px 16px 32px;
|
padding: 24px 16px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-overlay {
|
.login-card {
|
||||||
min-height: 560px;
|
padding: 24px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.panel {
|
h1 {
|
||||||
border-radius: 22px;
|
font-size: 24px;
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-dialog {
|
|
||||||
padding: 24px 20px;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-container img {
|
.qr-container img {
|
||||||
width: 140px;
|
width: 180px;
|
||||||
height: 140px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-loading,
|
.qr-loading,
|
||||||
.qr-expired {
|
.qr-expired {
|
||||||
width: 164px;
|
width: 204px;
|
||||||
height: 164px;
|
height: 204px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/app/app.ts
232
src/app/app.ts
@@ -1,17 +1,235 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
type DialogState = 'ready' | 'loading' | 'checking' | 'expired' | 'error';
|
type AuthState = 'loading' | 'ready' | 'checking' | 'expired' | 'error' | 'authenticated';
|
||||||
|
|
||||||
|
interface QrCreateResponse {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthSession {
|
||||||
|
sessionId: string;
|
||||||
|
telegramUserId: number;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string;
|
||||||
|
active: boolean;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QrPollResponse {
|
||||||
|
status: 'pending' | 'confirmed' | 'expired';
|
||||||
|
session?: AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
const MAX_POLL_ATTEMPTS = 100;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
|
imports: [DatePipe],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App {
|
export class App implements OnInit, OnDestroy {
|
||||||
protected readonly state = signal<DialogState>('ready');
|
private readonly http = inject(HttpClient);
|
||||||
protected readonly states: DialogState[] = ['ready', 'loading', 'checking', 'expired', 'error'];
|
private pollTimer: number | null = null;
|
||||||
|
|
||||||
protected setState(state: DialogState): void {
|
protected readonly state = signal<AuthState>('loading');
|
||||||
this.state.set(state);
|
protected readonly session = signal<AuthSession | null>(null);
|
||||||
|
protected readonly telegramUrl = signal('');
|
||||||
|
protected readonly qrToken = signal('');
|
||||||
|
protected readonly errorMessage = signal('');
|
||||||
|
protected readonly pollAttempts = signal(0);
|
||||||
|
protected readonly qrImageUrl = computed(() => {
|
||||||
|
const telegramUrl = this.telegramUrl();
|
||||||
|
if (!telegramUrl) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(telegramUrl)}`;
|
||||||
|
});
|
||||||
|
protected readonly statusMessage = computed(() => {
|
||||||
|
const session = this.session();
|
||||||
|
|
||||||
|
switch (this.state()) {
|
||||||
|
case 'loading':
|
||||||
|
return 'Creating your Telegram login session.';
|
||||||
|
case 'checking':
|
||||||
|
return `Waiting for confirmation. Checking every ${POLL_INTERVAL_MS / 1000} seconds.`;
|
||||||
|
case 'expired':
|
||||||
|
return 'This QR code expired. Refresh to generate a new one.';
|
||||||
|
case 'error':
|
||||||
|
return this.errorMessage() || 'Unable to reach the auth backend.';
|
||||||
|
case 'authenticated':
|
||||||
|
return session ? `${session.displayName} is authenticated.` : 'Authenticated.';
|
||||||
|
default:
|
||||||
|
return `Open Telegram or scan the QR code. The page checks every ${POLL_INTERVAL_MS / 1000} seconds.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
await this.initializeAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async refreshQr(): Promise<void> {
|
||||||
|
await this.startQrFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openTelegram(): void {
|
||||||
|
const telegramUrl = this.telegramUrl();
|
||||||
|
|
||||||
|
if (!telegramUrl) {
|
||||||
|
void this.startQrFlow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = telegramUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeAuth(): Promise<void> {
|
||||||
|
this.stopPolling();
|
||||||
|
this.state.set('checking');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await firstValueFrom(
|
||||||
|
this.http.get<AuthSession>(this.buildApiUrl('/userauth/session'), {
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (session?.active) {
|
||||||
|
this.handleAuthenticated(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-200 means unauthenticated for this contract.
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.startQrFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startQrFlow(): Promise<void> {
|
||||||
|
this.stopPolling();
|
||||||
|
this.state.set('loading');
|
||||||
|
this.errorMessage.set('');
|
||||||
|
this.telegramUrl.set('');
|
||||||
|
this.qrToken.set('');
|
||||||
|
this.pollAttempts.set(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<QrCreateResponse>(
|
||||||
|
this.buildApiUrl('/userauth/qr/create'),
|
||||||
|
{},
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response?.token || !response?.url) {
|
||||||
|
throw new Error('Invalid QR create response');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.qrToken.set(response.token);
|
||||||
|
this.telegramUrl.set(response.url);
|
||||||
|
this.state.set('ready');
|
||||||
|
this.startQrPolling(response.token);
|
||||||
|
} catch {
|
||||||
|
this.state.set('error');
|
||||||
|
this.errorMessage.set('Unable to create a QR session. Retrying session lookup in the background.');
|
||||||
|
this.startSessionFallbackPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startQrPolling(token: string): void {
|
||||||
|
this.stopPolling();
|
||||||
|
this.pollTimer = window.setInterval(() => {
|
||||||
|
void this.pollQrStatus(token);
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSessionFallbackPolling(): void {
|
||||||
|
this.stopPolling();
|
||||||
|
this.pollTimer = window.setInterval(() => {
|
||||||
|
void this.checkSessionFallback();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollQrStatus(token: string): Promise<void> {
|
||||||
|
const attempt = this.pollAttempts() + 1;
|
||||||
|
this.pollAttempts.set(attempt);
|
||||||
|
|
||||||
|
if (attempt >= MAX_POLL_ATTEMPTS) {
|
||||||
|
this.stopPolling();
|
||||||
|
this.state.set('expired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.set('checking');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<QrPollResponse>(this.buildApiUrl(`/userauth/qr/poll?token=${encodeURIComponent(token)}`), {
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 'confirmed' && response.session) {
|
||||||
|
this.handleAuthenticated(response.session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 'expired') {
|
||||||
|
this.stopPolling();
|
||||||
|
this.state.set('expired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.set('ready');
|
||||||
|
} catch {
|
||||||
|
this.stopPolling();
|
||||||
|
this.state.set('error');
|
||||||
|
this.errorMessage.set('Polling failed. Retrying session lookup in the background.');
|
||||||
|
this.startSessionFallbackPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkSessionFallback(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const session = await firstValueFrom(
|
||||||
|
this.http.get<AuthSession>(this.buildApiUrl('/userauth/session'), {
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (session?.active) {
|
||||||
|
this.handleAuthenticated(session);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep fallback polling until the user refreshes QR or the backend becomes available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAuthenticated(session: AuthSession): void {
|
||||||
|
this.stopPolling();
|
||||||
|
this.session.set(session);
|
||||||
|
this.state.set('authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.pollTimer !== null) {
|
||||||
|
window.clearInterval(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildApiUrl(path: string): string {
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user