From 11fb61a8832bcab61431677c0f9017eeef726fab Mon Sep 17 00:00:00 2001 From: Martin DONADIEU Date: Wed, 7 Jan 2026 20:51:45 +0100 Subject: [PATCH] feat: add QR code and password URL for Cloudflare tunnel (#112) * feat: add QR code and password URL parameter for Cloudflare tunnel Add --tunnel-qr flag to generate scannable QR code for tunnel URL in terminal. Add --tunnel-password-url flag to include password as URL parameter for auto-login. Frontend now automatically detects and submits password token from URL. * docs: update README with tunnel QR and password URL features Add dev script for concurrent server, web, and UI watch mode. --- README.md | 4 ++ bun.lock | 3 + package.json | 1 + .../src/components/auth/SessionAuthGate.tsx | 63 +++++++++++++++++++ packages/web/bin/cli.js | 53 +++++++++++++--- packages/web/package.json | 1 + 6 files changed, 116 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 572a4281..fbc2e318 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ The whole project was built entirely with AI coding agents under my supervision. - Self-serve web updates (no CLI required) - Update and restart keeps previous server settings (port/password) - Cloudflare Quick Tunnel support for easy remote access (`--try-cf-tunnel`) +- QR code generation for quick mobile access (`--tunnel-qr`) +- Auto-login URL with embedded password (`--tunnel-password-url`) ### Desktop (macOS) @@ -94,6 +96,8 @@ openchamber --port 8080 # Custom port openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access +openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access +openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login openchamber stop # Stop server openchamber update # Update to latest version ``` diff --git a/bun.lock b/bun.lock index 79130f02..1e11061a 100644 --- a/bun.lock +++ b/bun.lock @@ -218,6 +218,7 @@ "jsonc-parser": "^3.3.1", "next-themes": "^0.4.6", "node-pty": "^1.1.0", + "qrcode-terminal": "^0.12.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -2122,6 +2123,8 @@ "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], diff --git a/package.json b/package.json index 6572b5d0..fe3f8bc5 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dev:web": "bun run --cwd packages/web build:watch", "dev:web:server": "bun run --cwd packages/web dev:server:watch", "dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"", + "dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"", "start:web": "bun run --cwd packages/web start", "pack:web": "bun pm pack --cwd packages/web", "desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start", diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 0412de5b..5bb177e4 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -84,6 +84,25 @@ interface SessionAuthGateProps { type GateState = 'pending' | 'authenticated' | 'locked' | 'error'; +const getTokenFromUrl = (): string | null => { + try { + const params = new URLSearchParams(window.location.search); + return params.get('token'); + } catch { + return null; + } +}; + +const clearTokenFromUrl = () => { + try { + const url = new URL(window.location.href); + url.searchParams.delete('token'); + window.history.replaceState({}, '', url.toString()); + } catch { + // Ignore errors + } +}; + export const SessionAuthGate: React.FC = ({ children }) => { const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []); const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); @@ -94,6 +113,7 @@ export const SessionAuthGate: React.FC = ({ children }) => const [errorMessage, setErrorMessage] = React.useState(''); const passwordInputRef = React.useRef(null); const hasResyncedRef = React.useRef(skipAuth); + const hasTriedUrlTokenRef = React.useRef(false); const checkStatus = React.useCallback(async () => { if (skipAuth) { @@ -140,6 +160,49 @@ export const SessionAuthGate: React.FC = ({ children }) => } }, [state]); + // Auto-login with URL token parameter + React.useEffect(() => { + if (skipAuth || state !== 'locked' || hasTriedUrlTokenRef.current || isSubmitting) { + return; + } + + const urlToken = getTokenFromUrl(); + if (!urlToken) { + return; + } + + hasTriedUrlTokenRef.current = true; + clearTokenFromUrl(); + + // Auto-submit the password from URL + setIsSubmitting(true); + setErrorMessage(''); + + submitPassword(urlToken) + .then((response) => { + if (response.ok) { + setPassword(''); + setState('authenticated'); + return; + } + if (response.status === 401) { + setErrorMessage('URL token invalid. Please enter password manually.'); + setState('locked'); + return; + } + setErrorMessage('Unexpected response from server.'); + setState('error'); + }) + .catch((error) => { + console.warn('Failed to submit URL token:', error); + setErrorMessage('Network error. Check connection and retry.'); + setState('error'); + }) + .finally(() => { + setIsSubmitting(false); + }); + }, [skipAuth, state, isSubmitting]); + React.useEffect(() => { if (skipAuth) { return; diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 3b99e09a..fa0215ec 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -50,10 +50,30 @@ function generateRandomPassword(length = 16) { return password; } +async function displayTunnelQrCode(url) { + try { + const qrcode = await import('qrcode-terminal'); + console.log('\nšŸ“± Scan this QR code to access the tunnel:\n'); + qrcode.default.generate(url, { small: true }); + console.log(''); + } catch (error) { + console.warn('āš ļø Could not generate QR code:', error.message); + } +} + +function buildTunnelUrl(baseUrl, password, includePassword) { + if (!includePassword || !password) { + return baseUrl; + } + const url = new URL(baseUrl); + url.searchParams.set('token', password); + return url.toString(); +} + function parseArgs() { const args = process.argv.slice(2); const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined; - const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false }; + const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false, tunnelQr: false, tunnelPasswordUrl: false }; let command = 'serve'; const consumeValue = (currentIndex, inlineValue) => { @@ -99,6 +119,12 @@ function parseArgs() { case 'try-cf-tunnel': options.tryCfTunnel = true; break; + case 'tunnel-qr': + options.tunnelQr = true; + break; + case 'tunnel-password-url': + options.tunnelPasswordUrl = true; + break; case 'ui-password': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; @@ -139,12 +165,14 @@ COMMANDS: update Check for and install updates OPTIONS: - -p, --port Web server port (default: ${DEFAULT_PORT}) - --ui-password Protect browser UI with single password - --try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access - -d, --daemon Run in background (serve command) - -h, --help Show help - -v, --version Show version + -p, --port Web server port (default: ${DEFAULT_PORT}) + --ui-password Protect browser UI with single password + --try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access + --tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel) + --tunnel-password-url Include password in tunnel URL for auto-login + -d, --daemon Run in background (serve command) + -h, --help Show help + -v, --version Show version ENVIRONMENT: OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag @@ -490,8 +518,15 @@ const commands = { exitOnShutdown: true, uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, tryCfTunnel: options.tryCfTunnel, - onTunnelReady: (url) => { - console.log(`\n🌐 Tunnel URL: \x1b[36m${url}\x1b[0m\n`); + onTunnelReady: async (url) => { + const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl); + console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`); + if (options.tunnelPasswordUrl && effectiveUiPassword) { + console.log('šŸ”‘ Password is embedded in URL for auto-login\n'); + } + if (options.tunnelQr) { + await displayTunnelQrCode(displayUrl); + } }, }); } diff --git a/packages/web/package.json b/packages/web/package.json index 93fb9dc0..10eab5fb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,6 +22,7 @@ "start": "node bin/cli.js serve" }, "dependencies": { + "qrcode-terminal": "^0.12.0", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1",