From aef3c71ed424d49ec781e64fa26dcb20e6cf7074 Mon Sep 17 00:00:00 2001 From: Lakr <25259084+Lakr233@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:17:42 +0800 Subject: [PATCH] fix: show 2FA errors in modal, fix stale device check, add dark mode - Keep TwoFactorModal open after code submit; display server-side errors inline instead of silently closing the modal - handlePair now returns PairedDeviceInfo so handleInstall uses the fresh UDID instead of stale closure state - Add explicit /wisp/ location in nginx.conf returning 502 to prevent SPA fallback from swallowing websocket upgrades - Add prefers-color-scheme dark mode via CSS custom property overrides; replace hardcoded rgba/hex with color-mix() for automatic adaptation --- .gitignore | 1 + dependencies/webmuxd/package.json | 2 +- frontend/src/App.tsx | 44 +++++++++---- frontend/src/components/TwoFactorModal.tsx | 9 ++- frontend/src/style.css | 74 +++++++++++++++++----- nginx.conf | 6 ++ 6 files changed, 103 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 3a50abe..8aaee70 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ pnpm-debug.log* # Misc coverage/ .wrangler/ +repomix-output.xml diff --git a/dependencies/webmuxd/package.json b/dependencies/webmuxd/package.json index 7829075..e1c0210 100644 --- a/dependencies/webmuxd/package.json +++ b/dependencies/webmuxd/package.json @@ -28,7 +28,7 @@ "build": "bun run build:openssl-wasm && tsc && bun run copy:openssl-wasm", "build:openssl-wasm": "cd ../../wasm/openssl && bun run build", "copy:openssl-wasm": "bun scripts/copy-openssl-wasm.mjs", - "prepare": "bun run build", + "prepack": "bun run build", "prepublishOnly": "bun run test && bun run lint", "preversion": "bun run lint", "version": "bun run format && git add -A src", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb703ae..a7dea16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -109,7 +109,8 @@ export function App() { const [twoFactor, setTwoFactor] = useState<{ open: boolean; submit: ((code: string) => void) | null; - }>({ open: false, submit: null }); + error: string | null; + }>({ open: false, submit: null, error: null }); // Derived const isWebUsbSupported = useMemo(() => { @@ -258,8 +259,8 @@ export function App() { }, [knownUdids, selectedTargetUdid]); // ---- pair flow ---- - const handlePair = useCallback(async () => { - if (busyRef.current.pair) return; + const handlePair = useCallback(async (): Promise => { + if (busyRef.current.pair) return null; setBusy((prev) => ({ ...prev, pair: true })); setTrustOpen(true); addLog('pair: please continue on your device'); @@ -279,12 +280,14 @@ export function App() { saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid); setPairRecordsVersion((v) => v + 1); setTrustOpen(false); + return info; } catch (error) { if (isPairingDialogPendingError(error)) { - return; + return null; } setTrustOpen(false); addLog(`pair failed: ${formatError(error)}`); + return null; } finally { setBusy((prev) => ({ ...prev, pair: false })); } @@ -300,6 +303,8 @@ export function App() { } setBusy((prev) => ({ ...prev, loginSign: true })); + let twoFactorOpened = false; + let twoFactorErrorShown = false; try { saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId); addLog('login: initializing anisette...'); @@ -315,11 +320,13 @@ export function App() { anisetteData: nextAnisette, log: addLog, onTwoFactorRequired: (submit) => { - setTwoFactor({ open: true, submit }); + twoFactorOpened = true; + setTwoFactor({ open: true, submit, error: null }); addLog('login: 2FA required, opening verification dialog'); }, }); + setTwoFactor({ open: false, submit: null, error: null }); addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`); const key = accountKey(context.appleId, context.team.identifier); accountContextMapRef.current.set(key, context); @@ -335,9 +342,16 @@ export function App() { addLog('login: done, navigating to sign page'); navigateToPage('sign'); } catch (error) { - addLog(`login failed: ${formatError(error)}`); + const msg = formatError(error); + addLog(`login failed: ${msg}`); + if (twoFactorOpened) { + setTwoFactor((prev) => ({ ...prev, error: msg })); + twoFactorErrorShown = true; + } } finally { - setTwoFactor({ open: false, submit: null }); + if (!twoFactorErrorShown) { + setTwoFactor({ open: false, submit: null, error: null }); + } setBusy((prev) => ({ ...prev, loginSign: false })); } }, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]); @@ -345,7 +359,6 @@ export function App() { const handleTwoFactorSubmit = useCallback( (code: string) => { const submit = twoFactor.submit; - setTwoFactor({ open: false, submit: null }); if (submit) submit(code); }, [twoFactor.submit], @@ -353,7 +366,7 @@ export function App() { const handleTwoFactorCancel = useCallback(() => { const submit = twoFactor.submit; - setTwoFactor({ open: false, submit: null }); + setTwoFactor({ open: false, submit: null, error: null }); if (submit) { addLog('login: 2FA canceled'); submit('__CANCELLED__'); @@ -422,10 +435,12 @@ export function App() { onStateChange: () => {}, onTrustPending: () => setTrustOpen(true), }); + let currentDeviceUdid = pairedDeviceInfo?.udid ?? null; if (!client.isSessionStarted) { - await handlePair(); + const freshInfo = await handlePair(); + currentDeviceUdid = freshInfo?.udid ?? null; } - if (pairedDeviceInfo?.udid !== targetUdid) { + if (currentDeviceUdid !== targetUdid) { throw new Error('connected device udid does not match selected target'); } @@ -606,7 +621,12 @@ export function App() { /> setTrustOpen(false)} pairing={busy.pair} /> - + ); } diff --git a/frontend/src/components/TwoFactorModal.tsx b/frontend/src/components/TwoFactorModal.tsx index 220c0e6..da36943 100644 --- a/frontend/src/components/TwoFactorModal.tsx +++ b/frontend/src/components/TwoFactorModal.tsx @@ -6,12 +6,15 @@ interface TwoFactorModalProps { open: boolean; onSubmit: (code: string) => void; onCancel: () => void; + /** Server-side error (e.g. wrong code). Shown when the login flow rejects after 2FA submit. */ + serverError?: string | null; } -export function TwoFactorModal({ open, onSubmit, onCancel }: TwoFactorModalProps) { +export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFactorModalProps) { const [code, setCode] = useState(''); const [error, setError] = useState(null); const inputRef = useRef(null); + const displayError = serverError || error; useEffect(() => { if (open) { @@ -67,13 +70,13 @@ export function TwoFactorModal({ open, onSubmit, onCancel }: TwoFactorModalProps } }} /> -

