mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat(auth): add multi-account support with PIN/password protection
This commit is contained in:
113
app/login.tsx
113
app/login.tsx
@@ -10,6 +10,7 @@ import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
@@ -37,6 +43,7 @@ const Login: React.FC = () => {
|
||||
removeServer,
|
||||
initiateQuickConnect,
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
|
||||
const {
|
||||
@@ -57,6 +64,14 @@ const Login: React.FC = () => {
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
// Save account state
|
||||
const [saveAccount, setSaveAccount] = useState(false);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* A way to auto login based on a link
|
||||
*/
|
||||
@@ -101,12 +116,34 @@ const Login: React.FC = () => {
|
||||
const handleLogin = async () => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (!result.success) return;
|
||||
|
||||
if (saveAccount) {
|
||||
// Show save account modal to choose security type
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
// Login without saving
|
||||
await performLogin(credentials.username, credentials.password);
|
||||
}
|
||||
};
|
||||
|
||||
const performLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
options?: {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
},
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password, serverName);
|
||||
}
|
||||
await login(username, password, serverName, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
@@ -118,11 +155,45 @@ const Login: React.FC = () => {
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPendingLogin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickLoginWithSavedCredential = async (serverUrl: string) => {
|
||||
await loginWithSavedCredential(serverUrl);
|
||||
const handleSaveAccountConfirm = async (
|
||||
securityType: AccountSecurityType,
|
||||
pinCode?: string,
|
||||
) => {
|
||||
setShowSaveModal(false);
|
||||
if (pendingLogin) {
|
||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||
saveAccount: true,
|
||||
securityType,
|
||||
pinCode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickLoginWithSavedCredential = async (
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
) => {
|
||||
await loginWithSavedCredential(serverUrl, userId);
|
||||
};
|
||||
|
||||
const handlePasswordLogin = async (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => {
|
||||
await loginWithPassword(serverUrl, username, password);
|
||||
};
|
||||
|
||||
const handleAddAccount = (server: SavedServer) => {
|
||||
// Server is already selected, go to credential entry
|
||||
setServer({ address: server.address });
|
||||
if (server.name) {
|
||||
setServerName(server.name);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -394,6 +465,8 @@ const Login: React.FC = () => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -470,6 +543,21 @@ const Login: React.FC = () => {
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setSaveAccount(!saveAccount)}
|
||||
className='flex flex-row items-center py-2'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Switch
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
<Text className='ml-3 text-neutral-300'>
|
||||
{t("save_account.save_for_later")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
@@ -546,11 +634,24 @@ const Login: React.FC = () => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Save Account Modal */}
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -29,6 +29,7 @@
|
||||
"expo-brightness": "~14.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "18.0.13",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-font": "~14.0.10",
|
||||
@@ -1007,6 +1008,8 @@
|
||||
|
||||
"expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="],
|
||||
|
||||
"expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="],
|
||||
|
||||
"expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
|
||||
|
||||
"expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="],
|
||||
|
||||
223
components/AccountsSheet.tsx
Normal file
223
components/AccountsSheet.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Swipeable } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
deleteAccountCredential,
|
||||
type SavedServer,
|
||||
type SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface AccountsSheetProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
server: SavedServer | null;
|
||||
onAccountSelect: (account: SavedServerAccount) => void;
|
||||
onAddAccount: () => void;
|
||||
onAccountDeleted?: () => void;
|
||||
}
|
||||
|
||||
export const AccountsSheet: React.FC<AccountsSheetProps> = ({
|
||||
open,
|
||||
setOpen,
|
||||
server,
|
||||
onAccountSelect,
|
||||
onAddAccount,
|
||||
onAccountDeleted,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
||||
if (!server) return;
|
||||
|
||||
Alert.alert(
|
||||
t("server.remove_saved_login"),
|
||||
t("server.remove_account_description", { username: account.username }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.remove"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteAccountCredential(server.address, account.userId);
|
||||
onAccountDeleted?.();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const getSecurityIcon = (
|
||||
securityType: SavedServerAccount["securityType"],
|
||||
): keyof typeof Ionicons.glyphMap => {
|
||||
switch (securityType) {
|
||||
case "pin":
|
||||
return "keypad";
|
||||
case "password":
|
||||
return "lock-closed";
|
||||
default:
|
||||
return "key";
|
||||
}
|
||||
};
|
||||
|
||||
const renderRightActions = (account: SavedServerAccount) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteAccount(account)}
|
||||
className='bg-red-600 justify-center items-center px-5'
|
||||
>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-4'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("server.select_account")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>
|
||||
{server.name || server.address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Account List */}
|
||||
<View className='bg-neutral-800 rounded-xl overflow-hidden mb-4'>
|
||||
{server.accounts.map((account, index) => (
|
||||
<Swipeable
|
||||
key={account.userId}
|
||||
renderRightActions={() => renderRightActions(account)}
|
||||
overshootRight={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
onAccountSelect(account);
|
||||
}}
|
||||
className={`flex-row items-center p-4 bg-neutral-800 ${
|
||||
index < server.accounts.length - 1
|
||||
? "border-b border-neutral-700"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||
<Ionicons name='person' size={20} color='white' />
|
||||
</View>
|
||||
|
||||
{/* Account Info */}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-100 font-medium'>
|
||||
{account.username}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{account.securityType === "none"
|
||||
? t("save_account.no_protection")
|
||||
: account.securityType === "pin"
|
||||
? t("save_account.pin_code")
|
||||
: t("save_account.password")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Security Icon */}
|
||||
<Ionicons
|
||||
name={getSecurityIcon(account.securityType)}
|
||||
size={18}
|
||||
color={Colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Swipeable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Hint */}
|
||||
<Text className='text-xs text-neutral-500 mb-4 ml-1'>
|
||||
{t("server.swipe_to_remove")}
|
||||
</Text>
|
||||
|
||||
{/* Add Account Button */}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
onAddAccount();
|
||||
}}
|
||||
color='purple'
|
||||
>
|
||||
<View className='flex-row items-center justify-center'>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold ml-2'>
|
||||
{t("server.add_account")}
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
231
components/PINEntryModal.tsx
Normal file
231
components/PINEntryModal.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
Keyboard,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { PinInput } from "./inputs/PinInput";
|
||||
|
||||
interface PINEntryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onForgotPIN?: () => void;
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const PINEntryModal: React.FC<PINEntryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onForgotPIN,
|
||||
serverUrl,
|
||||
userId,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||
const errorHaptic = useHaptic("error");
|
||||
const successHaptic = useHaptic("success");
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
setPinCode("");
|
||||
setError(null);
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setPinCode("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const shake = () => {
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 10,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: -10,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 10,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 0,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
|
||||
const handlePinChange = async (value: string) => {
|
||||
setPinCode(value);
|
||||
setError(null);
|
||||
|
||||
// Auto-verify when 4 digits entered
|
||||
if (value.length === 4) {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
||||
if (isValid) {
|
||||
Keyboard.dismiss();
|
||||
successHaptic();
|
||||
onSuccess();
|
||||
setPinCode("");
|
||||
} else {
|
||||
errorHaptic();
|
||||
setError(t("pin.invalid_pin"));
|
||||
shake();
|
||||
setPinCode("");
|
||||
}
|
||||
} catch {
|
||||
errorHaptic();
|
||||
setError(t("pin.invalid_pin"));
|
||||
shake();
|
||||
setPinCode("");
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPIN = () => {
|
||||
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.continue"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
onClose();
|
||||
onForgotPIN?.();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
backdropComponent={renderBackdrop}
|
||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||
keyboardBlurBehavior='restore'
|
||||
android_keyboardInputMode='adjustResize'
|
||||
topInset={isAndroid ? 0 : undefined}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-6'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("pin.enter_pin")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>
|
||||
{t("pin.enter_pin_for", { username })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* PIN Input */}
|
||||
<Animated.View
|
||||
style={{ transform: [{ translateX: shakeAnimation }] }}
|
||||
className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'
|
||||
>
|
||||
<PinInput
|
||||
value={pinCode}
|
||||
onChangeText={handlePinChange}
|
||||
length={4}
|
||||
style={{ paddingHorizontal: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Text className='text-red-500 text-center mt-3'>{error}</Text>
|
||||
)}
|
||||
{isVerifying && (
|
||||
<Text className='text-neutral-400 text-center mt-3'>
|
||||
{t("common.verifying") || "Verifying..."}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Forgot PIN */}
|
||||
<TouchableOpacity onPress={handleForgotPIN} className='mb-4'>
|
||||
<Text className='text-purple-400 text-center'>
|
||||
{t("pin.forgot_pin")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<Button onPress={onClose} color='black'>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
185
components/PasswordEntryModal.tsx
Normal file
185
components/PasswordEntryModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface PasswordEntryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (password: string) => Promise<void>;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSubmit,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const errorHaptic = useHaptic("error");
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
setPassword("");
|
||||
setError(null);
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setPassword("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password) {
|
||||
setError(t("password.enter_password"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSubmit(password);
|
||||
setPassword("");
|
||||
} catch {
|
||||
errorHaptic();
|
||||
setError(t("password.invalid_password"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
backdropComponent={renderBackdrop}
|
||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||
keyboardBlurBehavior='restore'
|
||||
android_keyboardInputMode='adjustResize'
|
||||
topInset={isAndroid ? 0 : undefined}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-6'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("password.enter_password")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>
|
||||
{t("password.enter_password_for", { username })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||
<Text className='text-neutral-400 text-sm mb-2'>
|
||||
{t("login.password")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("login.password")}
|
||||
placeholderTextColor='#6B7280'
|
||||
secureTextEntry
|
||||
autoFocus
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
backgroundColor: "#1F2937",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
}}
|
||||
onSubmitEditing={handleSubmit}
|
||||
returnKeyType='done'
|
||||
/>
|
||||
{error && <Text className='text-red-500 mt-2'>{error}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className='flex-row gap-3'>
|
||||
<Button
|
||||
onPress={onClose}
|
||||
color='black'
|
||||
className='flex-1'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleSubmit}
|
||||
color='purple'
|
||||
className='flex-1'
|
||||
disabled={isLoading || !password}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("login.login")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -7,57 +7,171 @@ import { Swipeable } from "react-native-gesture-handler";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
deleteServerCredential,
|
||||
deleteAccountCredential,
|
||||
getPreviousServers,
|
||||
removeServerFromList,
|
||||
type SavedServer,
|
||||
type SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { AccountsSheet } from "./AccountsSheet";
|
||||
import { Text } from "./common/Text";
|
||||
import { ListGroup } from "./list/ListGroup";
|
||||
import { ListItem } from "./list/ListItem";
|
||||
import { PasswordEntryModal } from "./PasswordEntryModal";
|
||||
import { PINEntryModal } from "./PINEntryModal";
|
||||
|
||||
interface PreviousServersListProps {
|
||||
onServerSelect: (server: SavedServer) => void;
|
||||
onQuickLogin?: (serverUrl: string) => Promise<void>;
|
||||
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
|
||||
onPasswordLogin?: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<void>;
|
||||
onAddAccount?: (server: SavedServer) => void;
|
||||
}
|
||||
|
||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
onServerSelect,
|
||||
onQuickLogin,
|
||||
onPasswordLogin,
|
||||
onAddAccount,
|
||||
}) => {
|
||||
const [_previousServers, setPreviousServers] =
|
||||
useMMKVString("previousServers");
|
||||
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
|
||||
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||
null,
|
||||
);
|
||||
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] =
|
||||
useState<SavedServerAccount | null>(null);
|
||||
|
||||
const previousServers = useMemo(() => {
|
||||
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||
}, [_previousServers]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleServerPress = async (server: SavedServer) => {
|
||||
if (loadingServer) return; // Prevent double-tap
|
||||
const refreshServers = () => {
|
||||
const servers = getPreviousServers();
|
||||
setPreviousServers(JSON.stringify(servers));
|
||||
};
|
||||
|
||||
if (server.hasCredentials && onQuickLogin) {
|
||||
// Quick login with saved credentials
|
||||
setLoadingServer(server.address);
|
||||
try {
|
||||
await onQuickLogin(server.address);
|
||||
} catch (_error) {
|
||||
// Token expired/invalid, fall back to manual login
|
||||
Alert.alert(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||
);
|
||||
} finally {
|
||||
setLoadingServer(null);
|
||||
}
|
||||
} else {
|
||||
onServerSelect(server);
|
||||
const handleAccountLogin = async (
|
||||
server: SavedServer,
|
||||
account: SavedServerAccount,
|
||||
) => {
|
||||
switch (account.securityType) {
|
||||
case "none":
|
||||
// Quick login without protection
|
||||
if (onQuickLogin) {
|
||||
setLoadingServer(server.address);
|
||||
try {
|
||||
await onQuickLogin(server.address, account.userId);
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||
);
|
||||
} finally {
|
||||
setLoadingServer(null);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "pin":
|
||||
// Show PIN entry modal
|
||||
setSelectedServer(server);
|
||||
setSelectedAccount(account);
|
||||
setPinModalVisible(true);
|
||||
break;
|
||||
|
||||
case "password":
|
||||
// Show password entry modal
|
||||
setSelectedServer(server);
|
||||
setSelectedAccount(account);
|
||||
setPasswordModalVisible(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCredential = async (serverUrl: string) => {
|
||||
const handleServerPress = async (server: SavedServer) => {
|
||||
if (loadingServer) return; // Prevent double-tap
|
||||
|
||||
const accountCount = server.accounts?.length || 0;
|
||||
|
||||
if (accountCount === 0) {
|
||||
// No saved accounts, go to manual login
|
||||
onServerSelect(server);
|
||||
} else {
|
||||
// Has accounts, show account sheet (allows adding new account too)
|
||||
setSelectedServer(server);
|
||||
setAccountsSheetOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinSuccess = async () => {
|
||||
setPinModalVisible(false);
|
||||
if (selectedServer && selectedAccount && onQuickLogin) {
|
||||
setLoadingServer(selectedServer.address);
|
||||
try {
|
||||
await onQuickLogin(selectedServer.address, selectedAccount.userId);
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
[
|
||||
{
|
||||
text: t("common.ok"),
|
||||
onPress: () => onServerSelect(selectedServer),
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
setLoadingServer(null);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (password: string) => {
|
||||
if (selectedServer && selectedAccount && onPasswordLogin) {
|
||||
await onPasswordLogin(
|
||||
selectedServer.address,
|
||||
selectedAccount.username,
|
||||
password,
|
||||
);
|
||||
setPasswordModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPIN = async () => {
|
||||
if (selectedServer && selectedAccount) {
|
||||
await deleteAccountCredential(
|
||||
selectedServer.address,
|
||||
selectedAccount.userId,
|
||||
);
|
||||
refreshServers();
|
||||
// Go to manual login
|
||||
onServerSelect(selectedServer);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFirstCredential = async (serverUrl: string) => {
|
||||
const server = previousServers.find((s) => s.address === serverUrl);
|
||||
if (!server || server.accounts.length === 0) return;
|
||||
|
||||
Alert.alert(
|
||||
t("server.remove_saved_login"),
|
||||
t("server.remove_saved_login_description"),
|
||||
@@ -67,14 +181,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
text: t("common.remove"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteServerCredential(serverUrl);
|
||||
// Update UI
|
||||
const updated = previousServers.map((s) =>
|
||||
s.address === serverUrl
|
||||
? { ...s, hasCredentials: false, username: undefined }
|
||||
: s,
|
||||
);
|
||||
setPreviousServers(JSON.stringify(updated));
|
||||
// Remove first account
|
||||
await deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
refreshServers();
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -84,11 +193,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
const handleRemoveServer = useCallback(
|
||||
async (serverUrl: string) => {
|
||||
await removeServerFromList(serverUrl);
|
||||
// Update UI
|
||||
const filtered = previousServers.filter((s) => s.address !== serverUrl);
|
||||
setPreviousServers(JSON.stringify(filtered));
|
||||
refreshServers();
|
||||
},
|
||||
[previousServers, setPreviousServers],
|
||||
[setPreviousServers],
|
||||
);
|
||||
|
||||
const renderRightActions = useCallback(
|
||||
@@ -106,6 +213,39 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
[handleRemoveServer],
|
||||
);
|
||||
|
||||
const getServerSubtitle = (server: SavedServer): string | undefined => {
|
||||
const accountCount = server.accounts?.length || 0;
|
||||
|
||||
if (accountCount > 1) {
|
||||
return t("server.accounts_count", { count: accountCount });
|
||||
}
|
||||
if (accountCount === 1) {
|
||||
return `${server.accounts[0].username} • ${t("server.saved")}`;
|
||||
}
|
||||
return server.name ? server.address : undefined;
|
||||
};
|
||||
|
||||
const getSecurityIcon = (
|
||||
server: SavedServer,
|
||||
): keyof typeof Ionicons.glyphMap | null => {
|
||||
const accountCount = server.accounts?.length || 0;
|
||||
if (accountCount === 0) return null;
|
||||
|
||||
if (accountCount > 1) {
|
||||
return "people";
|
||||
}
|
||||
|
||||
const account = server.accounts[0];
|
||||
switch (account.securityType) {
|
||||
case "pin":
|
||||
return "keypad";
|
||||
case "password":
|
||||
return "lock-closed";
|
||||
default:
|
||||
return "key";
|
||||
}
|
||||
};
|
||||
|
||||
if (!previousServers.length) return null;
|
||||
|
||||
return (
|
||||
@@ -117,9 +257,10 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
server={s}
|
||||
loadingServer={loadingServer}
|
||||
onPress={() => handleServerPress(s)}
|
||||
onRemoveCredential={() => handleRemoveCredential(s.address)}
|
||||
onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
|
||||
renderRightActions={renderRightActions}
|
||||
t={t}
|
||||
subtitle={getServerSubtitle(s)}
|
||||
securityIcon={getSecurityIcon(s)}
|
||||
/>
|
||||
))}
|
||||
<ListItem
|
||||
@@ -133,6 +274,51 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
|
||||
{t("server.swipe_to_remove")}
|
||||
</Text>
|
||||
|
||||
{/* Account Selection Sheet */}
|
||||
<AccountsSheet
|
||||
open={accountsSheetOpen}
|
||||
setOpen={setAccountsSheetOpen}
|
||||
server={selectedServer}
|
||||
onAccountSelect={(account) => {
|
||||
if (selectedServer) {
|
||||
handleAccountLogin(selectedServer, account);
|
||||
}
|
||||
}}
|
||||
onAddAccount={() => {
|
||||
if (selectedServer && onAddAccount) {
|
||||
onAddAccount(selectedServer);
|
||||
}
|
||||
}}
|
||||
onAccountDeleted={refreshServers}
|
||||
/>
|
||||
|
||||
{/* PIN Entry Modal */}
|
||||
<PINEntryModal
|
||||
visible={pinModalVisible}
|
||||
onClose={() => {
|
||||
setPinModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
onSuccess={handlePinSuccess}
|
||||
onForgotPIN={handleForgotPIN}
|
||||
serverUrl={selectedServer?.address || ""}
|
||||
userId={selectedAccount?.userId || ""}
|
||||
username={selectedAccount?.username || ""}
|
||||
/>
|
||||
|
||||
{/* Password Entry Modal */}
|
||||
<PasswordEntryModal
|
||||
visible={passwordModalVisible}
|
||||
onClose={() => {
|
||||
setPasswordModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
username={selectedAccount?.username || ""}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -146,7 +332,8 @@ interface ServerItemProps {
|
||||
serverUrl: string,
|
||||
swipeableRef: React.RefObject<Swipeable | null>,
|
||||
) => React.ReactNode;
|
||||
t: (key: string) => string;
|
||||
subtitle?: string;
|
||||
securityIcon: keyof typeof Ionicons.glyphMap | null;
|
||||
}
|
||||
|
||||
const ServerItem: React.FC<ServerItemProps> = ({
|
||||
@@ -155,9 +342,11 @@ const ServerItem: React.FC<ServerItemProps> = ({
|
||||
onPress,
|
||||
onRemoveCredential,
|
||||
renderRightActions,
|
||||
t,
|
||||
subtitle,
|
||||
securityIcon,
|
||||
}) => {
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
const hasAccounts = server.accounts?.length > 0;
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
@@ -170,19 +359,13 @@ const ServerItem: React.FC<ServerItemProps> = ({
|
||||
<ListItem
|
||||
onPress={onPress}
|
||||
title={server.name || server.address}
|
||||
subtitle={
|
||||
server.hasCredentials
|
||||
? `${server.username} • ${t("server.saved")}`
|
||||
: server.name
|
||||
? server.address
|
||||
: undefined
|
||||
}
|
||||
subtitle={subtitle}
|
||||
showArrow={loadingServer !== server.address}
|
||||
disabled={loadingServer === server.address}
|
||||
>
|
||||
{loadingServer === server.address ? (
|
||||
<ActivityIndicator size='small' color={Colors.primary} />
|
||||
) : server.hasCredentials ? (
|
||||
) : hasAccounts && securityIcon ? (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -191,7 +374,7 @@ const ServerItem: React.FC<ServerItemProps> = ({
|
||||
className='p-1'
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name='key' size={16} color={Colors.primary} />
|
||||
<Ionicons name={securityIcon} size={16} color={Colors.primary} />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</ListItem>
|
||||
|
||||
252
components/SaveAccountModal.tsx
Normal file
252
components/SaveAccountModal.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { PinInput } from "./inputs/PinInput";
|
||||
|
||||
interface SaveAccountModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface SecurityOption {
|
||||
type: AccountSecurityType;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
}
|
||||
|
||||
const SECURITY_OPTIONS: SecurityOption[] = [
|
||||
{
|
||||
type: "none",
|
||||
titleKey: "save_account.no_protection",
|
||||
descriptionKey: "save_account.no_protection_desc",
|
||||
icon: "flash-outline",
|
||||
},
|
||||
{
|
||||
type: "pin",
|
||||
titleKey: "save_account.pin_code",
|
||||
descriptionKey: "save_account.pin_code_desc",
|
||||
icon: "keypad-outline",
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
titleKey: "save_account.password",
|
||||
descriptionKey: "save_account.password_desc",
|
||||
icon: "lock-closed-outline",
|
||||
},
|
||||
];
|
||||
|
||||
export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSave,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [pinError, setPinError] = useState<string | null>(null);
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["70%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setSelectedType("none");
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
};
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||
setSelectedType(type);
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (selectedType === "pin") {
|
||||
if (pinCode.length !== 4) {
|
||||
setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
|
||||
return;
|
||||
}
|
||||
onSave("pin", pinCode);
|
||||
} else {
|
||||
onSave(selectedType);
|
||||
}
|
||||
resetState();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canSave = () => {
|
||||
if (selectedType === "pin") {
|
||||
return pinCode.length === 4;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
backdropComponent={renderBackdrop}
|
||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||
keyboardBlurBehavior='restore'
|
||||
android_keyboardInputMode='adjustResize'
|
||||
topInset={isAndroid ? 0 : undefined}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-4'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("save_account.title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>{username}</Text>
|
||||
</View>
|
||||
|
||||
{/* PIN Entry Step */}
|
||||
{selectedType === "pin" ? (
|
||||
<View className='flex-1'>
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||
<Text className='text-neutral-100 text-center text-lg mb-4'>
|
||||
{t("pin.setup_pin")}
|
||||
</Text>
|
||||
<PinInput
|
||||
value={pinCode}
|
||||
onChangeText={setPinCode}
|
||||
length={4}
|
||||
style={{ paddingHorizontal: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
{pinError && (
|
||||
<Text className='text-red-500 text-center mt-3'>
|
||||
{pinError}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
/* Security Options */
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-400 mb-3'>
|
||||
{t("save_account.security_option")}
|
||||
</Text>
|
||||
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||
{SECURITY_OPTIONS.map((option, index) => (
|
||||
<TouchableOpacity
|
||||
key={option.type}
|
||||
onPress={() => handleOptionSelect(option.type)}
|
||||
className={`flex-row items-center p-4 ${
|
||||
index < SECURITY_OPTIONS.length - 1
|
||||
? "border-b border-neutral-700"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||
<Ionicons name={option.icon} size={20} color='white' />
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-100 font-medium'>
|
||||
{t(option.titleKey)}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{t(option.descriptionKey)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-6 h-6 rounded-full border-2 items-center justify-center ${
|
||||
selectedType === option.type
|
||||
? "border-purple-500 bg-purple-500"
|
||||
: "border-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{selectedType === option.type && (
|
||||
<Ionicons name='checkmark' size={14} color='white' />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<View className='flex-row gap-3 mt-4'>
|
||||
<Button onPress={handleCancel} color='black' className='flex-1'>
|
||||
{t("save_account.cancel_button")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleSave}
|
||||
color='purple'
|
||||
className='flex-1'
|
||||
disabled={!canSave()}
|
||||
>
|
||||
{t("save_account.save_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -48,6 +48,7 @@
|
||||
"expo-brightness": "~14.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "18.0.13",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-font": "~14.0.10",
|
||||
|
||||
@@ -27,11 +27,14 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
deleteServerCredential,
|
||||
getServerCredential,
|
||||
migrateServersList,
|
||||
type SavedServer,
|
||||
saveServerCredential,
|
||||
type AccountSecurityType,
|
||||
addServerToList,
|
||||
deleteAccountCredential,
|
||||
getAccountCredential,
|
||||
hashPIN,
|
||||
migrateToMultiAccount,
|
||||
saveAccountCredential,
|
||||
updateAccountToken,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
@@ -43,6 +46,12 @@ export const apiAtom = atom<Api | null>(null);
|
||||
export const userAtom = atom<UserDto | null>(null);
|
||||
export const wsAtom = atom<WebSocket | null>(null);
|
||||
|
||||
interface LoginOptions {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
}
|
||||
|
||||
interface JellyfinContextValue {
|
||||
discoverServers: (url: string) => Promise<Server[]>;
|
||||
setServer: (server: Server) => Promise<void>;
|
||||
@@ -51,11 +60,20 @@ interface JellyfinContextValue {
|
||||
username: string,
|
||||
password: string,
|
||||
serverName?: string,
|
||||
options?: LoginOptions,
|
||||
) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
initiateQuickConnect: () => Promise<string | undefined>;
|
||||
loginWithSavedCredential: (serverUrl: string) => Promise<void>;
|
||||
removeSavedCredential: (serverUrl: string) => Promise<void>;
|
||||
loginWithSavedCredential: (
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
) => Promise<void>;
|
||||
loginWithPassword: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<void>;
|
||||
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||
@@ -207,28 +225,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
storage.set("serverUrl", server.address);
|
||||
},
|
||||
onSuccess: async (_, server) => {
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]",
|
||||
) as SavedServer[];
|
||||
|
||||
// Check if we have saved credentials for this server
|
||||
const existingServer = previousServers.find(
|
||||
(s) => s.address === server.address,
|
||||
);
|
||||
|
||||
const updatedServers: SavedServer[] = [
|
||||
{
|
||||
address: server.address,
|
||||
name: existingServer?.name,
|
||||
hasCredentials: existingServer?.hasCredentials ?? false,
|
||||
username: existingServer?.username,
|
||||
},
|
||||
...previousServers.filter((s) => s.address !== server.address),
|
||||
];
|
||||
storage.set(
|
||||
"previousServers",
|
||||
JSON.stringify(updatedServers.slice(0, 5)),
|
||||
);
|
||||
// Add server to the list (will update existing or add new)
|
||||
addServerToList(server.address);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to set server:", error);
|
||||
@@ -250,10 +248,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
username,
|
||||
password,
|
||||
serverName,
|
||||
options,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
serverName?: string;
|
||||
options?: LoginOptions;
|
||||
}) => {
|
||||
if (!api || !jellyfin) throw new Error("API not initialized");
|
||||
|
||||
@@ -266,15 +266,24 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
storage.set("token", auth.data?.AccessToken);
|
||||
|
||||
// Save credentials to secure storage for quick switching
|
||||
if (api.basePath) {
|
||||
await saveServerCredential({
|
||||
// Save credentials to secure storage if requested
|
||||
if (api.basePath && options?.saveAccount) {
|
||||
const securityType = options.securityType || "none";
|
||||
let pinHash: string | undefined;
|
||||
|
||||
if (securityType === "pin" && options.pinCode) {
|
||||
pinHash = await hashPIN(options.pinCode);
|
||||
}
|
||||
|
||||
await saveAccountCredential({
|
||||
serverUrl: api.basePath,
|
||||
serverName: serverName || "",
|
||||
token: auth.data.AccessToken,
|
||||
userId: auth.data.User.Id || "",
|
||||
username,
|
||||
savedAt: Date.now(),
|
||||
securityType,
|
||||
pinHash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -347,10 +356,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
});
|
||||
|
||||
const loginWithSavedCredentialMutation = useMutation({
|
||||
mutationFn: async (serverUrl: string) => {
|
||||
mutationFn: async ({
|
||||
serverUrl,
|
||||
userId,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
||||
|
||||
const credential = await getServerCredential(serverUrl);
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (!credential) {
|
||||
throw new Error("No saved credential found");
|
||||
}
|
||||
@@ -372,21 +387,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
storage.set("token", credential.token);
|
||||
storage.set("user", JSON.stringify(response.data));
|
||||
|
||||
// Update previousServers list
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]",
|
||||
) as SavedServer[];
|
||||
const updatedServers: SavedServer[] = [
|
||||
{
|
||||
address: serverUrl,
|
||||
name: credential.serverName,
|
||||
hasCredentials: true,
|
||||
username: credential.username,
|
||||
},
|
||||
...previousServers.filter((s) => s.address !== serverUrl),
|
||||
].slice(0, 5);
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
|
||||
// Refresh plugin settings
|
||||
await refreshStreamyfinPluginSettings();
|
||||
} catch (error) {
|
||||
@@ -395,7 +395,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await deleteServerCredential(serverUrl);
|
||||
await deleteAccountCredential(serverUrl, userId);
|
||||
throw new Error(t("server.session_expired"));
|
||||
}
|
||||
throw error;
|
||||
@@ -406,9 +406,60 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const loginWithPasswordMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}) => {
|
||||
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
||||
|
||||
// Create API instance for the server
|
||||
const apiInstance = jellyfin.createApi(serverUrl);
|
||||
if (!apiInstance) {
|
||||
throw new Error("Failed to create API instance");
|
||||
}
|
||||
|
||||
// Authenticate with password
|
||||
const auth = await apiInstance.authenticateUserByName(username, password);
|
||||
|
||||
if (auth.data.AccessToken && auth.data.User) {
|
||||
setUser(auth.data.User);
|
||||
storage.set("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
|
||||
storage.set("serverUrl", serverUrl);
|
||||
storage.set("token", auth.data.AccessToken);
|
||||
|
||||
// Update the saved credential with new token
|
||||
await updateAccountToken(
|
||||
serverUrl,
|
||||
auth.data.User.Id || "",
|
||||
auth.data.AccessToken,
|
||||
);
|
||||
|
||||
// Refresh plugin settings
|
||||
await refreshStreamyfinPluginSettings();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Password login failed:", error);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const removeSavedCredentialMutation = useMutation({
|
||||
mutationFn: async (serverUrl: string) => {
|
||||
await deleteServerCredential(serverUrl);
|
||||
mutationFn: async ({
|
||||
serverUrl,
|
||||
userId,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
await deleteAccountCredential(serverUrl, userId);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to remove saved credential:", error);
|
||||
@@ -429,12 +480,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
if (!jellyfin) return;
|
||||
|
||||
try {
|
||||
// Run migration for server list format (once)
|
||||
const migrated = storage.getBoolean("credentialsMigrated");
|
||||
if (!migrated) {
|
||||
await migrateServersList();
|
||||
storage.set("credentialsMigrated", true);
|
||||
}
|
||||
// Run migration to multi-account format (once)
|
||||
await migrateToMultiAccount();
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const serverUrl = getServerUrlFromStorage();
|
||||
@@ -452,16 +499,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
const existingCredential = await getServerCredential(serverUrl);
|
||||
if (!existingCredential && storedUser?.Name) {
|
||||
await saveServerCredential({
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id || "",
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -478,14 +531,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
discoverServers,
|
||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||
removeServer: () => removeServerMutation.mutateAsync(),
|
||||
login: (username, password, serverName) =>
|
||||
loginMutation.mutateAsync({ username, password, serverName }),
|
||||
login: (username, password, serverName, options) =>
|
||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
initiateQuickConnect,
|
||||
loginWithSavedCredential: (serverUrl) =>
|
||||
loginWithSavedCredentialMutation.mutateAsync(serverUrl),
|
||||
removeSavedCredential: (serverUrl) =>
|
||||
removeSavedCredentialMutation.mutateAsync(serverUrl),
|
||||
loginWithSavedCredential: (serverUrl, userId) =>
|
||||
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||
loginWithPassword: (serverUrl, username, password) =>
|
||||
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
|
||||
removeSavedCredential: (serverUrl, userId) =>
|
||||
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -38,7 +38,40 @@
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time."
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Checking server connection...",
|
||||
@@ -441,7 +474,11 @@
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { storage } from "./mmkv";
|
||||
|
||||
const CREDENTIAL_KEY_PREFIX = "credential_";
|
||||
const MULTI_ACCOUNT_MIGRATED_KEY = "multiAccountMigrated";
|
||||
|
||||
/**
|
||||
* Security type for saved accounts.
|
||||
*/
|
||||
export type AccountSecurityType = "none" | "pin" | "password";
|
||||
|
||||
/**
|
||||
* Credential stored in secure storage for a specific account.
|
||||
*/
|
||||
export interface ServerCredential {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
@@ -10,50 +20,118 @@ export interface ServerCredential {
|
||||
userId: string;
|
||||
username: string;
|
||||
savedAt: number;
|
||||
securityType: AccountSecurityType;
|
||||
pinHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account summary stored in SavedServer for display in UI.
|
||||
*/
|
||||
export interface SavedServerAccount {
|
||||
userId: string;
|
||||
username: string;
|
||||
securityType: AccountSecurityType;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server with multiple saved accounts.
|
||||
*/
|
||||
export interface SavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
accounts: SavedServerAccount[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy interface for migration purposes.
|
||||
*/
|
||||
interface LegacySavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
hasCredentials?: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy credential interface for migration purposes.
|
||||
*/
|
||||
interface LegacyServerCredential {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface SavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
hasCredentials?: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode server URL to valid secure store key.
|
||||
* Secure store keys must be alphanumeric with underscores.
|
||||
* Encode server URL to valid secure store key (legacy, for migration).
|
||||
*/
|
||||
export function serverUrlToKey(serverUrl: string): string {
|
||||
// Use base64 encoding, replace non-alphanumeric chars with underscores
|
||||
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials for a server to secure storage.
|
||||
* Generate credential key for a specific account (serverUrl + userId).
|
||||
*/
|
||||
export async function saveServerCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
const key = serverUrlToKey(credential.serverUrl);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
export function credentialKey(serverUrl: string, userId: string): string {
|
||||
const combined = `${serverUrl}:${userId}`;
|
||||
const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||
}
|
||||
|
||||
// Update previousServers to mark this server as having credentials
|
||||
updatePreviousServerCredentialFlag(
|
||||
credential.serverUrl,
|
||||
true,
|
||||
credential.username,
|
||||
credential.serverName,
|
||||
/**
|
||||
* Hash a PIN using SHA256.
|
||||
*/
|
||||
export async function hashPIN(pin: string): Promise<string> {
|
||||
return await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
pin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve credentials for a server from secure storage.
|
||||
* Verify a PIN against stored hash.
|
||||
*/
|
||||
export async function getServerCredential(
|
||||
export async function verifyAccountPIN(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
pin: string,
|
||||
): Promise<boolean> {
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (!credential?.pinHash) return false;
|
||||
const inputHash = await hashPIN(pin);
|
||||
return inputHash === credential.pinHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credential for a specific account.
|
||||
*/
|
||||
export async function saveAccountCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
const key = credentialKey(credential.serverUrl, credential.userId);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
|
||||
// Update previousServers to include this account
|
||||
addAccountToServer(credential.serverUrl, credential.serverName, {
|
||||
userId: credential.userId,
|
||||
username: credential.username,
|
||||
securityType: credential.securityType,
|
||||
savedAt: credential.savedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve credential for a specific account.
|
||||
*/
|
||||
export async function getAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<ServerCredential | null> {
|
||||
const key = serverUrlToKey(serverUrl);
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
const stored = await SecureStore.getItemAsync(key);
|
||||
|
||||
if (stored) {
|
||||
@@ -67,57 +145,138 @@ export async function getServerCredential(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete credentials for a server from secure storage.
|
||||
* Delete credential for a specific account.
|
||||
*/
|
||||
export async function deleteServerCredential(serverUrl: string): Promise<void> {
|
||||
const key = serverUrlToKey(serverUrl);
|
||||
export async function deleteAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
|
||||
// Update previousServers to mark this server as not having credentials
|
||||
updatePreviousServerCredentialFlag(serverUrl, false);
|
||||
// Remove account from previousServers
|
||||
removeAccountFromServer(serverUrl, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist for a server (without retrieving them).
|
||||
* Get all credentials for a server (by iterating through accounts).
|
||||
*/
|
||||
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
|
||||
const key = serverUrlToKey(serverUrl);
|
||||
export async function getServerAccounts(
|
||||
serverUrl: string,
|
||||
): Promise<ServerCredential[]> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (!server) return [];
|
||||
|
||||
const credentials: ServerCredential[] = [];
|
||||
for (const account of server.accounts) {
|
||||
const credential = await getAccountCredential(serverUrl, account.userId);
|
||||
if (credential) {
|
||||
credentials.push(credential);
|
||||
}
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist for a specific account.
|
||||
*/
|
||||
export async function hasAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
const stored = await SecureStore.getItemAsync(key);
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all stored credentials for all servers.
|
||||
* Delete all credentials for all accounts on all servers.
|
||||
*/
|
||||
export async function clearAllCredentials(): Promise<void> {
|
||||
const previousServers = getPreviousServers();
|
||||
|
||||
for (const server of previousServers) {
|
||||
await deleteServerCredential(server.address);
|
||||
for (const account of server.accounts) {
|
||||
const key = credentialKey(server.address, account.userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all accounts from servers
|
||||
const clearedServers = previousServers.map((server) => ({
|
||||
...server,
|
||||
accounts: [],
|
||||
}));
|
||||
storage.set("previousServers", JSON.stringify(clearedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update the previousServers list in MMKV with credential status.
|
||||
* Add or update an account in a server's accounts list.
|
||||
*/
|
||||
function updatePreviousServerCredentialFlag(
|
||||
function addAccountToServer(
|
||||
serverUrl: string,
|
||||
hasCredentials: boolean,
|
||||
username?: string,
|
||||
serverName?: string,
|
||||
serverName: string,
|
||||
account: SavedServerAccount,
|
||||
): void {
|
||||
const previousServers = getPreviousServers();
|
||||
let serverFound = false;
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
serverFound = true;
|
||||
// Check if account already exists
|
||||
const existingIndex = server.accounts.findIndex(
|
||||
(a) => a.userId === account.userId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing account
|
||||
const updatedAccounts = [...server.accounts];
|
||||
updatedAccounts[existingIndex] = account;
|
||||
return {
|
||||
...server,
|
||||
name: serverName || server.name,
|
||||
accounts: updatedAccounts,
|
||||
};
|
||||
}
|
||||
// Add new account
|
||||
return {
|
||||
...server,
|
||||
hasCredentials,
|
||||
username: username || server.username,
|
||||
name: serverName || server.name,
|
||||
accounts: [...server.accounts, account],
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
// If server not found, add it
|
||||
if (!serverFound) {
|
||||
updatedServers.push({
|
||||
address: serverUrl,
|
||||
name: serverName,
|
||||
accounts: [account],
|
||||
});
|
||||
}
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account from a server's accounts list.
|
||||
*/
|
||||
function removeAccountFromServer(serverUrl: string, userId: string): void {
|
||||
const previousServers = getPreviousServers();
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
return {
|
||||
...server,
|
||||
accounts: server.accounts.filter((a) => a.userId !== userId),
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
@@ -137,46 +296,217 @@ export function getPreviousServers(): SavedServer[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a server from the previous servers list and delete its credentials.
|
||||
* Remove a server from the list and delete all its account credentials.
|
||||
*/
|
||||
export async function removeServerFromList(serverUrl: string): Promise<void> {
|
||||
// First delete any saved credentials
|
||||
await deleteServerCredential(serverUrl);
|
||||
// Then remove from the list
|
||||
const previousServers = getPreviousServers();
|
||||
const filtered = previousServers.filter((s) => s.address !== serverUrl);
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
|
||||
// Delete all account credentials for this server
|
||||
if (server) {
|
||||
for (const account of server.accounts) {
|
||||
const key = credentialKey(serverUrl, account.userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove server from list
|
||||
const filtered = servers.filter((s) => s.address !== serverUrl);
|
||||
storage.set("previousServers", JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing previousServers to new format (add hasCredentials: false).
|
||||
* Add a server to the list without credentials (for server discovery).
|
||||
*/
|
||||
export function addServerToList(serverUrl: string, serverName?: string): void {
|
||||
const servers = getPreviousServers();
|
||||
const existing = servers.find((s) => s.address === serverUrl);
|
||||
|
||||
if (existing) {
|
||||
// Update name if provided
|
||||
if (serverName) {
|
||||
const updated = servers.map((s) =>
|
||||
s.address === serverUrl ? { ...s, name: serverName } : s,
|
||||
);
|
||||
storage.set("previousServers", JSON.stringify(updated));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new server with empty accounts
|
||||
const newServer: SavedServer = {
|
||||
address: serverUrl,
|
||||
name: serverName,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
// Keep max 10 servers
|
||||
const updatedServers = [newServer, ...servers].slice(0, 10);
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-account format to multi-account format.
|
||||
* Should be called on app startup.
|
||||
*/
|
||||
export async function migrateServersList(): Promise<void> {
|
||||
export async function migrateToMultiAccount(): Promise<void> {
|
||||
// Check if already migrated
|
||||
if (storage.getBoolean(MULTI_ACCOUNT_MIGRATED_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = storage.getString("previousServers");
|
||||
if (!stored) return;
|
||||
if (!stored) {
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const servers = JSON.parse(stored);
|
||||
// Check if migration needed (old format doesn't have hasCredentials)
|
||||
if (servers.length > 0 && servers[0].hasCredentials === undefined) {
|
||||
const migrated = servers.map((server: SavedServer) => ({
|
||||
address: server.address,
|
||||
name: server.name,
|
||||
hasCredentials: false,
|
||||
username: undefined,
|
||||
}));
|
||||
storage.set("previousServers", JSON.stringify(migrated));
|
||||
|
||||
// Check if already in new format (has accounts array)
|
||||
if (servers.length > 0 && Array.isArray(servers[0].accounts)) {
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate from legacy format
|
||||
const migratedServers: SavedServer[] = [];
|
||||
|
||||
for (const legacyServer of servers as LegacySavedServer[]) {
|
||||
const newServer: SavedServer = {
|
||||
address: legacyServer.address,
|
||||
name: legacyServer.name,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
// Try to get existing credential using legacy key
|
||||
if (legacyServer.hasCredentials) {
|
||||
const legacyKey = serverUrlToKey(legacyServer.address);
|
||||
const storedCred = await SecureStore.getItemAsync(legacyKey);
|
||||
|
||||
if (storedCred) {
|
||||
try {
|
||||
const legacyCred = JSON.parse(storedCred) as LegacyServerCredential;
|
||||
|
||||
// Create new credential with security type
|
||||
const newCredential: ServerCredential = {
|
||||
...legacyCred,
|
||||
securityType: "none", // Existing accounts get no protection (preserve quick-login)
|
||||
};
|
||||
|
||||
// Save with new key format
|
||||
const newKey = credentialKey(
|
||||
legacyServer.address,
|
||||
legacyCred.userId,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
newKey,
|
||||
JSON.stringify(newCredential),
|
||||
);
|
||||
|
||||
// Delete old key
|
||||
await SecureStore.deleteItemAsync(legacyKey);
|
||||
|
||||
// Add account to server
|
||||
newServer.accounts.push({
|
||||
userId: legacyCred.userId,
|
||||
username: legacyCred.username,
|
||||
securityType: "none",
|
||||
savedAt: legacyCred.savedAt,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migratedServers.push(newServer);
|
||||
}
|
||||
|
||||
storage.set("previousServers", JSON.stringify(migratedServers));
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
} catch {
|
||||
// If parsing fails, reset to empty array
|
||||
storage.set("previousServers", "[]");
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate current session credentials to secure storage.
|
||||
* Should be called on app startup for existing users.
|
||||
* Update account's token after successful login.
|
||||
*/
|
||||
export async function updateAccountToken(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
newToken: string,
|
||||
): Promise<void> {
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (credential) {
|
||||
credential.token = newToken;
|
||||
credential.savedAt = Date.now();
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility during transition
|
||||
// These delegate to new functions
|
||||
|
||||
/**
|
||||
* @deprecated Use saveAccountCredential instead
|
||||
*/
|
||||
export async function saveServerCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
return saveAccountCredential(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccountCredential instead
|
||||
*/
|
||||
export async function getServerCredential(
|
||||
serverUrl: string,
|
||||
): Promise<ServerCredential | null> {
|
||||
// Try to get first account's credential for backward compatibility
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (server && server.accounts.length > 0) {
|
||||
return getAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteAccountCredential instead
|
||||
*/
|
||||
export async function deleteServerCredential(serverUrl: string): Promise<void> {
|
||||
// Delete first account for backward compatibility
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (server && server.accounts.length > 0) {
|
||||
return deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use hasAccountCredential instead
|
||||
*/
|
||||
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
return server ? server.accounts.length > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use migrateToMultiAccount instead
|
||||
*/
|
||||
export async function migrateServersList(): Promise<void> {
|
||||
return migrateToMultiAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use saveAccountCredential instead
|
||||
*/
|
||||
export async function migrateCurrentSessionToSecureStorage(
|
||||
serverUrl: string,
|
||||
@@ -185,17 +515,18 @@ export async function migrateCurrentSessionToSecureStorage(
|
||||
username: string,
|
||||
serverName?: string,
|
||||
): Promise<void> {
|
||||
const existingCredential = await getServerCredential(serverUrl);
|
||||
const existingCredential = await getAccountCredential(serverUrl, userId);
|
||||
|
||||
// Only save if not already saved
|
||||
if (!existingCredential) {
|
||||
await saveServerCredential({
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: serverName || "",
|
||||
token,
|
||||
userId,
|
||||
username,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user