mirror of
https://github.com/Termix-SSH/Mobile.git
synced 2026-07-28 05:51:43 +02:00
6c40d2f0cc
* fix: register physical Tab key on iPadOS/iOS hardware keyboards (#14) The native module only registered Shift+Tab but not bare Tab, so iOS intercepted it for UI focus navigation. Register an unmodified Tab UIKeyCommand with wantsPriorityOverSystemBehavior and handle both Tab and Shift+Tab in the same JS branch. Fixes Termix-SSH/Support#557 * fix: pass jumpHosts, forceKeyboardInteractive, and overrideCredentialUsername to terminal connection (#15) These fields were defined on SSHHost but omitted from the terminal hostConfig passthrough. Without jumpHosts the backend cannot route through jump servers; without forceKeyboardInteractive hosts that require keyboard-interactive auth fail silently; without overrideCredentialUsername shared credentials use the wrong username. Fixes Termix-SSH/Support#422 Fixes Termix-SSH/Support#638 * fix: improve mobile terminal scroll recovery (#17) * Add mobile remote desktop sessions (#18) * feat: add mobile remote desktop sessions * feat: add remote desktop input controls * feat: polish remote desktop controls * feat: add mobile terminal font selection (#19) * feat: add mobile shift tab hotkey (#20) * feat: initial UI redesign * feat: revamp server login system * chore: update app icon/background color * fix: remove unused fields in the settings tab * feat: improve the user pin system and update all settings modals to use new ui * fix: active server not updating unless app restarts * feat: add version in settings * feat: improve host page UI (updated host form and added credential management) * fix: preserve composed iOS terminal input * ci: restore mobile checks * feat: rewrite of connection system for all types * fix: preserve mobile jump hosts (#30) * fix: guard android user ca trust (#29) * fix: capture ios hardware tab key (#27) * fix: reset terminal input after special keys (#28) * fix: open oidc in system browser (#26) * fix: preserve websocket auth context (#25) * fix: keep none-auth terminal prompts alive (#23) * fix: allow local network SSL in Android app (#21) * feat: allow oidc passkeys * feat: bundeled xterm into app and added docker loading screen * feat: add 2fa, api key, and active sessions to settings and addressed multiple bug fixes and inconsistencies with termix web * chore: update repo images and readme * chore: update readme to split table for screenshots * chore: rename ios to apple in build workflow * fix: android build failure * feat: add snippet management and fix several bugs * fix: web login and oidc incorrect logic * chore: run linter --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { AppState, type AppStateStatus } from "react-native";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import * as LocalAuthentication from "expo-local-authentication";
|
|
import * as SecureStore from "expo-secure-store";
|
|
|
|
/**
|
|
* App Lock — optional biometric / PIN gate when the app is opened or returns
|
|
* from background. Addresses Support issues #496 / #338 (account security on
|
|
* mobile). The PIN is stored in the OS secure store; the enabled flag in
|
|
* AsyncStorage.
|
|
*/
|
|
|
|
const ENABLED_KEY = "appLockEnabled";
|
|
const PIN_KEY = "appLockPin"; // SecureStore
|
|
const LOCK_DELAY_MS = 15_000; // re-lock if backgrounded longer than this
|
|
|
|
interface AppLockContextValue {
|
|
enabled: boolean;
|
|
locked: boolean;
|
|
hasBiometrics: boolean;
|
|
/** Enable app lock with a numeric PIN (biometrics used when available). */
|
|
enable: (pin: string) => Promise<void>;
|
|
/**
|
|
* Tear down app lock. Performs no verification — callers MUST authenticate
|
|
* the user (via `verifyPin` / `authenticateBiometrics`) first.
|
|
*/
|
|
disable: () => Promise<void>;
|
|
/** Compare a PIN against the stored one. Does NOT change lock state. */
|
|
verifyPin: (pin: string) => Promise<boolean>;
|
|
/**
|
|
* Prompt for biometrics (no device-passcode fallback). Does NOT change lock
|
|
* state — used for re-authentication in Settings.
|
|
*/
|
|
authenticateBiometrics: () => Promise<boolean>;
|
|
/** Attempt unlock via biometrics; unlocks on success. Returns success. */
|
|
unlockWithBiometrics: () => Promise<boolean>;
|
|
/** Attempt unlock via PIN; unlocks on success. Returns success. */
|
|
unlockWithPin: (pin: string) => Promise<boolean>;
|
|
}
|
|
|
|
const AppLockContext = createContext<AppLockContextValue | null>(null);
|
|
|
|
export function AppLockProvider({ children }: { children: React.ReactNode }) {
|
|
const [enabled, setEnabled] = useState(false);
|
|
const [locked, setLocked] = useState(false);
|
|
const [hasBiometrics, setHasBiometrics] = useState(false);
|
|
const backgroundedAt = useRef<number | null>(null);
|
|
const appState = useRef(AppState.currentState);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const [isEnabled, hw, enrolled] = await Promise.all([
|
|
AsyncStorage.getItem(ENABLED_KEY),
|
|
LocalAuthentication.hasHardwareAsync().catch(() => false),
|
|
LocalAuthentication.isEnrolledAsync().catch(() => false),
|
|
]);
|
|
const on = isEnabled === "true";
|
|
setEnabled(on);
|
|
// Only treat biometrics as available when the device has a sensor AND a
|
|
// biometric is actually enrolled — otherwise the fingerprint button would
|
|
// appear but do nothing.
|
|
setHasBiometrics(!!hw && !!enrolled);
|
|
if (on) setLocked(true); // lock on cold start
|
|
})();
|
|
}, []);
|
|
|
|
// Re-lock when returning from background after the delay.
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener("change", (next: AppStateStatus) => {
|
|
const prev = appState.current;
|
|
appState.current = next;
|
|
if (!enabled) return;
|
|
if (next.match(/inactive|background/)) {
|
|
backgroundedAt.current = Date.now();
|
|
} else if (prev.match(/inactive|background/) && next === "active") {
|
|
const elapsed = Date.now() - (backgroundedAt.current ?? 0);
|
|
if (elapsed >= LOCK_DELAY_MS) setLocked(true);
|
|
}
|
|
});
|
|
return () => sub.remove();
|
|
}, [enabled]);
|
|
|
|
const enable = useCallback(async (pin: string) => {
|
|
await SecureStore.setItemAsync(PIN_KEY, pin);
|
|
await AsyncStorage.setItem(ENABLED_KEY, "true");
|
|
setEnabled(true);
|
|
setLocked(false);
|
|
}, []);
|
|
|
|
const disable = useCallback(async () => {
|
|
await SecureStore.deleteItemAsync(PIN_KEY).catch(() => {});
|
|
await AsyncStorage.setItem(ENABLED_KEY, "false");
|
|
setEnabled(false);
|
|
setLocked(false);
|
|
}, []);
|
|
|
|
// Prompt biometrics without changing lock state. Device-passcode fallback is
|
|
// disabled so iOS never shows the phone passcode prompt — the in-app PIN is
|
|
// the only fallback.
|
|
const authenticateBiometrics = useCallback(async () => {
|
|
try {
|
|
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
|
if (!enrolled) return false;
|
|
const res = await LocalAuthentication.authenticateAsync({
|
|
promptMessage: "Unlock Termix",
|
|
disableDeviceFallback: true,
|
|
cancelLabel: "Use PIN",
|
|
});
|
|
return res.success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
const unlockWithBiometrics = useCallback(async () => {
|
|
const ok = await authenticateBiometrics();
|
|
if (ok) setLocked(false);
|
|
return ok;
|
|
}, [authenticateBiometrics]);
|
|
|
|
const verifyPin = useCallback(async (pin: string) => {
|
|
const stored = await SecureStore.getItemAsync(PIN_KEY).catch(() => null);
|
|
return !!stored && stored === pin;
|
|
}, []);
|
|
|
|
const unlockWithPin = useCallback(
|
|
async (pin: string) => {
|
|
const ok = await verifyPin(pin);
|
|
if (ok) setLocked(false);
|
|
return ok;
|
|
},
|
|
[verifyPin],
|
|
);
|
|
|
|
return (
|
|
<AppLockContext.Provider
|
|
value={{
|
|
enabled,
|
|
locked,
|
|
hasBiometrics,
|
|
enable,
|
|
disable,
|
|
verifyPin,
|
|
authenticateBiometrics,
|
|
unlockWithBiometrics,
|
|
unlockWithPin,
|
|
}}
|
|
>
|
|
{children}
|
|
</AppLockContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAppLock(): AppLockContextValue {
|
|
const ctx = useContext(AppLockContext);
|
|
if (!ctx) throw new Error("useAppLock must be used within AppLockProvider");
|
|
return ctx;
|
|
}
|