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
This commit is contained in:
Lakr
2026-04-13 17:17:42 +08:00
parent afec333aa0
commit aef3c71ed4
6 changed files with 103 additions and 33 deletions

View File

@@ -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<PairedDeviceInfo | null> => {
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() {
/>
<TrustModal open={trustOpen} onClose={() => setTrustOpen(false)} pairing={busy.pair} />
<TwoFactorModal open={twoFactor.open} onSubmit={handleTwoFactorSubmit} onCancel={handleTwoFactorCancel} />
<TwoFactorModal
open={twoFactor.open}
onSubmit={handleTwoFactorSubmit}
onCancel={handleTwoFactorCancel}
serverError={twoFactor.error}
/>
</main>
);
}

View File

@@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const displayError = serverError || error;
useEffect(() => {
if (open) {
@@ -67,13 +70,13 @@ export function TwoFactorModal({ open, onSubmit, onCancel }: TwoFactorModalProps
}
}}
/>
<p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{error ?? ''}</p>
<p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" onClick={handleSubmit}>
<Button variant="primary" onClick={handleSubmit} disabled={!!serverError}>
Verify
</Button>
</div>

View File

@@ -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);
}
}