Compare commits

...

2 Commits

Author SHA1 Message Date
sdarbinyan
67b09147ea ppackage 2026-05-21 15:27:22 +04:00
sdarbinyan
d21056bf96 changes 2026-05-21 15:18:28 +04:00
10 changed files with 1935 additions and 602 deletions

159
README.md
View File

@@ -1,22 +1,114 @@
# Telegram UserAuth UI
# telegram-userauth
Reusable Angular-hosted UI for the Telegram login dialog.
Reusable Telegram authentication package built as a web component for drop-in use across other repos.
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:
It ships a custom element named `telegram-userauth` that:
- `ready`
- `loading`
- `checking`
- `expired`
- `error`
- checks `GET /userauth/session` on startup
- creates a QR session with `POST /userauth/qr/create`
- polls `GET /userauth/qr/poll?token=...` every 5 seconds by default
- emits browser events when auth state changes, when login succeeds, and when backend calls fail
- works with same-origin APIs or a separate API origin through `apiBaseUrl`
## Run
## Install
```bash
npm install telegram-userauth
```
## Use in another repo
Register the custom element once in your app entrypoint:
```ts
import { defineTelegramUserAuthElement } from 'telegram-userauth';
defineTelegramUserAuthElement();
```
Render it anywhere in your app:
```html
<telegram-userauth api-base-url="https://api.example.com"></telegram-userauth>
```
Listen for successful login:
```ts
const element = document.querySelector('telegram-userauth');
element?.addEventListener('userauth-authenticated', (event) => {
const detail = (event as CustomEvent).detail;
console.log('Authenticated session', detail.session);
});
```
## Element attributes
- `api-base-url`: backend origin, for example `https://api.example.com`. Leave empty for same-origin `/userauth/...`.
- `telegram-login-url`: optional direct Telegram button URL. If omitted, the button opens the deep link returned by `POST /userauth/qr/create`.
- `poll-interval-ms`: polling interval in milliseconds. Default: `5000`.
- `max-poll-attempts`: maximum QR poll attempts before the QR becomes expired. Default: `100`.
- `qr-size`: QR image size in pixels. Default: `220`.
- `title`: heading text.
- `description`: description text shown before authentication.
- `auto-start`: `true` by default. Set to `false` if the host app wants to call `element.start()` manually.
## Custom events
- `userauth-authenticated`: detail `{ session }`
- `userauth-statechange`: detail `{ state }`
- `userauth-error`: detail `{ message }`
## Imperative API
After selecting the element, the host app can call:
- `await element.start()`
- `await element.refresh()`
- `element.currentSession`
- `element.options = { apiBaseUrl: 'https://api.example.com' }`
## Example integrations
### React / Next / Vite
```ts
import { useEffect } from 'react';
import { defineTelegramUserAuthElement } from 'telegram-userauth';
export function TelegramAuth() {
useEffect(() => {
defineTelegramUserAuthElement();
}, []);
return <telegram-userauth api-base-url="https://api.example.com" />;
}
```
### Angular host app
```ts
import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core';
import { defineTelegramUserAuthElement } from 'telegram-userauth';
defineTelegramUserAuthElement();
@Component({
selector: 'app-auth-page',
template: '<telegram-userauth api-base-url="https://api.example.com"></telegram-userauth>',
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AuthPageComponent {}
```
## Local development
```bash
npm start
```
The dev server runs on port `4300`.
The local Angular app is only a preview host for the package and runs on port `4300`.
## Build
@@ -24,45 +116,22 @@ The dev server runs on port `4300`.
npm run build
```
## Backend contract
This builds both the preview app and the publishable package.
This UI is intended to work against a reusable Telegram auth backend with these endpoints:
To build only the package output:
- `GET /userauth/session`
- `POST /userauth/qr/create`
- `GET /userauth/qr/poll?token=...`
- `POST /userauth/qr/confirm`
- `GET /userauth/telegram/callback`
- `POST /userauth/logout`
- `POST /usersession/{sessionId}`
Expected authenticated session payload:
```json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"telegramUserId": 123456789,
"username": "ivan_petrov",
"displayName": "Ivan Petrov",
"active": true,
"expiresAt": "2026-05-21T14:30:00Z"
}
```bash
npm run build:package
```
Runtime expectations preserved by the UI:
## Backend contract
- QR polling every 3 seconds
- QR expiry after 100 checks on the frontend
- direct Telegram login button support
- fallback session re-check if QR creation fails
The backend requirements are documented in [TELEGRAM_USERAUTH_BACKEND.md](TELEGRAM_USERAUTH_BACKEND.md).
Cookie requirements expected by consumers:
Key expectations:
- name: `userauth_session`
- path: `/`
- `HttpOnly: true`
- `Secure: true`
- `SameSite: None`
- `MaxAge: 86400`
Credentialed CORS is required on the backend.
- cookie name `userauth_session`
- credentialed CORS when frontend and backend are on different origins
- exact session response shape
- `POST /userauth/qr/create` returns `{ token, url }`
- `GET /userauth/qr/poll` returns `pending`, `confirmed`, or `expired`