{error ?? ''}

+

{displayError ?? ''}

-
diff --git a/frontend/src/style.css b/frontend/src/style.css index f24fc66..6286cbd 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -16,6 +16,10 @@ --color-danger: #dc2626; --color-danger-soft: #fef2f2; + --color-on-ink: #ffffff; + --color-ink-hover: #1f1f1f; + --color-accent-hover: #1d4ed8; + --font-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif; } @@ -24,6 +28,30 @@ color-scheme: light; } +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --color-bg: #0a0a0a; + --color-surface: #141414; + --color-elevated: #1a1a1a; + --color-border: rgba(255, 255, 255, 0.08); + --color-border-strong: rgba(255, 255, 255, 0.14); + --color-ink: #ededed; + --color-muted: #a1a1aa; + --color-subtle: #71717a; + --color-accent: #3b82f6; + --color-accent-soft: rgba(59, 130, 246, 0.12); + --color-success: #22c55e; + --color-success-soft: rgba(34, 197, 94, 0.12); + --color-danger: #ef4444; + --color-danger-soft: rgba(239, 68, 68, 0.12); + --color-on-ink: #0a0a0a; + --color-ink-hover: #d4d4d4; + --color-accent-hover: #60a5fa; + --color-log-bg: #111111; + } +} + * { box-sizing: border-box; } @@ -48,7 +76,7 @@ body.modal-open { } ::selection { - background-color: rgba(37, 99, 235, 0.18); + background-color: color-mix(in srgb, var(--color-accent) 22%, transparent); } /* ----- form primitives ----- */ @@ -78,7 +106,7 @@ body.modal-open { .field-input:focus { border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); } .field-input:disabled { @@ -129,11 +157,11 @@ body.modal-open { .btn-primary { background: var(--color-ink); - color: #ffffff; + color: var(--color-on-ink); } .btn-primary:hover:not(:disabled) { - background: #1f1f1f; + background: var(--color-ink-hover); } .btn-accent { @@ -142,7 +170,7 @@ body.modal-open { } .btn-accent:hover:not(:disabled) { - background: #1d4ed8; + background: var(--color-accent-hover); } .btn-ghost { @@ -192,9 +220,9 @@ body.modal-open { } .seg-btn[data-active='true'] { - background: var(--color-bg); + background: var(--color-elevated); color: var(--color-ink); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); } /* ----- card ----- */ @@ -252,9 +280,9 @@ body.modal-open { .stepper-step[data-state='active'] .stepper-marker { background: var(--color-ink); - color: #ffffff; + color: var(--color-on-ink); border-color: var(--color-ink); - box-shadow: 0 0 0 4px rgba(10, 10, 10, 0.06); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-ink) 6%, transparent); } .stepper-step[data-state='active'] .stepper-marker::after { @@ -262,7 +290,7 @@ body.modal-open { position: absolute; inset: -6px; border-radius: 999px; - border: 1px solid rgba(10, 10, 10, 0.18); + border: 1px solid color-mix(in srgb, var(--color-ink) 18%, transparent); animation: pulse-ring 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; pointer-events: none; } @@ -270,7 +298,7 @@ body.modal-open { .stepper-step[data-state='done'] .stepper-marker { background: var(--color-success-soft); color: var(--color-success); - border-color: rgba(22, 163, 74, 0.28); + border-color: color-mix(in srgb, var(--color-success) 28%, transparent); } .stepper-label { @@ -386,7 +414,7 @@ pre.log { margin: 0; padding: 14px 16px; border-radius: 10px; - background: #0a0a0a; + background: var(--color-log-bg, #0a0a0a); color: #e5e7eb; font-family: var(--font-mono); font-size: 12px; @@ -437,10 +465,10 @@ pre.log::-webkit-scrollbar-track { width: 100%; max-width: 420px; border-radius: 16px; - border: 1px solid var(--color-border); - background: var(--color-bg); + border: 1px solid var(--color-border-strong); + background: var(--color-elevated); padding: 22px; - box-shadow: 0 20px 50px -20px rgba(10, 10, 10, 0.25), 0 4px 12px -4px rgba(10, 10, 10, 0.12); + box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.4), 0 4px 12px -4px rgba(0, 0, 0, 0.2); transform: translateY(8px) scale(0.98); opacity: 0; transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease; @@ -470,13 +498,13 @@ pre.log::-webkit-scrollbar-track { .chip[data-tone='success'] { background: var(--color-success-soft); - border-color: rgba(22, 163, 74, 0.22); + border-color: color-mix(in srgb, var(--color-success) 22%, transparent); color: var(--color-success); } .chip[data-tone='accent'] { background: var(--color-accent-soft); - border-color: rgba(37, 99, 235, 0.22); + border-color: color-mix(in srgb, var(--color-accent) 22%, transparent); color: var(--color-accent); } @@ -543,3 +571,15 @@ pre.log::-webkit-scrollbar-track { .anim-in { animation: fade-in-up 0.32s cubic-bezier(0.22, 1, 0.36, 1) both; } + +/* ----- dark mode component overrides ----- */ + +@media (prefers-color-scheme: dark) { + .field-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E"); + } + + .modal { + background: rgba(0, 0, 0, 0.55); + } +} diff --git a/nginx.conf b/nginx.conf index 83d907d..52b5541 100644 --- a/nginx.conf +++ b/nginx.conf @@ -44,6 +44,12 @@ server { add_header Cache-Control "no-cache" always; } + # WISP websocket — must be proxied by an outer reverse proxy or sidecar. + # Return 502 so the frontend gets a clear signal rather than the SPA shell. + location /wisp/ { + return 502; + } + # Hash routing (no server-side routes needed) — fall back to index.html. location / { try_files $uri $uri/ /index.html;