diff --git a/README.md b/README.md index 0738822..7d3b99c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # 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` -- `loading` -- `checking` -- `expired` -- `error` +- checks `GET /userauth/session` +- creates a QR session with `POST /userauth/qr/create` +- polls `GET /userauth/qr/poll?token=...` every 5 seconds +- falls back to session re-check polling if QR creation or polling fails ## Run @@ -51,9 +50,9 @@ Expected authenticated session payload: Runtime expectations preserved by the UI: -- QR polling every 3 seconds +- QR polling every 5 seconds - 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 Cookie requirements expected by consumers: diff --git a/TELEGRAM_USERAUTH_BACKEND.md b/TELEGRAM_USERAUTH_BACKEND.md new file mode 100644 index 0000000..902464d --- /dev/null +++ b/TELEGRAM_USERAUTH_BACKEND.md @@ -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: +``` + +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. \ No newline at end of file diff --git a/src/app/app.config.ts b/src/app/app.config.ts index cb999ab..a2c81c2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,6 @@ +import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners()] + providers: [provideBrowserGlobalErrorListeners(), provideHttpClient()] }; diff --git a/src/app/app.html b/src/app/app.html index df0929a..d403790 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,120 +1,59 @@
-
-

Telegram Login Dialog

-

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

- -
- @for (dialogState of states; track dialogState) { - - } + + -
-
diff --git a/src/app/app.scss b/src/app/app.scss index 8607116..8938aab 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -1,7 +1,6 @@ :host { --bg-page: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%); --bg-card: #ffffff; - --bg-hover: #f0f0f0; --text-primary: #1a1a1a; --text-secondary: #666666; --accent-color: #497671; @@ -24,137 +23,22 @@ .page { min-height: 100vh; - display: grid; - grid-template-columns: minmax(320px, 448px) minmax(320px, 560px); - gap: 32px; - padding: 40px 32px; + display: flex; align-items: center; justify-content: center; + padding: 40px 32px; } -.panel { +.login-card { background: rgba(255, 255, 255, 0.72); border: 1px solid rgba(255, 255, 255, 0.8); border-radius: 28px; - padding: 24px; + padding: 32px 28px; 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 { @@ -169,14 +53,13 @@ justify-content: center; } -.login-dialog h2 { +h1 { margin: 0 0 8px; - font-size: 20px; - font-weight: 700; - color: var(--text-primary); + font-size: 28px; + line-height: 1.1; } -.login-desc { +.status-copy { margin: 0 0 24px; font-size: 14px; color: var(--text-secondary); @@ -200,28 +83,39 @@ transition: all 0.2s ease; } -.telegram-btn:hover { +.telegram-btn:enabled: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); +.telegram-btn:disabled { + opacity: 0.7; + cursor: wait; } -.tg-icon { - flex-shrink: 0; +.secondary-btn { + 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 { - margin-top: 20px; -} - -.qr-hint { - margin: 0 0 12px; - font-size: 13px; - color: #999; + display: flex; + justify-content: center; + margin: 20px 0 16px; } .qr-container { @@ -240,14 +134,8 @@ .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; + width: 244px; + height: 244px; } .qr-loading .spinner { @@ -255,6 +143,8 @@ height: 32px; border: 3px solid #e0e0e0; border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; } .qr-expired { @@ -262,11 +152,12 @@ align-items: center; justify-content: center; gap: 8px; - width: 204px; - height: 204px; + width: 244px; + height: 244px; cursor: pointer; color: #999; transition: color 0.2s ease; + font: inherit; } .qr-expired:hover { @@ -277,139 +168,59 @@ font-size: 13px; } -.login-note { - margin: 16px 0 0; - font-size: 12px; - color: #999; - line-height: 1.4; +.tg-icon { + flex-shrink: 0; } -.login-status { - display: none; - align-items: center; - justify-content: center; - gap: 10px; - padding: 16px; - color: var(--text-secondary); +.session-card { + display: grid; + gap: 8px; + padding: 18px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid #e2e8f0; + text-align: left; +} + +.session-card strong { + font-size: 18px; +} + +.session-card span { 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) { +@media (max-width: 640px) { .page { - grid-template-columns: 1fr; padding: 24px 16px 32px; } - .login-overlay { - min-height: 560px; + .login-card { + padding: 24px 20px; + border-radius: 20px; } } @media (max-width: 480px) { - .panel { - border-radius: 22px; - padding: 18px; - } - - .login-dialog { - padding: 24px 20px; - border-radius: 16px; + h1 { + font-size: 24px; } .qr-container img { - width: 140px; - height: 140px; + width: 180px; + height: 180px; } .qr-loading, .qr-expired { - width: 164px; - height: 164px; + width: 204px; + height: 204px; } } diff --git a/src/app/app.ts b/src/app/app.ts index 6498997..d0b4abf 100644 --- a/src/app/app.ts +++ b/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({ selector: 'app-root', + imports: [DatePipe], templateUrl: './app.html', styleUrl: './app.scss' }) -export class App { - protected readonly state = signal('ready'); - protected readonly states: DialogState[] = ['ready', 'loading', 'checking', 'expired', 'error']; +export class App implements OnInit, OnDestroy { + private readonly http = inject(HttpClient); + private pollTimer: number | null = null; - protected setState(state: DialogState): void { - this.state.set(state); + protected readonly state = signal('loading'); + protected readonly session = signal(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 { + await this.initializeAuth(); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + protected async refreshQr(): Promise { + await this.startQrFlow(); + } + + protected openTelegram(): void { + const telegramUrl = this.telegramUrl(); + + if (!telegramUrl) { + void this.startQrFlow(); + return; + } + + window.location.href = telegramUrl; + } + + private async initializeAuth(): Promise { + this.stopPolling(); + this.state.set('checking'); + + try { + const session = await firstValueFrom( + this.http.get(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 { + 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( + 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 { + 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(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 { + try { + const session = await firstValueFrom( + this.http.get(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; } }