View File

@@ -0,0 +1,358 @@
# 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.
Package note:
- The published `telegram-userauth` package does not require this direct flow by default.
- If the host app provides `telegram-login-url`, the package button can use this flow.
- If `telegram-login-url` is omitted, the package button opens the `url` returned by `POST /userauth/qr/create`.
### 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 5 seconds by default.
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 5 seconds by default until confirmation or expiration.
Package note:
- The reusable package exposes `poll-interval-ms` and defaults it to `5000`.
- Backend should not assume a stricter cadence than 5 seconds.
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
This endpoint is also used by the package after successful direct-login redirects and during fallback retries.
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 reusable package behavior should be preserved by backend responses.
- QR polling interval: every 5 seconds by default
- QR expiration on frontend: after 100 checks
- If QR creation fails, frontend falls back to session polling
- Primary button opens `telegram-login-url` when the host app provides one
- Otherwise, primary button opens the deep link returned by `POST /userauth/qr/create`
- After login, frontend emits an authenticated event to the host app
## Package Integration Notes
The published package is a custom element named `telegram-userauth`.
Expected host configuration:
- `api-base-url` for separate backend origins, or empty for same-origin deployments
- optional `telegram-login-url` for dedicated direct-button flows
- optional `poll-interval-ms`, default `5000`
Expected package events:
- `userauth-authenticated` with `{ session }`
- `userauth-statechange` with `{ state }`
- `userauth-error` with `{ message }`
## 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

