From cf2b96b566ab649dc3bdb9a9d74ab4e6746fdfdf Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 18 Dec 2025 12:43:46 +0800 Subject: [PATCH] feat: add browser environment support with access token authentication Enable the web client to run in plain browser environments alongside Telegram Mini App support: - Server: Add CLI_API_TOKEN authentication path as alternative to Telegram initData - Client: Add useAuthSource hook to detect and manage Telegram vs browser auth sources - Client: Add usePlatform hook for platform abstraction with graceful haptic feedback degradation - Client: Add LoginPrompt component for browser access token login - Client: Extend useTheme to fall back to system prefers-color-scheme in browser - Client: Migrate all direct HapticFeedback calls to use usePlatform hook --- server/src/web/routes/auth.ts | 55 +++++-- web/src/App.tsx | 82 +++++------ web/src/api/client.ts | 4 +- .../AssistantChat/HappyComposer.tsx | 12 +- web/src/components/CodeBlock.tsx | 113 ++++++++++++++ web/src/components/LoginPrompt.tsx | 88 +++++++++++ web/src/components/SessionChat.tsx | 15 +- web/src/components/SpawnSession.tsx | 9 +- .../components/ToolCard/PermissionFooter.tsx | 9 +- .../components/assistant-ui/markdown-text.tsx | 7 +- web/src/hooks/useAuth.ts | 42 ++++-- web/src/hooks/useAuthSource.ts | 139 ++++++++++++++++++ web/src/hooks/usePlatform.ts | 55 +++++++ web/src/hooks/useTheme.ts | 18 ++- 14 files changed, 552 insertions(+), 96 deletions(-) create mode 100644 web/src/components/CodeBlock.tsx create mode 100644 web/src/components/LoginPrompt.tsx create mode 100644 web/src/hooks/useAuthSource.ts create mode 100644 web/src/hooks/usePlatform.ts diff --git a/server/src/web/routes/auth.ts b/server/src/web/routes/auth.ts index a0fd176f..6ff85184 100644 --- a/server/src/web/routes/auth.ts +++ b/server/src/web/routes/auth.ts @@ -5,10 +5,16 @@ import { configuration } from '../../configuration' import { validateTelegramInitData } from '../telegramInitData' import type { WebAppEnv } from '../middleware/auth' -const authBodySchema = z.object({ +const telegramAuthSchema = z.object({ initData: z.string() }) +const accessTokenAuthSchema = z.object({ + accessToken: z.string() +}) + +const authBodySchema = z.union([telegramAuthSchema, accessTokenAuthSchema]) + export function createAuthRoutes(jwtSecret: Uint8Array): Hono { const app = new Hono() @@ -19,18 +25,37 @@ export function createAuthRoutes(jwtSecret: Uint8Array): Hono { return c.json({ error: 'Invalid body' }, 400) } - const initData = parsed.data.initData - const result = validateTelegramInitData(initData, configuration.telegramBotToken) - if (!result.ok) { - return c.json({ error: result.error }, 401) + let userId: number + let username: string | undefined + let firstName: string | undefined + let lastName: string | undefined + + // Access Token authentication (CLI_API_TOKEN) + if ('accessToken' in parsed.data) { + if (parsed.data.accessToken !== configuration.cliApiToken) { + return c.json({ error: 'Invalid access token' }, 401) + } + // Use first allowed chat ID as the shared user identity + userId = configuration.allowedChatIds[0] + firstName = 'Web User' + } else { + // Telegram initData authentication + const result = validateTelegramInitData(parsed.data.initData, configuration.telegramBotToken) + if (!result.ok) { + return c.json({ error: result.error }, 401) + } + + userId = result.user.id + if (!configuration.isChatIdAllowed(userId)) { + return c.json({ error: 'User not allowed' }, 403) + } + + username = result.user.username + firstName = result.user.first_name + lastName = result.user.last_name } - const telegramUserId = result.user.id - if (!configuration.isChatIdAllowed(telegramUserId)) { - return c.json({ error: 'User not allowed' }, 403) - } - - const token = await new SignJWT({ uid: telegramUserId }) + const token = await new SignJWT({ uid: userId }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('15m') @@ -39,10 +64,10 @@ export function createAuthRoutes(jwtSecret: Uint8Array): Hono { return c.json({ token, user: { - id: telegramUserId, - username: result.user.username, - firstName: result.user.first_name, - lastName: result.user.last_name + id: userId, + username, + firstName, + lastName } }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 68ce6fa4..82e3973e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,28 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTelegramWebApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' +import { useAuthSource } from '@/hooks/useAuthSource' +import { usePlatform } from '@/hooks/usePlatform' import { useSocket } from '@/hooks/useSocket' import type { DecryptedMessage, Machine, Session, SessionSummary, SyncEvent } from '@/types/api' import { SessionList } from '@/components/SessionList' import { SessionChat } from '@/components/SessionChat' import { MachineList } from '@/components/MachineList' import { SpawnSession } from '@/components/SpawnSession' - -function getInitData(): string | null { - const tg = getTelegramWebApp() - if (tg?.initData) { - return tg.initData - } - - const query = new URLSearchParams(window.location.search) - const tgWebAppData = query.get('tgWebAppData') - if (tgWebAppData) { - return tgWebAppData - } - - const fromQuery = new URLSearchParams(window.location.search).get('initData') - return fromQuery || null -} +import { LoginPrompt } from '@/components/LoginPrompt' type Screen = | { type: 'sessions' } @@ -134,8 +121,9 @@ function mergeMessages(existing: DecryptedMessage[], incoming: DecryptedMessage[ } export function App() { - const [initData, setInitData] = useState(() => getInitData()) - const { token, api, isLoading: isAuthLoading, error: authError, user } = useAuth(initData) + const { authSource, isLoading: isAuthSourceLoading, isTelegram, setAccessToken, clearAuth } = useAuthSource() + const { token, api, isLoading: isAuthLoading, error: authError, user } = useAuth(authSource) + const { haptic } = usePlatform() const [screen, setScreen] = useState(() => { const deepLinkedSessionId = getDeepLinkedSessionId() @@ -237,27 +225,6 @@ export function App() { } }, [goBack, screen.type]) - useEffect(() => { - if (initData) { - return - } - - let attempts = 0 - const interval = setInterval(() => { - attempts += 1 - const next = getInitData() - if (next) { - setInitData(next) - clearInterval(interval) - } else if (attempts >= 20) { - clearInterval(interval) - } - }, 250) - - return () => { - clearInterval(interval) - } - }, [initData]) const loadSessions = useCallback(async () => { if (!api) return @@ -351,7 +318,7 @@ export function App() { api.sendMessage(selectedSessionId, text, localId) .then(() => { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success') + haptic.notification('success') setMessages((prev) => prev.map(m => m.localId === localId ? { ...m, status: 'sent' as const } @@ -360,7 +327,7 @@ export function App() { ) }) .catch(() => { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') setMessages((prev) => prev.map(m => m.localId === localId ? { ...m, status: 'failed' as const } @@ -450,6 +417,21 @@ export function App() { } }) + // Loading auth source + if (isAuthSourceLoading) { + return ( +
+
Loading…
+
+ ) + } + + // No auth source (browser environment, not logged in) + if (!authSource) { + return + } + + // Authenticating if (isAuthLoading) { return (
@@ -458,7 +440,19 @@ export function App() { ) } + // Auth error if (authError || !token || !api) { + // If using access token and auth failed, show login again + if (authSource.type === 'accessToken') { + return ( + + ) + } + + // Telegram auth failed return (
Happy Mini App
@@ -466,7 +460,7 @@ export function App() { {authError ?? 'Not authorized'}
- Open this page from Telegram using the bot’s “Open App” button (not “Open in browser”). + Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
) @@ -530,7 +524,7 @@ export function App() { api.sendMessage(screen.sessionId, text, localId) .then(() => { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success') + haptic.notification('success') // Update status to sent setMessages((prev) => prev.map(m => m.localId === localId @@ -540,7 +534,7 @@ export function App() { ) }) .catch(() => { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') // Update status to failed setMessages((prev) => prev.map(m => m.localId === localId diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 844ddfe6..388ef644 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -34,11 +34,11 @@ export class ApiClient { return await res.json() as T } - async authenticate(initData: string): Promise { + async authenticate(auth: { initData: string } | { accessToken: string }): Promise { const res = await fetch('/api/auth', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ initData }) + body: JSON.stringify(auth) }) if (!res.ok) { diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 849c8d58..f181ddd1 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -14,7 +14,7 @@ import type { Suggestion } from '@/hooks/useActiveSuggestions' import { useActiveWord } from '@/hooks/useActiveWord' import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { usePlatform } from '@/hooks/usePlatform' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { StatusBar } from '@/components/AssistantChat/StatusBar' @@ -95,6 +95,7 @@ export function HappyComposer(props: { }) }, [composerText]) + const { haptic: platformHaptic } = usePlatform() const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes) const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( activeWord, @@ -103,15 +104,14 @@ export function HappyComposer(props: { ) const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => { - const tg = getTelegramWebApp() if (type === 'light') { - tg?.HapticFeedback?.impactOccurred('light') + platformHaptic.impact('light') } else if (type === 'success') { - tg?.HapticFeedback?.notificationOccurred('success') + platformHaptic.notification('success') } else { - tg?.HapticFeedback?.notificationOccurred('error') + platformHaptic.notification('error') } - }, []) + }, [platformHaptic]) const handleSuggestionSelect = useCallback((index: number) => { const suggestion = suggestions[index] diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx new file mode 100644 index 00000000..063123af --- /dev/null +++ b/web/src/components/CodeBlock.tsx @@ -0,0 +1,113 @@ +import type { Themes } from 'react-shiki/web' +import { useMemo, useState } from 'react' +import { useShikiHighlighter } from 'react-shiki/web' +import { usePlatform } from '@/hooks/usePlatform' + +const SHIKI_THEMES: Themes = { + light: 'github-light', + dark: 'github-dark', +} + +function normalizeLanguage(language?: string): string { + const raw = language?.trim() + if (!raw) return 'text' + const cleaned = raw.startsWith('language-') ? raw.slice('language-'.length) : raw + const canonical = cleaned.toLowerCase() + if (canonical === 'text' || canonical === 'plaintext' || canonical === 'txt') return 'text' + return cleaned +} + +function safeCopyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text) + } + return Promise.reject(new Error('Clipboard API not available')) +} + +function CopyIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function CheckIcon(props: { className?: string }) { + return ( + + + + ) +} + +export function CodeBlock(props: { + code: string + language?: string + showCopyButton?: boolean +}) { + const { haptic } = usePlatform() + const showCopyButton = props.showCopyButton ?? true + const normalizedLanguage = useMemo(() => normalizeLanguage(props.language), [props.language]) + + const [copied, setCopied] = useState(false) + + const highlighted = useShikiHighlighter(props.code, normalizedLanguage, SHIKI_THEMES, { + delay: 75, + structure: 'inline', + }) + + const handleCopy = async () => { + try { + await safeCopyToClipboard(props.code) + haptic.notification('success') + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + haptic.notification('error') + } + } + + return ( +
+ {showCopyButton ? ( + + ) : null} + +
+                
+                    {highlighted ?? props.code}
+                
+            
+
+ ) +} diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx new file mode 100644 index 00000000..0680ee34 --- /dev/null +++ b/web/src/components/LoginPrompt.tsx @@ -0,0 +1,88 @@ +import { useCallback, useState } from 'react' +import { ApiClient } from '@/api/client' + +type LoginPromptProps = { + onLogin: (token: string) => void + error?: string | null +} + +export function LoginPrompt(props: LoginPromptProps) { + const [accessToken, setAccessToken] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault() + + const trimmedToken = accessToken.trim() + if (!trimmedToken) { + setError('Please enter an access token') + return + } + + setIsLoading(true) + setError(null) + + try { + // Validate the token by attempting to authenticate + const client = new ApiClient('') + await client.authenticate({ accessToken: trimmedToken }) + // If successful, pass the token to parent + props.onLogin(trimmedToken) + } catch (e) { + setError(e instanceof Error ? e.message : 'Authentication failed') + } finally { + setIsLoading(false) + } + }, [accessToken, props]) + + const displayError = error || props.error + + return ( +
+
+ {/* Header */} +
+
Happy
+
+ Enter your access token to continue +
+
+ + {/* Form */} +
+
+ setAccessToken(e.target.value)} + placeholder="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" + /> +
+ + {displayError && ( +
+ {displayError} +
+ )} + + +
+ + {/* Help text */} +
+ Use the CLI_API_TOKEN from your server configuration +
+
+
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 26bd8b6d..d06de285 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -10,7 +10,7 @@ import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import { HappyThread } from '@/components/AssistantChat/HappyThread' import { useHappyRuntime } from '@/lib/assistant-runtime' import { SessionHeader } from '@/components/SessionHeader' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { usePlatform } from '@/hooks/usePlatform' export function SessionChat(props: { api: ApiClient @@ -27,6 +27,7 @@ export function SessionChat(props: { onSend: (text: string) => void onRetryMessage?: (localId: string) => void }) { + const { haptic } = usePlatform() const controlsDisabled = !props.session.active const normalizedCacheRef = useRef>(new Map()) @@ -63,25 +64,25 @@ export function SessionChat(props: { const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { try { await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan') - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success') + haptic.notification('success') props.onRefresh() } catch (e) { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') console.error('Failed to set permission mode:', e) } - }, [props.api, props.session.id, props.onRefresh]) + }, [props.api, props.session.id, props.onRefresh, haptic]) // Model mode change handler const handleModelModeChange = useCallback(async (mode: ModelMode) => { try { await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus') - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success') + haptic.notification('success') props.onRefresh() } catch (e) { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') console.error('Failed to set model mode:', e) } - }, [props.api, props.session.id, props.onRefresh]) + }, [props.api, props.session.id, props.onRefresh, haptic]) // Abort handler const handleAbort = useCallback(async () => { diff --git a/web/src/components/SpawnSession.tsx b/web/src/components/SpawnSession.tsx index cf232939..b93b84a7 100644 --- a/web/src/components/SpawnSession.tsx +++ b/web/src/components/SpawnSession.tsx @@ -3,7 +3,7 @@ import type { ApiClient } from '@/api/client' import type { Machine } from '@/types/api' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { usePlatform } from '@/hooks/usePlatform' function getMachineTitle(machine: Machine | null): string { if (!machine) return 'Machine' @@ -19,6 +19,7 @@ export function SpawnSession(props: { onSuccess: (sessionId: string) => void onCancel: () => void }) { + const { haptic } = usePlatform() const [directory, setDirectory] = useState('') const [isWorking, setIsWorking] = useState(false) const [error, setError] = useState(null) @@ -34,14 +35,14 @@ export function SpawnSession(props: { try { const result = await props.api.spawnSession(props.machineId, trimmed) if (result.type === 'success') { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success') + haptic.notification('success') props.onSuccess(result.sessionId) return } - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') setError(result.message) } catch (e) { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') setError(e instanceof Error ? e.message : 'Failed to spawn session') } finally { setIsWorking(false) diff --git a/web/src/components/ToolCard/PermissionFooter.tsx b/web/src/components/ToolCard/PermissionFooter.tsx index 16ebe2e3..53ee4dd3 100644 --- a/web/src/components/ToolCard/PermissionFooter.tsx +++ b/web/src/components/ToolCard/PermissionFooter.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' import type { ChatToolCall, ToolPermission } from '@/chat/types' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { usePlatform } from '@/hooks/usePlatform' function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' @@ -108,6 +108,7 @@ export function PermissionFooter(props: { disabled: boolean onDone: () => void }) { + const { haptic } = usePlatform() const permission = props.tool.permission const [loading, setLoading] = useState<'allow' | 'deny' | 'abort' | null>(null) const [loadingAllEdits, setLoadingAllEdits] = useState(false) @@ -121,15 +122,15 @@ export function PermissionFooter(props: { const summary = formatPermissionSummary(permission, props.tool.name, props.tool.input, codex) const isPending = permission.status === 'pending' - const run = async (action: () => Promise, haptic: 'success' | 'error') => { + const run = async (action: () => Promise, hapticType: 'success' | 'error') => { if (props.disabled) return setError(null) try { await action() - getTelegramWebApp()?.HapticFeedback?.notificationOccurred(haptic) + haptic.notification(hapticType) props.onDone() } catch (e) { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') setError(e instanceof Error ? e.message : 'Request failed') } } diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 2f52761a..8ec911dd 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -8,7 +8,7 @@ import { type CodeHeaderProps, } from '@assistant-ui/react-markdown' import remarkGfm from 'remark-gfm' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { getPlatform } from '@/hooks/usePlatform' import { cn } from '@/lib/utils' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' @@ -66,13 +66,14 @@ function CodeHeader(props: CodeHeaderProps) { const language = props.language && props.language !== 'unknown' ? props.language : '' const handleCopy = async () => { + const { haptic } = getPlatform() try { await safeCopyToClipboard(props.code) - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success') + haptic.notification('success') setCopied(true) setTimeout(() => setCopied(false), 1500) } catch { - getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + haptic.notification('error') } } diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 34edfbba..5518102a 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ApiClient } from '@/api/client' import type { AuthResponse } from '@/types/api' +export type AuthSource = + | { type: 'telegram'; initData: string } + | { type: 'accessToken'; token: string } + function decodeJwtExpMs(token: string): number | null { const parts = token.split('.') if (parts.length < 2) return null @@ -22,7 +26,14 @@ function decodeJwtExpMs(token: string): number | null { } } -export function useAuth(initData: string | null): { +function getAuthPayload(source: AuthSource): { initData: string } | { accessToken: string } { + if (source.type === 'telegram') { + return { initData: source.initData } + } + return { accessToken: source.token } +} + +export function useAuth(authSource: AuthSource | null): { token: string | null user: AuthResponse['user'] | null api: ApiClient | null @@ -37,12 +48,16 @@ export function useAuth(initData: string | null): { const api = useMemo(() => (token ? new ApiClient(token) : null), [token]) + // Stable reference for auth source to use in effects + const authSourceRef = useRef(authSource) + authSourceRef.current = authSource + useEffect(() => { let isCancelled = false async function run() { - if (!initData) { - setError('Missing Telegram initData (open inside Telegram)') + if (!authSource) { + // No auth source - waiting for login return } @@ -50,7 +65,7 @@ export function useAuth(initData: string | null): { setError(null) try { const client = new ApiClient('') // temporary for auth call - const auth = await client.authenticate(initData) + const auth = await client.authenticate(getAuthPayload(authSource)) if (isCancelled) return setToken(auth.token) setUser(auth.user) @@ -69,10 +84,10 @@ export function useAuth(initData: string | null): { return () => { isCancelled = true } - }, [initData]) + }, [authSource]) useEffect(() => { - if (!token || !initData) { + if (!token || !authSource) { return } @@ -96,9 +111,15 @@ export function useAuth(initData: string | null): { if (refreshInFlightRef.current) return refreshInFlightRef.current = true + const currentSource = authSourceRef.current + if (!currentSource) { + refreshInFlightRef.current = false + return + } + try { const client = new ApiClient('') - const auth = await client.authenticate(initData) + const auth = await client.authenticate(getAuthPayload(currentSource)) if (isCancelled) return setToken(auth.token) setUser(auth.user) @@ -107,7 +128,10 @@ export function useAuth(initData: string | null): { if (Date.now() >= expMs) { setToken(null) setUser(null) - setError('Session expired. Reopen the Mini App from Telegram.') + const msg = currentSource.type === 'telegram' + ? 'Session expired. Reopen the Mini App from Telegram.' + : 'Session expired. Please login again.' + setError(msg) return } schedule(15_000) @@ -124,7 +148,7 @@ export function useAuth(initData: string | null): { clearTimeout(timeout) } } - }, [initData, token]) + }, [authSource, token]) return { token, user, api, isLoading, error } } diff --git a/web/src/hooks/useAuthSource.ts b/web/src/hooks/useAuthSource.ts new file mode 100644 index 00000000..8439642e --- /dev/null +++ b/web/src/hooks/useAuthSource.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getTelegramWebApp } from './useTelegram' +import type { AuthSource } from './useAuth' + +const ACCESS_TOKEN_KEY = 'hapi_access_token' + +function getTelegramInitData(): string | null { + const tg = getTelegramWebApp() + if (tg?.initData) { + return tg.initData + } + + // Fallback: check URL parameters (for testing or alternative flows) + const query = new URLSearchParams(window.location.search) + const tgWebAppData = query.get('tgWebAppData') + if (tgWebAppData) { + return tgWebAppData + } + + const initData = query.get('initData') + return initData || null +} + +function getStoredAccessToken(): string | null { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY) + } catch { + return null + } +} + +function storeAccessToken(token: string): void { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, token) + } catch { + // Ignore storage errors + } +} + +function clearStoredAccessToken(): void { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY) + } catch { + // Ignore storage errors + } +} + +export function useAuthSource(): { + authSource: AuthSource | null + isLoading: boolean + isTelegram: boolean + setAccessToken: (token: string) => void + clearAuth: () => void +} { + const [authSource, setAuthSource] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isTelegram, setIsTelegram] = useState(false) + const retryCountRef = useRef(0) + + // Initialize auth source on mount, with retry for delayed Telegram initData + useEffect(() => { + const telegramInitData = getTelegramInitData() + + if (telegramInitData) { + // Telegram Mini App environment + setAuthSource({ type: 'telegram', initData: telegramInitData }) + setIsTelegram(true) + setIsLoading(false) + return + } + + // Check for stored access token as fallback + const storedToken = getStoredAccessToken() + if (storedToken) { + setAuthSource({ type: 'accessToken', token: storedToken }) + setIsLoading(false) + return + } + + // Check if we're likely in a Telegram environment before polling + // Hints: Telegram object exists (SDK loading), iframe, or Telegram URL params (in query or hash) + const hasTelegramHint = + typeof window !== 'undefined' && ( + window.Telegram !== undefined || + window.self !== window.top || + window.location.search.includes('tgWebApp') || + window.location.hash.includes('tgWebApp') + ) + + if (!hasTelegramHint) { + // Plain browser - show login prompt immediately + setIsLoading(false) + return + } + + // Telegram environment detected - poll for delayed initData + // Telegram WebApp SDK may initialize slightly after page mount + const maxRetries = 20 + const retryInterval = 250 // ms + + const interval = setInterval(() => { + retryCountRef.current += 1 + const initData = getTelegramInitData() + + if (initData) { + setAuthSource({ type: 'telegram', initData }) + setIsTelegram(true) + setIsLoading(false) + clearInterval(interval) + } else if (retryCountRef.current >= maxRetries) { + // Give up - show login prompt for browser access + setIsLoading(false) + clearInterval(interval) + } + }, retryInterval) + + return () => { + clearInterval(interval) + } + }, []) + + const setAccessToken = useCallback((token: string) => { + storeAccessToken(token) + setAuthSource({ type: 'accessToken', token }) + }, []) + + const clearAuth = useCallback(() => { + clearStoredAccessToken() + setAuthSource(null) + }, []) + + return { + authSource, + isLoading, + isTelegram, + setAccessToken, + clearAuth + } +} diff --git a/web/src/hooks/usePlatform.ts b/web/src/hooks/usePlatform.ts new file mode 100644 index 00000000..1fffb402 --- /dev/null +++ b/web/src/hooks/usePlatform.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { getTelegramWebApp } from './useTelegram' + +export type HapticStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' +export type HapticNotification = 'error' | 'success' | 'warning' + +export type PlatformHaptic = { + /** Trigger impact feedback */ + impact: (style: HapticStyle) => void + /** Trigger notification feedback */ + notification: (type: HapticNotification) => void + /** Trigger selection changed feedback */ + selection: () => void +} + +export type Platform = { + /** Whether running in Telegram Mini App */ + isTelegram: boolean + /** Haptic feedback (no-op on browser) */ + haptic: PlatformHaptic +} + +function createHaptic(): PlatformHaptic { + return { + impact: (style: HapticStyle) => { + getTelegramWebApp()?.HapticFeedback?.impactOccurred(style) + }, + notification: (type: HapticNotification) => { + getTelegramWebApp()?.HapticFeedback?.notificationOccurred(type) + }, + selection: () => { + getTelegramWebApp()?.HapticFeedback?.selectionChanged() + } + } +} + +// Singleton haptic instance (functions are stable) +const haptic = createHaptic() + +export function usePlatform(): Platform { + const isTelegram = useMemo(() => getTelegramWebApp() !== null, []) + + return { + isTelegram, + haptic + } +} + +// Non-hook version for use outside React components +export function getPlatform(): Platform { + return { + isTelegram: getTelegramWebApp() !== null, + haptic + } +} diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index ba691479..3a5ab58e 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -5,7 +5,16 @@ type ColorScheme = 'light' | 'dark' function getColorScheme(): ColorScheme { const tg = getTelegramWebApp() - return tg?.colorScheme === 'dark' ? 'dark' : 'light' + if (tg?.colorScheme) { + return tg.colorScheme === 'dark' ? 'dark' : 'light' + } + + // Fallback to system preference for browser environment + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + + return 'light' } function isIOS(): boolean { @@ -47,10 +56,15 @@ function updateScheme(): void { // Initialize theme on module load applyTheme(currentScheme) -// Listen for Telegram theme changes +// Listen for theme changes const tg = getTelegramWebApp() if (tg?.onEvent) { + // Telegram theme changes tg.onEvent('themeChanged', updateScheme) +} else if (typeof window !== 'undefined' && window.matchMedia) { + // Browser system preference changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + mediaQuery.addEventListener('change', updateScheme) } export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } {