diff --git a/README.md b/README.md index d41256cd..f4edd93c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ docker run -d --name hapi -p 3006:3006 -v hapi-data:/data ghcr.io/tiann/hapi-ser | `CLI_API_TOKEN` | (auto-generated) | Access token for CLI and web UI | | `TELEGRAM_BOT_TOKEN` | - | Telegram bot token (optional) | | `WEBAPP_URL` | - | Public URL for Telegram Mini App | -| `ALLOWED_CHAT_IDS` | - | Comma-separated Telegram chat IDs | #### With Telegram Support @@ -81,7 +80,6 @@ docker run -d \ -v hapi-data:/data \ -e WEBAPP_URL="https://your-domain.example" \ -e TELEGRAM_BOT_TOKEN="your-bot-token" \ - -e ALLOWED_CHAT_IDS="12345678" \ ghcr.io/tiann/hapi-server:latest ``` @@ -149,15 +147,11 @@ WEBAPP_URL="https://your-domain.example" TELEGRAM_BOT_TOKEN="..." ``` -4. Start the server and send `/start` to the bot to get your chat ID. +4. Start the server and send `/start` to the bot. -5. Add your chat ID and restart: +5. Run `/app` in the bot chat to open the Mini App. -``` -ALLOWED_CHAT_IDS="12345678" -``` - -6. Run `/app` in the bot chat to open the Mini App. +6. If prompted, enter `CLI_API_TOKEN` to bind your Telegram account. After binding, you can open the Mini App without re-entering the token, and notifications only go to bound users. ## Multi-agent support diff --git a/server/README.md b/server/README.md index 250010c4..28b885e9 100644 --- a/server/README.md +++ b/server/README.md @@ -22,7 +22,6 @@ See `src/configuration.ts` for all options. ### Optional (Telegram) - `TELEGRAM_BOT_TOKEN` - Token from @BotFather. -- `ALLOWED_CHAT_IDS` - Comma-separated chat IDs allowed to use the bot. - `WEBAPP_URL` - Public HTTPS URL for Telegram Mini App access. Also used to derive default CORS origins for the web app. ### Optional @@ -38,16 +37,15 @@ Binary (single executable): ```bash export TELEGRAM_BOT_TOKEN="..." -export ALLOWED_CHAT_IDS="12345678" export CLI_API_TOKEN="shared-secret" export WEBAPP_URL="https://your-domain.example" hapi server ``` -If you only need web + CLI, you can omit TELEGRAM_BOT_TOKEN and ALLOWED_CHAT_IDS. -To enable Telegram, set TELEGRAM_BOT_TOKEN and WEBAPP_URL, start the server, send `/start` -to the bot to get your chat ID, set ALLOWED_CHAT_IDS, and restart the server. +If you only need web + CLI, you can omit TELEGRAM_BOT_TOKEN. +To enable Telegram, set TELEGRAM_BOT_TOKEN and WEBAPP_URL, start the server, open `/app` +in the bot chat, and bind the Mini App with CLI_API_TOKEN when prompted. From source: @@ -63,6 +61,7 @@ See `src/web/routes/` for all endpoints. ### Authentication (`src/web/routes/auth.ts`) - `POST /api/auth` - Get JWT token (Telegram initData or CLI_API_TOKEN). +- `POST /api/bind` - Bind a Telegram account using initData + CLI_API_TOKEN. ### Sessions (`src/web/routes/sessions.ts`) @@ -137,7 +136,7 @@ See `src/telegram/bot.ts` for bot implementation. ### Commands -- `/start` - Welcome message with chat ID. +- `/start` - Welcome message with Mini App link. - `/app` - Open Mini App. ### Features @@ -168,6 +167,7 @@ See `src/store/index.ts` for SQLite persistence: - Messages with pagination support. - Machines with daemon state. - Todo extraction from messages. +- Users table for Telegram bindings. ## Source structure @@ -181,7 +181,7 @@ See `src/store/index.ts` for SQLite persistence: ## Security model Access is controlled by: -- Telegram chat ID allowlist (when Telegram is enabled). +- Telegram initData verification plus bound Telegram users (bound via CLI_API_TOKEN). - `CLI_API_TOKEN` shared secret for CLI and browser access. Transport security depends on HTTPS in front of the server. diff --git a/server/src/configuration.ts b/server/src/configuration.ts index 2c2aa5be..120285ca 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -8,7 +8,6 @@ * Optional environment variables: * - CLI_API_TOKEN: Shared secret for hapi CLI authentication (auto-generated if not set) * - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather - * - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs * - WEBAPP_PORT: Port for Mini App HTTP server (default: 3006) * - WEBAPP_URL: Public URL for Telegram Mini App * - CORS_ORIGINS: Comma-separated CORS origins @@ -26,7 +25,6 @@ export type ConfigSource = 'env' | 'file' | 'default' export interface ConfigSources { telegramBotToken: ConfigSource - allowedChatIds: ConfigSource webappPort: ConfigSource webappUrl: ConfigSource corsOrigins: ConfigSource @@ -37,9 +35,6 @@ class Configuration { /** Telegram Bot API token */ public readonly telegramBotToken: string | null - /** List of allowed Telegram chat IDs (security whitelist) */ - public readonly allowedChatIds: number[] - /** Telegram bot enabled status (token present) */ public readonly telegramEnabled: boolean @@ -87,7 +82,6 @@ class Configuration { // Apply server settings this.telegramBotToken = serverSettings.telegramBotToken this.telegramEnabled = Boolean(this.telegramBotToken) - this.allowedChatIds = serverSettings.allowedChatIds this.webappPort = serverSettings.webappPort this.miniAppUrl = serverSettings.webappUrl this.corsOrigins = serverSettings.corsOrigins @@ -147,11 +141,6 @@ class Configuration { return config } - /** Check if a chat ID is allowed */ - isChatIdAllowed(chatId: number): boolean { - return this.allowedChatIds.includes(chatId) - } - /** Set CLI API token (called during async initialization) */ _setCliApiToken(token: string, source: 'env' | 'file' | 'generated', isNew: boolean): void { this.cliApiToken = token diff --git a/server/src/index.ts b/server/src/index.ts index d1d35c77..8e058bdf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -69,12 +69,7 @@ async function main() { console.log('[Server] Telegram: disabled (no TELEGRAM_BOT_TOKEN)') } else { const tokenSource = formatSource(config.sources.telegramBotToken) - if (config.allowedChatIds.length === 0) { - console.log(`[Server] Telegram: enabled (${tokenSource}), allowlist empty - /start shows chat ID`) - } else { - const idsSource = formatSource(config.sources.allowedChatIds) - console.log(`[Server] Telegram: enabled (${tokenSource}), chat IDs: ${config.allowedChatIds.join(', ')} (${idsSource})`) - } + console.log(`[Server] Telegram: enabled (${tokenSource})`) } const store = new Store(config.dbPath) @@ -99,8 +94,8 @@ async function main() { happyBot = new HappyBot({ syncEngine, botToken: config.telegramBotToken, - allowedChatIds: config.allowedChatIds, - miniAppUrl: config.miniAppUrl + miniAppUrl: config.miniAppUrl, + store }) } @@ -109,6 +104,7 @@ async function main() { getSyncEngine: () => syncEngine, getSseManager: () => sseManager, jwtSecret, + store, socketEngine: socketServer.engine }) diff --git a/server/src/serverSettings.ts b/server/src/serverSettings.ts index a0fb3227..c3b39b8c 100644 --- a/server/src/serverSettings.ts +++ b/server/src/serverSettings.ts @@ -13,7 +13,6 @@ import { readSettings, writeSettings, type Settings } from './web/cliApiToken' export interface ServerSettings { telegramBotToken: string | null - allowedChatIds: number[] webappPort: number webappUrl: string corsOrigins: string[] @@ -23,7 +22,6 @@ export interface ServerSettingsResult { settings: ServerSettings sources: { telegramBotToken: 'env' | 'file' | 'default' - allowedChatIds: 'env' | 'file' | 'default' webappPort: 'env' | 'file' | 'default' webappUrl: 'env' | 'file' | 'default' corsOrigins: 'env' | 'file' | 'default' @@ -31,16 +29,6 @@ export interface ServerSettingsResult { savedToFile: boolean } -/** - * Parse comma-separated chat IDs from string - */ -function parseChatIds(str: string): number[] { - return str - .split(',') - .map(id => parseInt(id.trim(), 10)) - .filter(id => !isNaN(id)) -} - /** * Parse and normalize CORS origins */ @@ -95,7 +83,6 @@ export async function loadServerSettings(dataDir: string): Promise file > [] - let allowedChatIds: number[] = [] - if (process.env.ALLOWED_CHAT_IDS) { - allowedChatIds = parseChatIds(process.env.ALLOWED_CHAT_IDS) - sources.allowedChatIds = 'env' - if (settings.allowedChatIds === undefined) { - settings.allowedChatIds = allowedChatIds - needsSave = true - } - } else if (settings.allowedChatIds !== undefined) { - allowedChatIds = settings.allowedChatIds - sources.allowedChatIds = 'file' - } - // webappPort: env > file > 3006 let webappPort = 3006 if (process.env.WEBAPP_PORT) { @@ -185,7 +158,6 @@ export async function loadServerSettings(dataDir: string): Promise = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } @@ -86,6 +93,13 @@ type DbMessageRow = { local_id: string | null } +type DbUserRow = { + id: number + platform: string + platform_user_id: string + created_at: number +} + function safeJsonParse(value: string | null): unknown | null { if (value === null) return null try { @@ -140,6 +154,15 @@ function toStoredMessage(row: DbMessageRow): StoredMessage { } } +function toStoredUser(row: DbUserRow): StoredUser { + return { + id: row.id, + platform: row.platform, + platformUserId: row.platform_user_id, + createdAt: row.created_at + } +} + export class Store { private db: Database @@ -222,6 +245,15 @@ export class Store { ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq); CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL; + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform); `) const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> @@ -560,4 +592,46 @@ export class Store { return rows.map(toStoredMessage) } + + getUser(platform: string, platformUserId: string): StoredUser | null { + const row = this.db.prepare( + 'SELECT * FROM users WHERE platform = ? AND platform_user_id = ? LIMIT 1' + ).get(platform, platformUserId) as DbUserRow | undefined + return row ? toStoredUser(row) : null + } + + getUsersByPlatform(platform: string): StoredUser[] { + const rows = this.db.prepare( + 'SELECT * FROM users WHERE platform = ? ORDER BY created_at ASC' + ).all(platform) as DbUserRow[] + return rows.map(toStoredUser) + } + + addUser(platform: string, platformUserId: string): StoredUser { + const now = Date.now() + this.db.prepare(` + INSERT OR IGNORE INTO users ( + platform, platform_user_id, created_at + ) VALUES ( + @platform, @platform_user_id, @created_at + ) + `).run({ + platform, + platform_user_id: platformUserId, + created_at: now + }) + + const row = this.getUser(platform, platformUserId) + if (!row) { + throw new Error('Failed to create user') + } + return row + } + + removeUser(platform: string, platformUserId: string): boolean { + const result = this.db.prepare( + 'DELETE FROM users WHERE platform = ? AND platform_user_id = ?' + ).run(platform, platformUserId) + return result.changes > 0 + } } diff --git a/server/src/telegram/bot.ts b/server/src/telegram/bot.ts index da724493..64f88c84 100644 --- a/server/src/telegram/bot.ts +++ b/server/src/telegram/bot.ts @@ -5,10 +5,11 @@ * All interactive features are handled by the Telegram Mini App. */ -import { Bot, Context, NextFunction, InlineKeyboard } from 'grammy' +import { Bot, Context, InlineKeyboard } from 'grammy' import { SyncEngine, SyncEvent, Session } from '../sync/syncEngine' import { handleCallback, CallbackContext } from './callbacks' import { formatSessionNotification, createNotificationKeyboard } from './sessionView' +import type { Store } from '../store' export interface BotContext extends Context { // Extended context for future use @@ -17,8 +18,8 @@ export interface BotContext extends Context { export interface HappyBotConfig { syncEngine: SyncEngine botToken: string - allowedChatIds: number[] miniAppUrl: string + store: Store } /** @@ -28,9 +29,8 @@ export class HappyBot { private bot: Bot private syncEngine: SyncEngine | null = null private isRunning = false - private readonly allowedChatIds: number[] private readonly miniAppUrl: string - private readonly allowlistConfigured: boolean + private readonly store: Store // Track last known permission requests per session to detect new ones private lastKnownRequests: Map> = new Map() @@ -46,17 +46,13 @@ export class HappyBot { constructor(config: HappyBotConfig) { this.syncEngine = config.syncEngine - this.allowedChatIds = config.allowedChatIds this.miniAppUrl = config.miniAppUrl - this.allowlistConfigured = this.allowedChatIds.length > 0 + this.store = config.store this.bot = new Bot(config.botToken) this.setupMiddleware() this.setupCommands() - - if (this.allowlistConfigured) { - this.setupCallbacks() - } + this.setupCallbacks() // Subscribe to sync events immediately if engine is available if (this.syncEngine) { @@ -134,28 +130,6 @@ export class HappyBot { * Setup middleware */ private setupMiddleware(): void { - // Security middleware: only allow configured chat IDs - this.bot.use(async (ctx: BotContext, next: NextFunction) => { - const chatId = ctx.chat?.id - if (this.allowlistConfigured) { - if (!chatId || !this.allowedChatIds.includes(chatId)) { - console.log(`[HAPIBot] Rejected message from unauthorized chat: ${chatId}`) - return // Silently ignore unauthorized users - } - await next() - return - } - - const messageText = ctx.message?.text ?? '' - if (!messageText.startsWith('/start')) { - if (chatId) { - console.log(`[HAPIBot] Allowlist empty; ignoring chat: ${chatId}`) - } - return - } - await next() - }) - // Error handling middleware this.bot.catch((err) => { console.error('[HAPIBot] Error:', err.message) @@ -166,22 +140,6 @@ export class HappyBot { * Setup command handlers */ private setupCommands(): void { - // When allowlist is not configured, show setup instructions - if (!this.allowlistConfigured) { - this.bot.command('start', async (ctx) => { - const chatId = ctx.chat?.id - const chatIdDisplay = chatId ? String(chatId) : 'unknown' - const example = chatId ? `ALLOWED_CHAT_IDS="${chatId}"` : 'ALLOWED_CHAT_IDS="12345678"' - - await ctx.reply( - `HAPI bot is not fully configured yet.\n\n` + - `Your chat ID is: ${chatIdDisplay}\n` + - `Set ${example} and restart the server.` - ) - }) - return - } - // /app - Open Telegram Mini App (primary entry point) this.bot.command('app', async (ctx) => { const keyboard = new InlineKeyboard().webApp('Open App', this.miniAppUrl) @@ -231,10 +189,6 @@ export class HappyBot { * Handle sync engine events for notifications */ private handleSyncEvent(event: SyncEvent): void { - if (!this.allowlistConfigured) { - return - } - if (event.type === 'session-updated' && event.sessionId) { const session = this.syncEngine?.getSession(event.sessionId) if (session) { @@ -263,6 +217,21 @@ export class HappyBot { return session } + /** + * Get bound Telegram chat IDs from storage. + */ + private getBoundChatIds(): number[] { + const users = this.store.getUsersByPlatform('telegram') + const ids = new Set() + for (const user of users) { + const chatId = Number(user.platformUserId) + if (Number.isFinite(chatId)) { + ids.add(chatId) + } + } + return Array.from(ids) + } + /** * Send a push notification when agent is ready for input. */ @@ -290,7 +259,12 @@ export class HappyBot { const keyboard = new InlineKeyboard() .webApp('Open Session', url) - for (const chatId of this.allowedChatIds) { + const chatIds = this.getBoundChatIds() + if (chatIds.length === 0) { + return + } + + for (const chatId of chatIds) { await this.bot.api.sendMessage( chatId, `It's ready!\n\n${agentName} is waiting for your command`, @@ -353,7 +327,7 @@ export class HappyBot { } /** - * Send permission notification to all allowed chats + * Send permission notification to all bound chats */ private async sendPermissionNotification(sessionId: string): Promise { const session = this.getNotifiableSession(sessionId) @@ -364,8 +338,12 @@ export class HappyBot { const text = formatSessionNotification(session) const keyboard = createNotificationKeyboard(session, this.miniAppUrl) - // Send to all allowed chat IDs - for (const chatId of this.allowedChatIds) { + const chatIds = this.getBoundChatIds() + if (chatIds.length === 0) { + return + } + + for (const chatId of chatIds) { try { await this.bot.api.sendMessage(chatId, text, { reply_markup: keyboard diff --git a/server/src/web/cliApiToken.ts b/server/src/web/cliApiToken.ts index bea338d9..563fbc0d 100644 --- a/server/src/web/cliApiToken.ts +++ b/server/src/web/cliApiToken.ts @@ -17,7 +17,6 @@ export interface Settings { cliApiToken?: string // Server configuration (persisted from environment variables) telegramBotToken?: string - allowedChatIds?: number[] webappPort?: number webappUrl?: string corsOrigins?: string[] diff --git a/server/src/web/middleware/auth.ts b/server/src/web/middleware/auth.ts index f99dfe36..675c8a84 100644 --- a/server/src/web/middleware/auth.ts +++ b/server/src/web/middleware/auth.ts @@ -15,7 +15,7 @@ const jwtPayloadSchema = z.object({ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler { return async (c, next) => { const path = c.req.path - if (path === '/api/auth') { + if (path === '/api/auth' || path === '/api/bind') { await next() return } diff --git a/server/src/web/routes/auth.ts b/server/src/web/routes/auth.ts index cff16cb7..2f06b5aa 100644 --- a/server/src/web/routes/auth.ts +++ b/server/src/web/routes/auth.ts @@ -6,6 +6,7 @@ import { safeCompareStrings } from '../../utils/crypto' import { validateTelegramInitData } from '../telegramInitData' import { getOrCreateOwnerId } from '../ownerId' import type { WebAppEnv } from '../middleware/auth' +import type { Store } from '../../store' const telegramAuthSchema = z.object({ initData: z.string() @@ -17,7 +18,7 @@ const accessTokenAuthSchema = z.object({ const authBodySchema = z.union([telegramAuthSchema, accessTokenAuthSchema]) -export function createAuthRoutes(jwtSecret: Uint8Array): Hono { +export function createAuthRoutes(jwtSecret: Uint8Array, store: Store): Hono { const app = new Hono() app.post('/auth', async (c) => { @@ -44,19 +45,16 @@ export function createAuthRoutes(jwtSecret: Uint8Array): Hono { return c.json({ error: 'Telegram authentication is disabled. Configure TELEGRAM_BOT_TOKEN.' }, 503) } - if (configuration.allowedChatIds.length === 0) { - return c.json({ error: 'Telegram allowlist is empty. Configure ALLOWED_CHAT_IDS and restart.' }, 403) - } - // Telegram initData authentication const result = validateTelegramInitData(parsed.data.initData, configuration.telegramBotToken) if (!result.ok) { return c.json({ error: result.error }, 401) } - const telegramUserId = result.user.id - if (!configuration.isChatIdAllowed(telegramUserId)) { - return c.json({ error: 'User not allowed' }, 403) + const telegramUserId = String(result.user.id) + const storedUser = store.getUser('telegram', telegramUserId) + if (!storedUser) { + return c.json({ error: 'not_bound' }, 401) } userId = await getOrCreateOwnerId() diff --git a/server/src/web/routes/bind.ts b/server/src/web/routes/bind.ts new file mode 100644 index 00000000..30216bea --- /dev/null +++ b/server/src/web/routes/bind.ts @@ -0,0 +1,62 @@ +import { Hono } from 'hono' +import { SignJWT } from 'jose' +import { z } from 'zod' +import { configuration } from '../../configuration' +import { safeCompareStrings } from '../../utils/crypto' +import { validateTelegramInitData } from '../telegramInitData' +import { getOrCreateOwnerId } from '../ownerId' +import type { WebAppEnv } from '../middleware/auth' +import type { Store } from '../../store' + +const bindBodySchema = z.object({ + initData: z.string(), + accessToken: z.string() +}) + +export function createBindRoutes(jwtSecret: Uint8Array, store: Store): Hono { + const app = new Hono() + + app.post('/bind', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = bindBodySchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + if (!safeCompareStrings(parsed.data.accessToken, configuration.cliApiToken)) { + return c.json({ error: 'Invalid access token' }, 401) + } + + if (!configuration.telegramEnabled || !configuration.telegramBotToken) { + return c.json({ error: 'Telegram authentication is disabled. Configure TELEGRAM_BOT_TOKEN.' }, 503) + } + + const result = validateTelegramInitData(parsed.data.initData, configuration.telegramBotToken) + if (!result.ok) { + return c.json({ error: result.error }, 401) + } + + const telegramUserId = String(result.user.id) + store.addUser('telegram', telegramUserId) + + const userId = await getOrCreateOwnerId() + + const token = await new SignJWT({ uid: userId }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('15m') + .sign(jwtSecret) + + return c.json({ + token, + user: { + id: userId, + username: result.user.username, + firstName: result.user.first_name, + lastName: result.user.last_name + } + }) + }) + + return app +} diff --git a/server/src/web/server.ts b/server/src/web/server.ts index 2b40f4ed..309c3902 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -8,6 +8,7 @@ import { configuration } from '../configuration' import type { SyncEngine } from '../sync/syncEngine' import { createAuthMiddleware, type WebAppEnv } from './middleware/auth' import { createAuthRoutes } from './routes/auth' +import { createBindRoutes } from './routes/bind' import { createEventsRoutes } from './routes/events' import { createSessionsRoutes } from './routes/sessions' import { createMessagesRoutes } from './routes/messages' @@ -21,6 +22,7 @@ import type { Server as SocketEngine } from '@socket.io/bun-engine' import type { WebSocketData } from '@socket.io/bun-engine' import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets' import { isBunCompiled } from '../utils/bunCompiled' +import type { Store } from '../store' function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { const candidates = [ @@ -52,6 +54,7 @@ function createWebApp(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null jwtSecret: Uint8Array + store: Store embeddedAssetMap: Map | null }): Hono { const app = new Hono() @@ -70,7 +73,8 @@ function createWebApp(options: { app.route('/cli', createCliRoutes(options.getSyncEngine)) - app.route('/api', createAuthRoutes(options.jwtSecret)) + app.route('/api', createAuthRoutes(options.jwtSecret, options.store)) + app.route('/api', createBindRoutes(options.jwtSecret, options.store)) app.use('/api/*', createAuthMiddleware(options.jwtSecret)) app.route('/api', createEventsRoutes(options.getSseManager)) @@ -162,6 +166,7 @@ export async function startWebServer(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null jwtSecret: Uint8Array + store: Store socketEngine: SocketEngine }): Promise> { const isCompiled = isBunCompiled() @@ -170,6 +175,7 @@ export async function startWebServer(options: { getSyncEngine: options.getSyncEngine, getSseManager: options.getSseManager, jwtSecret: options.jwtSecret, + store: options.store, embeddedAssetMap }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 16002f0f..5cad0a11 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,7 +20,7 @@ import { LoadingState } from '@/components/LoadingState' export function App() { const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) - const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource, baseUrl) + const { token, api, isLoading: isAuthLoading, error: authError, needsBinding, bind } = useAuth(authSource, baseUrl) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() @@ -176,6 +176,20 @@ export function App() { ) } + if (needsBinding) { + return ( + + ) + } + // Authenticating (also covers the gap before useAuth effect starts) if (isAuthLoading || (authSource && !token && !authError)) { return ( diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a24a2508..e89feb74 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -18,6 +18,33 @@ type ApiClientOptions = { onUnauthorized?: () => Promise } +type ErrorPayload = { + error?: unknown +} + +function parseErrorCode(bodyText: string): string | undefined { + try { + const parsed = JSON.parse(bodyText) as ErrorPayload + return typeof parsed.error === 'string' ? parsed.error : undefined + } catch { + return undefined + } +} + +export class ApiError extends Error { + status: number + code?: string + body?: string + + constructor(message: string, status: number, code?: string, body?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + this.body = body + } +} + export class ApiClient { private token: string private readonly baseUrl: string | null @@ -93,7 +120,26 @@ export class ApiClient { if (!res.ok) { const body = await res.text().catch(() => '') - throw new Error(`Auth failed: HTTP ${res.status} ${res.statusText}: ${body}`) + const code = parseErrorCode(body) + const detail = body ? `: ${body}` : '' + throw new ApiError(`Auth failed: HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined) + } + + return await res.json() as AuthResponse + } + + async bind(auth: { initData: string; accessToken: string }): Promise { + const res = await fetch(this.buildUrl('/api/bind'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(auth) + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + const code = parseErrorCode(body) + const detail = body ? `: ${body}` : '' + throw new ApiError(`Bind failed: HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined) } return await res.json() as AuthResponse diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx index 727aa945..185a5ee4 100644 --- a/web/src/components/LoginPrompt.tsx +++ b/web/src/components/LoginPrompt.tsx @@ -6,7 +6,9 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di import type { ServerUrlResult } from '@/hooks/useServerUrl' type LoginPromptProps = { - onLogin: (token: string) => void + mode?: 'login' | 'bind' + onLogin?: (token: string) => void + onBind?: (token: string) => Promise baseUrl: string serverUrl: string | null setServerUrl: (input: string) => ServerUrlResult @@ -15,6 +17,7 @@ type LoginPromptProps = { } export function LoginPrompt(props: LoginPromptProps) { + const isBindMode = props.mode === 'bind' const [accessToken, setAccessToken] = useState('') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) @@ -35,13 +38,26 @@ export function LoginPrompt(props: LoginPromptProps) { setError(null) try { - // Validate the token by attempting to authenticate - const client = new ApiClient('', { baseUrl: props.baseUrl }) - await client.authenticate({ accessToken: trimmedToken }) - // If successful, pass the token to parent - props.onLogin(trimmedToken) + if (isBindMode) { + if (!props.onBind) { + setError('Binding is unavailable.') + return + } + await props.onBind(trimmedToken) + } else { + // Validate the token by attempting to authenticate + const client = new ApiClient('', { baseUrl: props.baseUrl }) + await client.authenticate({ accessToken: trimmedToken }) + // If successful, pass the token to parent + if (!props.onLogin) { + setError('Login is unavailable.') + return + } + props.onLogin(trimmedToken) + } } catch (e) { - setError(e instanceof Error ? e.message : 'Authentication failed') + const fallbackMessage = isBindMode ? 'Binding failed' : 'Authentication failed' + setError(e instanceof Error ? e.message : fallbackMessage) } finally { setIsLoading(false) } @@ -76,73 +92,81 @@ export function LoginPrompt(props: LoginPromptProps) { const displayError = error || props.error const serverSummary = props.serverUrl ?? `${props.baseUrl} (same origin)` + const title = isBindMode ? 'Bind Telegram' : 'HAPI' + const subtitle = isBindMode + ? 'Enter your access token to bind this Telegram account' + : 'Enter your access token to continue' + const submitLabel = isBindMode ? 'Bind' : 'Sign In' + const helpText = 'Use the CLI_API_TOKEN from your server configuration' return (
-
- - - - - - - Server URL - - Set the hapi server origin for API and live updates. - - -
-
- Current: {serverSummary} -
-
- - { - setServerInput(e.target.value) - setServerError(null) - }} - placeholder="https://hapi.example.com" - className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent" - /> -
- Use http(s) only. Any path is ignored. + {!isBindMode && ( +
+ + + + + + + Server URL + + Set the hapi server origin for API and live updates. + + + +
+ Current: {serverSummary}
-
- - {serverError && ( -
- {serverError} +
+ + { + setServerInput(e.target.value) + setServerError(null) + }} + placeholder="https://hapi.example.com" + className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent" + /> +
+ Use http(s) only. Any path is ignored. +
- )} -
- {props.serverUrl && ( - + {serverError && ( +
+ {serverError} +
)} - -
- - -
-
+ +
+ {props.serverUrl && ( + + )} + +
+ + + +
+ )}
{/* Header */}
-
HAPI
+
{title}
- Enter your access token to continue + {subtitle}
@@ -153,7 +177,7 @@ export function LoginPrompt(props: LoginPromptProps) { type="password" value={accessToken} onChange={(e) => setAccessToken(e.target.value)} - placeholder="Access Token" + placeholder={isBindMode ? 'CLI_API_TOKEN' : 'Access Token'} autoComplete="current-password" disabled={isLoading} className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50" @@ -175,17 +199,17 @@ export function LoginPrompt(props: LoginPromptProps) { {isLoading ? ( <> - Signing in… + {isBindMode ? 'Binding...' : 'Signing in...'} ) : ( - 'Sign In' + submitLabel )} {/* Help text */}
- Use the CLI_API_TOKEN from your server configuration + {helpText}
diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 43ff589b..054ced5a 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ApiClient } from '@/api/client' +import { ApiClient, ApiError } from '@/api/client' import type { AuthResponse } from '@/types/api' export type AuthSource = @@ -33,17 +33,24 @@ function getAuthPayload(source: AuthSource): { initData: string } | { accessToke return { accessToken: source.token } } +function isNotBoundError(error: unknown): boolean { + return error instanceof ApiError && error.status === 401 && error.code === 'not_bound' +} + export function useAuth(authSource: AuthSource | null, baseUrl: string): { token: string | null user: AuthResponse['user'] | null api: ApiClient | null isLoading: boolean error: string | null + needsBinding: boolean + bind: (accessToken: string) => Promise } { const [token, setToken] = useState(null) const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [needsBinding, setNeedsBinding] = useState(false) const refreshPromiseRef = useRef | null>(null) const tokenRef = useRef(null) const lastRefreshAttemptRef = useRef(0) @@ -89,8 +96,17 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { setToken(auth.token) setUser(auth.user) setError(null) + setNeedsBinding(false) return auth.token - } catch { + } catch (error) { + if (currentSource.type === 'telegram' && isNotBoundError(error)) { + tokenRef.current = null + setToken(null) + setUser(null) + setError(null) + setNeedsBinding(true) + return null + } const isExpired = expMs ? Date.now() >= expMs : false if (options?.hardFail || isExpired) { tokenRef.current = null @@ -117,6 +133,30 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { } }, [baseUrl]) + const bind = useCallback(async (accessToken: string) => { + const currentSource = authSourceRef.current + if (!currentSource || currentSource.type !== 'telegram') { + setError('Binding is only supported in Telegram.') + return + } + + setIsLoading(true) + setError(null) + try { + const client = new ApiClient('', { baseUrl }) + const auth = await client.bind({ initData: currentSource.initData, accessToken }) + tokenRef.current = auth.token + setToken(auth.token) + setUser(auth.user) + setNeedsBinding(false) + } catch (error) { + setError(error instanceof Error ? error.message : 'Binding failed') + throw error + } finally { + setIsLoading(false) + } + }, [baseUrl]) + const api = useMemo(() => ( token ? new ApiClient(token, { @@ -133,19 +173,30 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { async function run() { if (!authSource) { // No auth source - waiting for login + setNeedsBinding(false) return } setIsLoading(true) setError(null) + setNeedsBinding(false) try { const client = new ApiClient('', { baseUrl }) // temporary for auth call const auth = await client.authenticate(getAuthPayload(authSource)) if (isCancelled) return setToken(auth.token) setUser(auth.user) + setNeedsBinding(false) } catch (e) { if (isCancelled) return + if (authSource.type === 'telegram' && isNotBoundError(e)) { + setToken(null) + setUser(null) + setError(null) + setNeedsBinding(true) + return + } + setNeedsBinding(false) setError(e instanceof Error ? e.message : 'Auth failed') } finally { if (!isCancelled) { @@ -168,6 +219,7 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { setToken(null) setUser(null) setError(null) + setNeedsBinding(false) }, [baseUrl]) useEffect(() => { @@ -233,5 +285,5 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { } }, [authSource, refreshAuth]) - return { token, user, api, isLoading, error } + return { token, user, api, isLoading, error, needsBinding, bind } }