@@ -23,6 +23,9 @@
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"allowedCommonJsDependencies": [
"qrcode"
],
"assets": [
{
"glob": "**/*",

686
package-lock.json generated
View File

@@ -1,12 +1,13 @@
{
"name": "auth-service",
"version": "0.0.0",
"name": "telegram-userauth",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auth-service",
"version": "0.0.0",
"name": "telegram-userauth",
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
@@ -19,7 +20,10 @@
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@types/qrcode": "^1.5.6",
"jsdom": "^27.1.0",
"qrcode": "^1.5.4",
"tsup": "^8.5.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
@@ -3887,12 +3891,20 @@
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz",
@@ -4048,6 +4060,19 @@
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4161,6 +4186,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -4284,6 +4316,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
"integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"load-tsconfig": "^0.2.3"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"peerDependencies": {
"esbuild": ">=0.18"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -4294,6 +4342,16 @@
"node": ">= 0.8"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cacache": {
"version": "20.0.3",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz",
@@ -4358,6 +4416,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -4569,6 +4637,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -4765,6 +4860,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -4793,6 +4898,13 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -5256,6 +5368,32 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
"integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.17",
"mlly": "^1.7.4",
"rollup": "^4.34.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5818,6 +5956,16 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5932,6 +6080,26 @@
],
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
@@ -6014,6 +6182,29 @@
"@lmdb/lmdb-win32-x64": "3.4.4"
}
},
"node_modules/load-tsconfig": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
"integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
@@ -6379,6 +6570,19 @@
"node": ">= 18"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -6440,6 +6644,18 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -6787,6 +7003,35 @@
"license": "MIT",
"optional": true
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
@@ -6800,6 +7045,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pacote": {
"version": "21.0.4",
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz",
@@ -6909,6 +7164,16 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6991,6 +7256,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/piscina": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz",
@@ -7014,6 +7289,28 @@
"node": ">=16.20.0"
}
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -7043,6 +7340,49 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/postcss-media-query-parser": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
@@ -7098,6 +7438,135 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -7161,6 +7630,16 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -7171,6 +7650,13 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -7192,6 +7678,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -7460,6 +7956,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -7822,6 +8325,29 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -7869,6 +8395,29 @@
"node": ">=18"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7969,12 +8518,119 @@
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsup": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
"integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==",
"dev": true,
"license": "MIT",
"dependencies": {
"bundle-require": "^5.1.0",
"cac": "^6.7.14",
"chokidar": "^4.0.3",
"consola": "^3.4.0",
"debug": "^4.4.0",
"esbuild": "^0.27.0",
"fix-dts-default-cjs-exports": "^1.0.0",
"joycon": "^3.1.1",
"picocolors": "^1.1.1",
"postcss-load-config": "^6.0.1",
"resolve-from": "^5.0.0",
"rollup": "^4.34.8",
"source-map": "^0.7.6",
"sucrase": "^3.35.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.11",
"tree-kill": "^1.2.2"
},
"bin": {
"tsup": "dist/cli-default.js",
"tsup-node": "dist/cli-node.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.36.0",
"@swc/core": "^1",
"postcss": "^8.4.12",
"typescript": ">=4.5.0"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"@swc/core": {
"optional": true
},
"postcss": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/tsup/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/tsup/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/tsup/node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tuf-js": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz",
@@ -8019,6 +8675,13 @@
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
@@ -8034,9 +8697,7 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/unique-filename": {
"version": "5.0.0",
@@ -8374,6 +9035,13 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@@ -1,13 +1,17 @@
{
"name": "auth-service",
"version": "0.0.0",
"name": "telegram-userauth",
"version": "0.1.0",
"description": "Reusable Telegram user authentication web component for cross-repo integration.",
"scripts": {
"ng": "ng",
"start": "ng serve --port 4300 --open",
"build": "ng build",
"build": "npm run build:app && npm run build:package",
"build:app": "ng build",
"build:package": "tsup src/package/index.ts --format esm,cjs --dts --clean --out-dir dist/package",
"build:prod": "ng build --configuration production",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"prepack": "npm run build:package"
},
"prettier": {
"printWidth": 100,
@@ -21,7 +25,29 @@
}
]
},
"private": true,
"keywords": [
"telegram",
"auth",
"web-component",
"userauth"
],
"license": "UNLICENSED",
"main": "./dist/package/index.js",
"module": "./dist/package/index.mjs",
"types": "./dist/package/index.d.ts",
"exports": {
".": {
"types": "./dist/package/index.d.ts",
"import": "./dist/package/index.mjs",
"require": "./dist/package/index.js"
}
},
"files": [
"dist/package",
"README.md",
"TELEGRAM_USERAUTH_BACKEND.md"
],
"sideEffects": false,
"packageManager": "npm@10.9.2",
"dependencies": {
"@angular/common": "^21.0.0",
@@ -35,7 +61,10 @@
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@types/qrcode": "^1.5.6",
"jsdom": "^27.1.0",
"qrcode": "^1.5.4",
"tsup": "^8.5.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}

View File

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

View File

@@ -1,120 +1 @@
<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">
@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 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/&#123;sessionId&#125;</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" [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>
<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>
<telegram-userauth></telegram-userauth>

View File

@@ -1,415 +1,4 @@
: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;
--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);
display: block;
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--text-primary);
background: var(--bg-page);
}
* {
box-sizing: border-box;
}
.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;
}
}

View File

@@ -1,17 +1,12 @@
import { Component, signal } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core';
import { defineTelegramUserAuthElement } from '../package';
type DialogState = 'ready' | 'loading' | 'checking' | 'expired' | 'error';
defineTelegramUserAuthElement();
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrl: './app.scss',
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class App {
protected readonly state = signal<DialogState>('ready');
protected readonly states: DialogState[] = ['ready', 'loading', 'checking', 'expired', 'error'];
protected setState(state: DialogState): void {
this.state.set(state);
}
}
export class App {}

740
src/package/index.ts Normal file
View File

@@ -0,0 +1,740 @@
import QRCode from 'qrcode';
export type AuthState = 'loading' | 'ready' | 'checking' | 'expired' | 'error' | 'authenticated';
export interface TelegramUserAuthSession {
sessionId: string;
telegramUserId: number;
username: string | null;
displayName: string;
active: boolean;
expiresAt: string;
}
export interface TelegramUserAuthConfig {
apiBaseUrl: string;
telegramLoginUrl?: string;
pollIntervalMs: number;
maxPollAttempts: number;
qrSize: number;
title: string;
description: string;
autoStart: boolean;
}
export interface TelegramUserAuthAuthenticatedDetail {
session: TelegramUserAuthSession;
}
export interface TelegramUserAuthErrorDetail {
message: string;
}
export interface TelegramUserAuthStateChangeDetail {
state: AuthState;
}
interface QrCreateResponse {
token: string;
url: string;
}
interface QrPollResponse {
status: 'pending' | 'confirmed' | 'expired';
session?: TelegramUserAuthSession;
}
const DEFAULT_TAG_NAME = 'telegram-userauth';
const DEFAULT_CONFIG: TelegramUserAuthConfig = {
apiBaseUrl: '',
telegramLoginUrl: undefined,
pollIntervalMs: 5000,
maxPollAttempts: 100,
qrSize: 220,
title: 'Continue With Telegram',
description: 'Open Telegram or scan the QR code to authenticate.',
autoStart: true
};
const COMPONENT_STYLES = `
:host {
--bg-page: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
--bg-card: rgba(255, 255, 255, 0.78);
--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);
display: block;
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--text-primary);
background: var(--bg-page);
}
* {
box-sizing: border-box;
}
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 32px;
}
.login-card {
background: var(--bg-card);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 28px;
padding: 32px 28px;
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
backdrop-filter: blur(14px);
max-width: 400px;
width: 100%;
text-align: center;
}
.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;
}
h1 {
margin: 0 0 8px;
font-size: 28px;
line-height: 1.1;
}
.status-copy {
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:enabled:hover {
background: var(--telegram-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
.telegram-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.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);
}
.tg-icon {
flex-shrink: 0;
}
.qr-section {
display: flex;
justify-content: center;
margin: 20px 0 16px;
}
.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: 244px;
height: 244px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e0e0e0;
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.qr-expired {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 244px;
height: 244px;
cursor: pointer;
color: #999;
transition: color 0.2s ease;
font: inherit;
}
.qr-expired:hover {
color: var(--accent-color);
}
.qr-expired span {
font-size: 13px;
}
.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;
color: var(--text-secondary);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.page {
padding: 24px 16px 32px;
}
.login-card {
padding: 24px 20px;
border-radius: 20px;
}
}
@media (max-width: 480px) {
h1 {
font-size: 24px;
}
.qr-container img {
width: 180px;
height: 180px;
}
.qr-loading,
.qr-expired {
width: 204px;
height: 204px;
}
}
`;
export class TelegramUserAuthElement extends HTMLElement {
static observedAttributes = [
'api-base-url',
'telegram-login-url',
'poll-interval-ms',
'max-poll-attempts',
'qr-size',
'title',
'description',
'auto-start'
];
private readonly root: ShadowRoot;
private pollTimer: number | null = null;
private started = false;
private optionOverrides: Partial<TelegramUserAuthConfig> = {};
private config: TelegramUserAuthConfig = { ...DEFAULT_CONFIG };
private state: AuthState = 'loading';
private session: TelegramUserAuthSession | null = null;
private qrToken = '';
private qrUrl = '';
private qrImageUrl = '';
private errorMessage = '';
private pollAttempts = 0;
constructor() {
super();
this.root = this.attachShadow({ mode: 'open' });
}
connectedCallback(): void {
this.applyConfig();
this.render();
if (!this.started && this.config.autoStart) {
this.started = true;
void this.initializeAuth();
}
}
disconnectedCallback(): void {
this.stopPolling();
}
attributeChangedCallback(): void {
const previousBaseUrl = this.config.apiBaseUrl;
const previousTelegramLoginUrl = this.config.telegramLoginUrl;
this.applyConfig();
this.render();
if (
this.started &&
this.config.autoStart &&
(previousBaseUrl !== this.config.apiBaseUrl || previousTelegramLoginUrl !== this.config.telegramLoginUrl)
) {
void this.initializeAuth();
}
}
set options(value: Partial<TelegramUserAuthConfig>) {
this.optionOverrides = { ...this.optionOverrides, ...value };
this.applyConfig();
this.render();
if (this.isConnected && this.config.autoStart) {
if (!this.started) {
this.started = true;
}
void this.initializeAuth();
}
}
get options(): TelegramUserAuthConfig {
return { ...this.config };
}
get currentSession(): TelegramUserAuthSession | null {
return this.session;
}
async start(): Promise<void> {
this.started = true;
await this.initializeAuth();
}
async refresh(): Promise<void> {
await this.startQrFlow();
}
private applyConfig(): void {
this.config = {
...DEFAULT_CONFIG,
...this.readAttributeConfig(),
...this.optionOverrides
};
}
private readAttributeConfig(): Partial<TelegramUserAuthConfig> {
return {
apiBaseUrl: this.getAttribute('api-base-url') ?? DEFAULT_CONFIG.apiBaseUrl,
telegramLoginUrl: this.getAttribute('telegram-login-url') ?? undefined,
pollIntervalMs: this.getNumberAttribute('poll-interval-ms', DEFAULT_CONFIG.pollIntervalMs),
maxPollAttempts: this.getNumberAttribute('max-poll-attempts', DEFAULT_CONFIG.maxPollAttempts),
qrSize: this.getNumberAttribute('qr-size', DEFAULT_CONFIG.qrSize),
title: this.getAttribute('title') ?? DEFAULT_CONFIG.title,
description: this.getAttribute('description') ?? DEFAULT_CONFIG.description,
autoStart: this.getBooleanAttribute('auto-start', DEFAULT_CONFIG.autoStart)
};
}
private getNumberAttribute(name: string, fallback: number): number {
const value = this.getAttribute(name);
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
private getBooleanAttribute(name: string, fallback: boolean): boolean {
const value = this.getAttribute(name);
if (value === null) {
return fallback;
}
return value !== 'false';
}
private async initializeAuth(): Promise<void> {
this.stopPolling();
this.setState('checking');
try {
const session = await this.fetchSession();
if (session.active) {
this.handleAuthenticated(session);
return;
}
} catch {
// Non-200 means unauthenticated in this contract.
}
await this.startQrFlow();
}
private async startQrFlow(): Promise<void> {
this.stopPolling();
this.session = null;
this.qrToken = '';
this.qrUrl = '';
this.qrImageUrl = '';
this.errorMessage = '';
this.pollAttempts = 0;
this.setState('loading');
try {
const response = await this.fetchJson<QrCreateResponse>(this.buildApiUrl('/userauth/qr/create'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: '{}',
credentials: 'include'
});
if (!response?.token || !response?.url) {
throw new Error('Invalid QR create response');
}
this.qrToken = response.token;
this.qrUrl = response.url;
this.qrImageUrl = await QRCode.toDataURL(response.url, {
width: this.config.qrSize,
margin: 1
});
this.setState('ready');
this.startQrPolling(response.token);
} catch {
this.errorMessage = 'Unable to create a QR session. Retrying session lookup in the background.';
this.setState('error');
this.dispatchPackageError(this.errorMessage);
this.startSessionFallbackPolling();
}
}
private startQrPolling(token: string): void {
this.stopPolling();
this.pollTimer = window.setInterval(() => {
void this.pollQrStatus(token);
}, this.config.pollIntervalMs);
}
private startSessionFallbackPolling(): void {
this.stopPolling();
this.pollTimer = window.setInterval(() => {
void this.checkSessionFallback();
}, this.config.pollIntervalMs);
}
private async pollQrStatus(token: string): Promise<void> {
this.pollAttempts += 1;
if (this.pollAttempts >= this.config.maxPollAttempts) {
this.stopPolling();
this.setState('expired');
return;
}
this.setState('checking');
try {
const response = await this.fetchJson<QrPollResponse>(
this.buildApiUrl(`/userauth/qr/poll?token=${encodeURIComponent(token)}`),
{
credentials: 'include'
}
);
if (response.status === 'confirmed' && response.session) {
this.handleAuthenticated(response.session);
return;
}
if (response.status === 'expired') {
this.stopPolling();
this.setState('expired');
return;
}
this.setState('ready');
} catch {
this.stopPolling();
this.errorMessage = 'Polling failed. Retrying session lookup in the background.';
this.setState('error');
this.dispatchPackageError(this.errorMessage);
this.startSessionFallbackPolling();
}
}
private async checkSessionFallback(): Promise<void> {
try {
const session = await this.fetchSession();
if (session.active) {
this.handleAuthenticated(session);
}
} catch {
// Continue fallback polling until the backend becomes reachable or the user refreshes.
}
}
private async fetchSession(): Promise<TelegramUserAuthSession> {
return this.fetchJson<TelegramUserAuthSession>(this.buildApiUrl('/userauth/session'), {
credentials: 'include'
});
}
private handleAuthenticated(session: TelegramUserAuthSession): void {
this.stopPolling();
this.session = session;
this.setState('authenticated');
this.dispatchEvent(
new CustomEvent<TelegramUserAuthAuthenticatedDetail>('userauth-authenticated', {
detail: { session },
bubbles: true,
composed: true
})
);
}
private setState(state: AuthState): void {
this.state = state;
this.render();
this.dispatchEvent(
new CustomEvent<TelegramUserAuthStateChangeDetail>('userauth-statechange', {
detail: { state },
bubbles: true,
composed: true
})
);
}
private dispatchPackageError(message: string): void {
this.dispatchEvent(
new CustomEvent<TelegramUserAuthErrorDetail>('userauth-error', {
detail: { message },
bubbles: true,
composed: true
})
);
}
private stopPolling(): void {
if (this.pollTimer !== null) {
window.clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
private async openTelegram(): Promise<void> {
const destination = this.config.telegramLoginUrl || this.qrUrl;
if (!destination) {
await this.startQrFlow();
return;
}
window.location.href = destination;
}
private buildApiUrl(path: string): string {
const baseUrl = this.config.apiBaseUrl.replace(/\/+$/, '');
return baseUrl ? `${baseUrl}${path}` : path;
}
private async fetchJson<T>(input: RequestInfo | URL, init: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
...(init.headers ?? {})
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return (await response.json()) as T;
}
private render(): void {
const sessionContent = this.session
? `
<h1>Authenticated</h1>
<p class="status-copy">${this.escapeHtml(this.getStatusMessage())}</p>
<div class="session-card">
<strong>${this.escapeHtml(this.session.displayName)}</strong>
<span>${this.escapeHtml(this.session.username ? `@${this.session.username}` : `Telegram user ${this.session.telegramUserId}`)}</span>
<span>${this.escapeHtml(`Session ${this.session.sessionId}`)}</span>
<span>${this.escapeHtml(`Expires ${new Date(this.session.expiresAt).toLocaleString()}`)}</span>
</div>
`
: `
<h1>${this.escapeHtml(this.config.title)}</h1>
<p class="status-copy">${this.escapeHtml(this.getStatusMessage())}</p>
<button class="telegram-btn" type="button" data-action="open" ${!this.qrUrl && this.state === 'loading' ? 'disabled' : ''}>
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<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>
Open Telegram
</button>
<div class="qr-section">${this.renderQrSection()}</div>
<button class="secondary-btn" type="button" data-action="refresh">Generate new QR</button>
`;
this.root.innerHTML = `
<style>${COMPONENT_STYLES}</style>
<main class="page">
<section class="login-card">
<div class="login-icon" aria-hidden="true">
<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>
${sessionContent}
</section>
</main>
`;
this.root.querySelector<HTMLButtonElement>('[data-action="open"]')?.addEventListener('click', () => {
void this.openTelegram();
});
this.root.querySelector<HTMLButtonElement>('[data-action="refresh"]')?.addEventListener('click', () => {
void this.startQrFlow();
});
}
private renderQrSection(): string {
if (this.state === 'loading') {
return '<div class="qr-container qr-loading" aria-live="polite"><div class="spinner"></div></div>';
}
if (this.state === 'expired' || this.state === 'error') {
return `
<button class="qr-container qr-expired" type="button" data-action="refresh" aria-label="Refresh QR code">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<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>Refresh QR code</span>
</button>
`;
}
if (this.qrImageUrl) {
return `<div class="qr-container qr-ready"><img src="${this.escapeAttribute(this.qrImageUrl)}" alt="Telegram login QR code" width="${this.config.qrSize}" height="${this.config.qrSize}" loading="eager" /></div>`;
}
return '';
}
private getStatusMessage(): string {
switch (this.state) {
case 'loading':
return 'Creating your Telegram login session.';
case 'checking':
return `Waiting for confirmation. Checking every ${this.config.pollIntervalMs / 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 this.session ? `${this.session.displayName} is authenticated.` : 'Authenticated.';
default:
return this.config.description;
}
}
private escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
private escapeAttribute(value: string): string {
return this.escapeHtml(value);
}
}
export function defineTelegramUserAuthElement(tagName = DEFAULT_TAG_NAME): typeof TelegramUserAuthElement {
const existing = customElements.get(tagName);
if (!existing) {
customElements.define(tagName, TelegramUserAuthElement);
return TelegramUserAuthElement;
}
return existing as typeof TelegramUserAuthElement;
}
declare global {
interface HTMLElementTagNameMap {
'telegram-userauth': TelegramUserAuthElement;
}
}