diff --git a/server/README.md b/server/README.md index 6af05db8..250010c4 100644 --- a/server/README.md +++ b/server/README.md @@ -23,7 +23,7 @@ See `src/configuration.ts` for all options. - `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. +- `WEBAPP_URL` - Public HTTPS URL for Telegram Mini App access. Also used to derive default CORS origins for the web app. ### Optional @@ -200,4 +200,14 @@ The server build output is `server/dist/index.js`, and the web assets are in `we ## Networking notes - Telegram Mini Apps require HTTPS and a public URL. If the server has no public IP, use Cloudflare Tunnel or Tailscale and set `WEBAPP_URL` to the HTTPS endpoint. -- If the web app is hosted on a different origin, set `CORS_ORIGINS` accordingly. +- If the web app is hosted on a different origin, set `CORS_ORIGINS` (or `WEBAPP_URL`) to include that static host origin. + +## Standalone web hosting + +The web UI can be hosted separately from the server (for example on GitHub Pages or Cloudflare Pages): + +1. Build and deploy `web/dist` from the repo root. +2. Set `CORS_ORIGINS` (or `WEBAPP_URL`) to the static host origin. +3. Open the static site, click the Server button on the login screen, and enter the hapi server origin. + +Leaving the server override empty preserves the default same-origin behavior when the server serves the web assets directly. diff --git a/web/README.md b/web/README.md index 1437bd16..0bfe2e08 100644 --- a/web/README.md +++ b/web/README.md @@ -16,6 +16,7 @@ React Mini App / PWA for monitoring and controlling hapi sessions. - When opened inside Telegram, auth uses Telegram WebApp init data. - When opened in a normal browser, you can log in with the shared `CLI_API_TOKEN`. +- The login screen includes a top-right server picker; if unset, the app uses the same origin it was loaded from. - Live updates come from the server via SSE. ## Routes @@ -122,3 +123,19 @@ bun run build:web ``` The built assets land in `web/dist` and are served by hapi-server. The single executable can embed these assets. + +## Standalone hosting + +You can host `web/dist` on a static host (GitHub Pages, Cloudflare Pages) and point it at any hapi server: + +1. Build the web app. If your static host uses a subpath, set the Vite base: + +```bash +bun run build:web -- --base // +``` + +2. Deploy `web/dist` to your static host. +3. Set server CORS to allow the static origin (`WEBAPP_URL` or `CORS_ORIGINS`). +4. Open the static site, click the top-right Server button on the login screen, and enter the hapi server origin. + +Clear the server override in the same dialog to return to same-origin behavior. diff --git a/web/src/App.tsx b/web/src/App.tsx index 72c61060..1904dfba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { getTelegramWebApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' +import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { queryKeys } from '@/lib/query-keys' @@ -17,8 +18,9 @@ import { SyncingBanner } from '@/components/SyncingBanner' import { LoadingState } from '@/components/LoadingState' export function App() { - const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource() - const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource) + const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() + const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) + const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource, baseUrl) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() @@ -90,6 +92,17 @@ export function App() { const { isSyncing, startSync, endSync } = useSyncingState() const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) + const baseUrlRef = useRef(baseUrl) + + useEffect(() => { + if (baseUrlRef.current === baseUrl) { + return + } + baseUrlRef.current = baseUrl + isFirstConnectRef.current = true + syncTokenRef.current = 0 + queryClient.clear() + }, [baseUrl, queryClient]) const handleSseConnect = useCallback(() => { // Increment token to track this specific connection @@ -135,6 +148,7 @@ export function App() { useSSE({ enabled: Boolean(api && token), token: token ?? '', + baseUrl, subscription: eventSubscription, onConnect: handleSseConnect, onEvent: handleSseEvent, @@ -151,7 +165,15 @@ export function App() { // No auth source (browser environment, not logged in) if (!authSource) { - return + return ( + + ) } // Authenticating (also covers the gap before useAuth effect starts) @@ -170,6 +192,10 @@ export function App() { return ( ) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6db1bd49..98139fbe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -11,21 +11,35 @@ import type { } from '@/types/api' type ApiClientOptions = { + baseUrl?: string getToken?: () => string | null onUnauthorized?: () => Promise } export class ApiClient { private token: string + private readonly baseUrl: string | null private readonly getToken: (() => string | null) | null private readonly onUnauthorized: (() => Promise) | null constructor(token: string, options?: ApiClientOptions) { this.token = token + this.baseUrl = options?.baseUrl ?? null this.getToken = options?.getToken ?? null this.onUnauthorized = options?.onUnauthorized ?? null } + private buildUrl(path: string): string { + if (!this.baseUrl) { + return path + } + try { + return new URL(path, this.baseUrl).toString() + } catch { + return path + } + } + private async request( path: string, init?: RequestInit, @@ -44,7 +58,7 @@ export class ApiClient { headers.set('content-type', 'application/json') } - const res = await fetch(path, { + const res = await fetch(this.buildUrl(path), { ...init, headers }) @@ -69,7 +83,7 @@ export class ApiClient { } async authenticate(auth: { initData: string } | { accessToken: string }): Promise { - const res = await fetch('/api/auth', { + const res = await fetch(this.buildUrl('/api/auth'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(auth) diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx index 9e444455..01f5c3f9 100644 --- a/web/src/components/LoginPrompt.tsx +++ b/web/src/components/LoginPrompt.tsx @@ -1,9 +1,16 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { ApiClient } from '@/api/client' import { Spinner } from '@/components/Spinner' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import type { ServerUrlResult } from '@/hooks/useServerUrl' type LoginPromptProps = { onLogin: (token: string) => void + baseUrl: string + serverUrl: string | null + setServerUrl: (input: string) => ServerUrlResult + clearServerUrl: () => void error?: string | null } @@ -11,6 +18,9 @@ export function LoginPrompt(props: LoginPromptProps) { const [accessToken, setAccessToken] = useState('') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [isServerDialogOpen, setIsServerDialogOpen] = useState(false) + const [serverInput, setServerInput] = useState(props.serverUrl ?? '') + const [serverError, setServerError] = useState(null) const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() @@ -26,7 +36,7 @@ export function LoginPrompt(props: LoginPromptProps) { try { // Validate the token by attempting to authenticate - const client = new ApiClient('') + const client = new ApiClient('', { baseUrl: props.baseUrl }) await client.authenticate({ accessToken: trimmedToken }) // If successful, pass the token to parent props.onLogin(trimmedToken) @@ -37,10 +47,96 @@ export function LoginPrompt(props: LoginPromptProps) { } }, [accessToken, props]) + useEffect(() => { + if (!isServerDialogOpen) { + return + } + setServerInput(props.serverUrl ?? '') + setServerError(null) + }, [isServerDialogOpen, props.serverUrl]) + + const handleSaveServer = useCallback((e: React.FormEvent) => { + e.preventDefault() + const result = props.setServerUrl(serverInput) + if (!result.ok) { + setServerError(result.error) + return + } + setServerError(null) + setServerInput(result.value) + setIsServerDialogOpen(false) + }, [props, serverInput]) + + const handleClearServer = useCallback(() => { + props.clearServerUrl() + setServerInput('') + setServerError(null) + setIsServerDialogOpen(false) + }, [props]) + const displayError = error || props.error + const serverSummary = props.serverUrl ?? `${props.baseUrl} (same origin)` 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. +
+
+ + {serverError && ( +
+ {serverError} +
+ )} + +
+ {props.serverUrl && ( + + )} + +
+
+
+
+
{/* Header */}
diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 44cc0754..43ff589b 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -33,7 +33,7 @@ function getAuthPayload(source: AuthSource): { initData: string } | { accessToke return { accessToken: source.token } } -export function useAuth(authSource: AuthSource | null): { +export function useAuth(authSource: AuthSource | null, baseUrl: string): { token: string | null user: AuthResponse['user'] | null api: ApiClient | null @@ -83,7 +83,7 @@ export function useAuth(authSource: AuthSource | null): { lastRefreshAttemptRef.current = now try { - const client = new ApiClient('') + const client = new ApiClient('', { baseUrl }) const auth = await client.authenticate(getAuthPayload(currentSource)) tokenRef.current = auth.token setToken(auth.token) @@ -115,16 +115,17 @@ export function useAuth(authSource: AuthSource | null): { refreshPromiseRef.current = null } } - }, []) + }, [baseUrl]) const api = useMemo(() => ( token ? new ApiClient(token, { + baseUrl, getToken: () => tokenRef.current, onUnauthorized: () => refreshAuth({ force: true }) }) : null - ), [refreshAuth, token]) + ), [baseUrl, refreshAuth, token]) useEffect(() => { let isCancelled = false @@ -138,7 +139,7 @@ export function useAuth(authSource: AuthSource | null): { setIsLoading(true) setError(null) try { - const client = new ApiClient('') // temporary for auth call + const client = new ApiClient('', { baseUrl }) // temporary for auth call const auth = await client.authenticate(getAuthPayload(authSource)) if (isCancelled) return setToken(auth.token) @@ -158,7 +159,16 @@ export function useAuth(authSource: AuthSource | null): { return () => { isCancelled = true } - }, [authSource]) + }, [authSource, baseUrl]) + + useEffect(() => { + tokenRef.current = null + refreshPromiseRef.current = null + lastRefreshAttemptRef.current = 0 + setToken(null) + setUser(null) + setError(null) + }, [baseUrl]) useEffect(() => { if (!token || !authSource) { diff --git a/web/src/hooks/useAuthSource.ts b/web/src/hooks/useAuthSource.ts index c3150f41..59b128f8 100644 --- a/web/src/hooks/useAuthSource.ts +++ b/web/src/hooks/useAuthSource.ts @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTelegramWebApp, isTelegramEnvironment } from './useTelegram' import type { AuthSource } from './useAuth' -const ACCESS_TOKEN_KEY = 'hapi_access_token' +const ACCESS_TOKEN_PREFIX = 'hapi_access_token::' function getTelegramInitData(): string | null { const tg = getTelegramWebApp() @@ -21,31 +21,35 @@ function getTelegramInitData(): string | null { return initData || null } -function getStoredAccessToken(): string | null { +function getAccessTokenKey(baseUrl: string): string { + return `${ACCESS_TOKEN_PREFIX}${baseUrl}` +} + +function getStoredAccessToken(key: string): string | null { try { - return localStorage.getItem(ACCESS_TOKEN_KEY) + return localStorage.getItem(key) } catch { return null } } -function storeAccessToken(token: string): void { +function storeAccessToken(key: string, token: string): void { try { - localStorage.setItem(ACCESS_TOKEN_KEY, token) + localStorage.setItem(key, token) } catch { // Ignore storage errors } } -function clearStoredAccessToken(): void { +function clearStoredAccessToken(key: string): void { try { - localStorage.removeItem(ACCESS_TOKEN_KEY) + localStorage.removeItem(key) } catch { // Ignore storage errors } } -export function useAuthSource(): { +export function useAuthSource(baseUrl: string): { authSource: AuthSource | null isLoading: boolean isTelegram: boolean @@ -56,9 +60,15 @@ export function useAuthSource(): { const [isLoading, setIsLoading] = useState(true) const [isTelegram, setIsTelegram] = useState(false) const retryCountRef = useRef(0) + const accessTokenKey = useMemo(() => getAccessTokenKey(baseUrl), [baseUrl]) // Initialize auth source on mount, with retry for delayed Telegram initData useEffect(() => { + retryCountRef.current = 0 + setAuthSource(null) + setIsTelegram(false) + setIsLoading(true) + const telegramInitData = getTelegramInitData() if (telegramInitData) { @@ -70,7 +80,7 @@ export function useAuthSource(): { } // Check for stored access token as fallback - const storedToken = getStoredAccessToken() + const storedToken = getStoredAccessToken(accessTokenKey) if (storedToken) { setAuthSource({ type: 'accessToken', token: storedToken }) setIsLoading(false) @@ -108,17 +118,17 @@ export function useAuthSource(): { return () => { clearInterval(interval) } - }, []) + }, [accessTokenKey]) const setAccessToken = useCallback((token: string) => { - storeAccessToken(token) + storeAccessToken(accessTokenKey, token) setAuthSource({ type: 'accessToken', token }) - }, []) + }, [accessTokenKey]) const clearAuth = useCallback(() => { - clearStoredAccessToken() + clearStoredAccessToken(accessTokenKey) setAuthSource(null) - }, []) + }, [accessTokenKey]) return { authSource, diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 4b6f205c..32408ed8 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -14,7 +14,7 @@ type SSESubscription = { machineId?: string } -function buildEventsUrl(token: string, subscription: SSESubscription): string { +function buildEventsUrl(baseUrl: string, token: string, subscription: SSESubscription): string { const params = new URLSearchParams() params.set('token', token) if (subscription.all) { @@ -27,12 +27,18 @@ function buildEventsUrl(token: string, subscription: SSESubscription): string { params.set('machineId', subscription.machineId) } - return `/api/events?${params.toString()}` + const path = `/api/events?${params.toString()}` + try { + return new URL(path, baseUrl).toString() + } catch { + return path + } } export function useSSE(options: { enabled: boolean token: string + baseUrl: string subscription?: SSESubscription onEvent: (event: SyncEvent) => void onConnect?: () => void @@ -74,7 +80,7 @@ export function useSSE(options: { return } - const url = buildEventsUrl(options.token, subscription) + const url = buildEventsUrl(options.baseUrl, options.token, subscription) const eventSource = new EventSource(url) eventSourceRef.current = eventSource @@ -148,5 +154,5 @@ export function useSSE(options: { eventSourceRef.current = null } } - }, [options.enabled, options.token, subscriptionKey, queryClient]) + }, [options.baseUrl, options.enabled, options.token, subscriptionKey, queryClient]) } diff --git a/web/src/hooks/useServerUrl.ts b/web/src/hooks/useServerUrl.ts new file mode 100644 index 00000000..f223f1e8 --- /dev/null +++ b/web/src/hooks/useServerUrl.ts @@ -0,0 +1,94 @@ +import { useCallback, useMemo, useState } from 'react' + +const SERVER_URL_KEY = 'hapi_server_url' + +export type ServerUrlResult = + | { ok: true; value: string } + | { ok: false; error: string } + +export function normalizeServerUrl(input: string): ServerUrlResult { + const trimmed = input.trim() + if (!trimmed) { + return { ok: false, error: 'Enter a server URL like https://example.com' } + } + + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return { ok: false, error: 'Enter a valid URL including http:// or https://' } + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: 'Server URL must start with http:// or https://' } + } + + return { ok: true, value: parsed.origin } +} + +function readStoredServerUrl(): string | null { + try { + const stored = localStorage.getItem(SERVER_URL_KEY) + if (!stored) { + return null + } + const normalized = normalizeServerUrl(stored) + if (!normalized.ok) { + localStorage.removeItem(SERVER_URL_KEY) + return null + } + return normalized.value + } catch { + return null + } +} + +function writeStoredServerUrl(value: string): void { + try { + localStorage.setItem(SERVER_URL_KEY, value) + } catch { + // Ignore storage errors + } +} + +function clearStoredServerUrl(): void { + try { + localStorage.removeItem(SERVER_URL_KEY) + } catch { + // Ignore storage errors + } +} + +export function useServerUrl(): { + serverUrl: string | null + baseUrl: string + setServerUrl: (input: string) => ServerUrlResult + clearServerUrl: () => void +} { + const [serverUrl, setServerUrlState] = useState(() => readStoredServerUrl()) + + const fallbackOrigin = typeof window !== 'undefined' ? window.location.origin : '' + const baseUrl = useMemo(() => serverUrl ?? fallbackOrigin, [serverUrl, fallbackOrigin]) + + const setServerUrl = useCallback((input: string): ServerUrlResult => { + const normalized = normalizeServerUrl(input) + if (!normalized.ok) { + return normalized + } + writeStoredServerUrl(normalized.value) + setServerUrlState(normalized.value) + return normalized + }, []) + + const clearServerUrl = useCallback(() => { + clearStoredServerUrl() + setServerUrlState(null) + }, []) + + return { + serverUrl, + baseUrl, + setServerUrl, + clearServerUrl + } +}