mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 17:33:00 +01: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,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import type {
|
||||||
|
AccountSecurityType,
|
||||||
|
SavedServer,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -37,6 +43,7 @@ const Login: React.FC = () => {
|
|||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -57,6 +64,14 @@ const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
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
|
* A way to auto login based on a link
|
||||||
*/
|
*/
|
||||||
@@ -101,12 +116,34 @@ const Login: React.FC = () => {
|
|||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
Keyboard.dismiss();
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
await login(username, password, serverName, options);
|
||||||
if (result.success) {
|
|
||||||
await login(credentials.username, credentials.password, serverName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
@@ -118,11 +155,45 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setPendingLogin(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickLoginWithSavedCredential = async (serverUrl: string) => {
|
const handleSaveAccountConfirm = async (
|
||||||
await loginWithSavedCredential(serverUrl);
|
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);
|
await handleConnect(s.address);
|
||||||
}}
|
}}
|
||||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -470,6 +543,21 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
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'>
|
<View className='flex flex-row items-center justify-between'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
@@ -546,11 +634,24 @@ const Login: React.FC = () => {
|
|||||||
await handleConnect(s.address);
|
await handleConnect(s.address);
|
||||||
}}
|
}}
|
||||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Save Account Modal */}
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -29,6 +29,7 @@
|
|||||||
"expo-brightness": "~14.0.8",
|
"expo-brightness": "~14.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-constants": "18.0.13",
|
"expo-constants": "18.0.13",
|
||||||
|
"expo-crypto": "^15.0.8",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-device": "~8.0.10",
|
"expo-device": "~8.0.10",
|
||||||
"expo-font": "~14.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-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-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=="],
|
"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 { useMMKVString } from "react-native-mmkv";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import {
|
import {
|
||||||
deleteServerCredential,
|
deleteAccountCredential,
|
||||||
|
getPreviousServers,
|
||||||
removeServerFromList,
|
removeServerFromList,
|
||||||
type SavedServer,
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
|
import { AccountsSheet } from "./AccountsSheet";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
|
import { PasswordEntryModal } from "./PasswordEntryModal";
|
||||||
|
import { PINEntryModal } from "./PINEntryModal";
|
||||||
|
|
||||||
interface PreviousServersListProps {
|
interface PreviousServersListProps {
|
||||||
onServerSelect: (server: SavedServer) => void;
|
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> = ({
|
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||||
onServerSelect,
|
onServerSelect,
|
||||||
onQuickLogin,
|
onQuickLogin,
|
||||||
|
onPasswordLogin,
|
||||||
|
onAddAccount,
|
||||||
}) => {
|
}) => {
|
||||||
const [_previousServers, setPreviousServers] =
|
const [_previousServers, setPreviousServers] =
|
||||||
useMMKVString("previousServers");
|
useMMKVString("previousServers");
|
||||||
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
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(() => {
|
const previousServers = useMemo(() => {
|
||||||
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||||
}, [_previousServers]);
|
}, [_previousServers]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleServerPress = async (server: SavedServer) => {
|
const refreshServers = () => {
|
||||||
if (loadingServer) return; // Prevent double-tap
|
const servers = getPreviousServers();
|
||||||
|
setPreviousServers(JSON.stringify(servers));
|
||||||
|
};
|
||||||
|
|
||||||
if (server.hasCredentials && onQuickLogin) {
|
const handleAccountLogin = async (
|
||||||
// Quick login with saved credentials
|
server: SavedServer,
|
||||||
setLoadingServer(server.address);
|
account: SavedServerAccount,
|
||||||
try {
|
) => {
|
||||||
await onQuickLogin(server.address);
|
switch (account.securityType) {
|
||||||
} catch (_error) {
|
case "none":
|
||||||
// Token expired/invalid, fall back to manual login
|
// Quick login without protection
|
||||||
Alert.alert(
|
if (onQuickLogin) {
|
||||||
t("server.session_expired"),
|
setLoadingServer(server.address);
|
||||||
t("server.please_login_again"),
|
try {
|
||||||
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
await onQuickLogin(server.address, account.userId);
|
||||||
);
|
} catch {
|
||||||
} finally {
|
Alert.alert(
|
||||||
setLoadingServer(null);
|
t("server.session_expired"),
|
||||||
}
|
t("server.please_login_again"),
|
||||||
} else {
|
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||||
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(
|
Alert.alert(
|
||||||
t("server.remove_saved_login"),
|
t("server.remove_saved_login"),
|
||||||
t("server.remove_saved_login_description"),
|
t("server.remove_saved_login_description"),
|
||||||
@@ -67,14 +181,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
text: t("common.remove"),
|
text: t("common.remove"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await deleteServerCredential(serverUrl);
|
// Remove first account
|
||||||
// Update UI
|
await deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||||
const updated = previousServers.map((s) =>
|
refreshServers();
|
||||||
s.address === serverUrl
|
|
||||||
? { ...s, hasCredentials: false, username: undefined }
|
|
||||||
: s,
|
|
||||||
);
|
|
||||||
setPreviousServers(JSON.stringify(updated));
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -84,11 +193,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
const handleRemoveServer = useCallback(
|
const handleRemoveServer = useCallback(
|
||||||
async (serverUrl: string) => {
|
async (serverUrl: string) => {
|
||||||
await removeServerFromList(serverUrl);
|
await removeServerFromList(serverUrl);
|
||||||
// Update UI
|
refreshServers();
|
||||||
const filtered = previousServers.filter((s) => s.address !== serverUrl);
|
|
||||||
setPreviousServers(JSON.stringify(filtered));
|
|
||||||
},
|
},
|
||||||
[previousServers, setPreviousServers],
|
[setPreviousServers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRightActions = useCallback(
|
const renderRightActions = useCallback(
|
||||||
@@ -106,6 +213,39 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
[handleRemoveServer],
|
[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;
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,9 +257,10 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
server={s}
|
server={s}
|
||||||
loadingServer={loadingServer}
|
loadingServer={loadingServer}
|
||||||
onPress={() => handleServerPress(s)}
|
onPress={() => handleServerPress(s)}
|
||||||
onRemoveCredential={() => handleRemoveCredential(s.address)}
|
onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
|
||||||
renderRightActions={renderRightActions}
|
renderRightActions={renderRightActions}
|
||||||
t={t}
|
subtitle={getServerSubtitle(s)}
|
||||||
|
securityIcon={getSecurityIcon(s)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -133,6 +274,51 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
|
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
|
||||||
{t("server.swipe_to_remove")}
|
{t("server.swipe_to_remove")}
|
||||||
</Text>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -146,7 +332,8 @@ interface ServerItemProps {
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
swipeableRef: React.RefObject<Swipeable | null>,
|
swipeableRef: React.RefObject<Swipeable | null>,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
t: (key: string) => string;
|
subtitle?: string;
|
||||||
|
securityIcon: keyof typeof Ionicons.glyphMap | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerItem: React.FC<ServerItemProps> = ({
|
const ServerItem: React.FC<ServerItemProps> = ({
|
||||||
@@ -155,9 +342,11 @@ const ServerItem: React.FC<ServerItemProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
onRemoveCredential,
|
onRemoveCredential,
|
||||||
renderRightActions,
|
renderRightActions,
|
||||||
t,
|
subtitle,
|
||||||
|
securityIcon,
|
||||||
}) => {
|
}) => {
|
||||||
const swipeableRef = useRef<Swipeable>(null);
|
const swipeableRef = useRef<Swipeable>(null);
|
||||||
|
const hasAccounts = server.accounts?.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Swipeable
|
<Swipeable
|
||||||
@@ -170,19 +359,13 @@ const ServerItem: React.FC<ServerItemProps> = ({
|
|||||||
<ListItem
|
<ListItem
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
title={server.name || server.address}
|
title={server.name || server.address}
|
||||||
subtitle={
|
subtitle={subtitle}
|
||||||
server.hasCredentials
|
|
||||||
? `${server.username} • ${t("server.saved")}`
|
|
||||||
: server.name
|
|
||||||
? server.address
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
showArrow={loadingServer !== server.address}
|
showArrow={loadingServer !== server.address}
|
||||||
disabled={loadingServer === server.address}
|
disabled={loadingServer === server.address}
|
||||||
>
|
>
|
||||||
{loadingServer === server.address ? (
|
{loadingServer === server.address ? (
|
||||||
<ActivityIndicator size='small' color={Colors.primary} />
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
) : server.hasCredentials ? (
|
) : hasAccounts && securityIcon ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -191,7 +374,7 @@ const ServerItem: React.FC<ServerItemProps> = ({
|
|||||||
className='p-1'
|
className='p-1'
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
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>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
</ListItem>
|
</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-brightness": "~14.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-constants": "18.0.13",
|
"expo-constants": "18.0.13",
|
||||||
|
"expo-crypto": "^15.0.8",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-device": "~8.0.10",
|
"expo-device": "~8.0.10",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import {
|
import {
|
||||||
deleteServerCredential,
|
type AccountSecurityType,
|
||||||
getServerCredential,
|
addServerToList,
|
||||||
migrateServersList,
|
deleteAccountCredential,
|
||||||
type SavedServer,
|
getAccountCredential,
|
||||||
saveServerCredential,
|
hashPIN,
|
||||||
|
migrateToMultiAccount,
|
||||||
|
saveAccountCredential,
|
||||||
|
updateAccountToken,
|
||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { store } from "@/utils/store";
|
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 userAtom = atom<UserDto | null>(null);
|
||||||
export const wsAtom = atom<WebSocket | null>(null);
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
|
|
||||||
|
interface LoginOptions {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface JellyfinContextValue {
|
interface JellyfinContextValue {
|
||||||
discoverServers: (url: string) => Promise<Server[]>;
|
discoverServers: (url: string) => Promise<Server[]>;
|
||||||
setServer: (server: Server) => Promise<void>;
|
setServer: (server: Server) => Promise<void>;
|
||||||
@@ -51,11 +60,20 @@ interface JellyfinContextValue {
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
serverName?: string,
|
serverName?: string,
|
||||||
|
options?: LoginOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
initiateQuickConnect: () => Promise<string | undefined>;
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
loginWithSavedCredential: (serverUrl: string) => Promise<void>;
|
loginWithSavedCredential: (
|
||||||
removeSavedCredential: (serverUrl: string) => Promise<void>;
|
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>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
@@ -207,28 +225,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
storage.set("serverUrl", server.address);
|
storage.set("serverUrl", server.address);
|
||||||
},
|
},
|
||||||
onSuccess: async (_, server) => {
|
onSuccess: async (_, server) => {
|
||||||
const previousServers = JSON.parse(
|
// Add server to the list (will update existing or add new)
|
||||||
storage.getString("previousServers") || "[]",
|
addServerToList(server.address);
|
||||||
) 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)),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to set server:", error);
|
console.error("Failed to set server:", error);
|
||||||
@@ -250,10 +248,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
serverName,
|
serverName,
|
||||||
|
options,
|
||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
|
options?: LoginOptions;
|
||||||
}) => {
|
}) => {
|
||||||
if (!api || !jellyfin) throw new Error("API not initialized");
|
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));
|
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||||
storage.set("token", auth.data?.AccessToken);
|
storage.set("token", auth.data?.AccessToken);
|
||||||
|
|
||||||
// Save credentials to secure storage for quick switching
|
// Save credentials to secure storage if requested
|
||||||
if (api.basePath) {
|
if (api.basePath && options?.saveAccount) {
|
||||||
await saveServerCredential({
|
const securityType = options.securityType || "none";
|
||||||
|
let pinHash: string | undefined;
|
||||||
|
|
||||||
|
if (securityType === "pin" && options.pinCode) {
|
||||||
|
pinHash = await hashPIN(options.pinCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveAccountCredential({
|
||||||
serverUrl: api.basePath,
|
serverUrl: api.basePath,
|
||||||
serverName: serverName || "",
|
serverName: serverName || "",
|
||||||
token: auth.data.AccessToken,
|
token: auth.data.AccessToken,
|
||||||
userId: auth.data.User.Id || "",
|
userId: auth.data.User.Id || "",
|
||||||
username,
|
username,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
|
securityType,
|
||||||
|
pinHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,10 +356,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loginWithSavedCredentialMutation = useMutation({
|
const loginWithSavedCredentialMutation = useMutation({
|
||||||
mutationFn: async (serverUrl: string) => {
|
mutationFn: async ({
|
||||||
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
||||||
|
|
||||||
const credential = await getServerCredential(serverUrl);
|
const credential = await getAccountCredential(serverUrl, userId);
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
throw new Error("No saved credential found");
|
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("token", credential.token);
|
||||||
storage.set("user", JSON.stringify(response.data));
|
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
|
// Refresh plugin settings
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -395,7 +395,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
axios.isAxiosError(error) &&
|
axios.isAxiosError(error) &&
|
||||||
(error.response?.status === 401 || error.response?.status === 403)
|
(error.response?.status === 401 || error.response?.status === 403)
|
||||||
) {
|
) {
|
||||||
await deleteServerCredential(serverUrl);
|
await deleteAccountCredential(serverUrl, userId);
|
||||||
throw new Error(t("server.session_expired"));
|
throw new Error(t("server.session_expired"));
|
||||||
}
|
}
|
||||||
throw error;
|
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({
|
const removeSavedCredentialMutation = useMutation({
|
||||||
mutationFn: async (serverUrl: string) => {
|
mutationFn: async ({
|
||||||
await deleteServerCredential(serverUrl);
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
|
await deleteAccountCredential(serverUrl, userId);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to remove saved credential:", error);
|
console.error("Failed to remove saved credential:", error);
|
||||||
@@ -429,12 +480,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (!jellyfin) return;
|
if (!jellyfin) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run migration for server list format (once)
|
// Run migration to multi-account format (once)
|
||||||
const migrated = storage.getBoolean("credentialsMigrated");
|
await migrateToMultiAccount();
|
||||||
if (!migrated) {
|
|
||||||
await migrateServersList();
|
|
||||||
storage.set("credentialsMigrated", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const serverUrl = getServerUrlFromStorage();
|
const serverUrl = getServerUrlFromStorage();
|
||||||
@@ -452,16 +499,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
// Migrate current session to secure storage if not already saved
|
||||||
const existingCredential = await getServerCredential(serverUrl);
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
if (!existingCredential && storedUser?.Name) {
|
const existingCredential = await getAccountCredential(
|
||||||
await saveServerCredential({
|
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverName: "",
|
storedUser.Id,
|
||||||
token,
|
);
|
||||||
userId: storedUser.Id || "",
|
if (!existingCredential) {
|
||||||
username: storedUser.Name,
|
await saveAccountCredential({
|
||||||
savedAt: Date.now(),
|
serverUrl,
|
||||||
});
|
serverName: "",
|
||||||
|
token,
|
||||||
|
userId: storedUser.Id,
|
||||||
|
username: storedUser.Name,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
securityType: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -478,14 +531,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
discoverServers,
|
discoverServers,
|
||||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||||
removeServer: () => removeServerMutation.mutateAsync(),
|
removeServer: () => removeServerMutation.mutateAsync(),
|
||||||
login: (username, password, serverName) =>
|
login: (username, password, serverName, options) =>
|
||||||
loginMutation.mutateAsync({ username, password, serverName }),
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
loginWithSavedCredential: (serverUrl) =>
|
loginWithSavedCredential: (serverUrl, userId) =>
|
||||||
loginWithSavedCredentialMutation.mutateAsync(serverUrl),
|
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||||
removeSavedCredential: (serverUrl) =>
|
loginWithPassword: (serverUrl, username, password) =>
|
||||||
removeSavedCredentialMutation.mutateAsync(serverUrl),
|
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
|
||||||
|
removeSavedCredential: (serverUrl, userId) =>
|
||||||
|
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -38,7 +38,40 @@
|
|||||||
"session_expired": "Session Expired",
|
"session_expired": "Session Expired",
|
||||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||||
"remove_saved_login": "Remove Saved Login",
|
"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": {
|
"home": {
|
||||||
"checking_server_connection": "Checking server connection...",
|
"checking_server_connection": "Checking server connection...",
|
||||||
@@ -441,7 +474,11 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove"
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
|
"back": "Back",
|
||||||
|
"continue": "Continue",
|
||||||
|
"verifying": "Verifying..."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
import * as Crypto from "expo-crypto";
|
||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
import { storage } from "./mmkv";
|
import { storage } from "./mmkv";
|
||||||
|
|
||||||
const CREDENTIAL_KEY_PREFIX = "credential_";
|
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 {
|
export interface ServerCredential {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@@ -10,50 +20,118 @@ export interface ServerCredential {
|
|||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
savedAt: number;
|
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.
|
* Encode server URL to valid secure store key (legacy, for migration).
|
||||||
* Secure store keys must be alphanumeric with underscores.
|
|
||||||
*/
|
*/
|
||||||
export function serverUrlToKey(serverUrl: string): string {
|
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, "_");
|
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
|
||||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
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(
|
export function credentialKey(serverUrl: string, userId: string): string {
|
||||||
credential: ServerCredential,
|
const combined = `${serverUrl}:${userId}`;
|
||||||
): Promise<void> {
|
const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
|
||||||
const key = serverUrlToKey(credential.serverUrl);
|
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
}
|
||||||
|
|
||||||
// Update previousServers to mark this server as having credentials
|
/**
|
||||||
updatePreviousServerCredentialFlag(
|
* Hash a PIN using SHA256.
|
||||||
credential.serverUrl,
|
*/
|
||||||
true,
|
export async function hashPIN(pin: string): Promise<string> {
|
||||||
credential.username,
|
return await Crypto.digestStringAsync(
|
||||||
credential.serverName,
|
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,
|
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> {
|
): Promise<ServerCredential | null> {
|
||||||
const key = serverUrlToKey(serverUrl);
|
const key = credentialKey(serverUrl, userId);
|
||||||
const stored = await SecureStore.getItemAsync(key);
|
const stored = await SecureStore.getItemAsync(key);
|
||||||
|
|
||||||
if (stored) {
|
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> {
|
export async function deleteAccountCredential(
|
||||||
const key = serverUrlToKey(serverUrl);
|
serverUrl: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = credentialKey(serverUrl, userId);
|
||||||
await SecureStore.deleteItemAsync(key);
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
|
||||||
// Update previousServers to mark this server as not having credentials
|
// Remove account from previousServers
|
||||||
updatePreviousServerCredentialFlag(serverUrl, false);
|
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> {
|
export async function getServerAccounts(
|
||||||
const key = serverUrlToKey(serverUrl);
|
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);
|
const stored = await SecureStore.getItemAsync(key);
|
||||||
return stored !== null;
|
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> {
|
export async function clearAllCredentials(): Promise<void> {
|
||||||
const previousServers = getPreviousServers();
|
const previousServers = getPreviousServers();
|
||||||
|
|
||||||
for (const server of previousServers) {
|
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,
|
serverUrl: string,
|
||||||
hasCredentials: boolean,
|
serverName: string,
|
||||||
username?: string,
|
account: SavedServerAccount,
|
||||||
serverName?: string,
|
|
||||||
): void {
|
): void {
|
||||||
const previousServers = getPreviousServers();
|
const previousServers = getPreviousServers();
|
||||||
|
let serverFound = false;
|
||||||
|
|
||||||
const updatedServers = previousServers.map((server) => {
|
const updatedServers = previousServers.map((server) => {
|
||||||
if (server.address === serverUrl) {
|
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 {
|
return {
|
||||||
...server,
|
...server,
|
||||||
hasCredentials,
|
|
||||||
username: username || server.username,
|
|
||||||
name: serverName || server.name,
|
name: serverName || server.name,
|
||||||
|
accounts: [...server.accounts, account],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return server;
|
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));
|
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> {
|
export async function removeServerFromList(serverUrl: string): Promise<void> {
|
||||||
// First delete any saved credentials
|
const servers = getPreviousServers();
|
||||||
await deleteServerCredential(serverUrl);
|
const server = servers.find((s) => s.address === serverUrl);
|
||||||
// Then remove from the list
|
|
||||||
const previousServers = getPreviousServers();
|
// Delete all account credentials for this server
|
||||||
const filtered = previousServers.filter((s) => s.address !== serverUrl);
|
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));
|
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.
|
* 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");
|
const stored = storage.getString("previousServers");
|
||||||
if (!stored) return;
|
if (!stored) {
|
||||||
|
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const servers = JSON.parse(stored);
|
const servers = JSON.parse(stored);
|
||||||
// Check if migration needed (old format doesn't have hasCredentials)
|
|
||||||
if (servers.length > 0 && servers[0].hasCredentials === undefined) {
|
// Check if already in new format (has accounts array)
|
||||||
const migrated = servers.map((server: SavedServer) => ({
|
if (servers.length > 0 && Array.isArray(servers[0].accounts)) {
|
||||||
address: server.address,
|
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||||
name: server.name,
|
return;
|
||||||
hasCredentials: false,
|
|
||||||
username: undefined,
|
|
||||||
}));
|
|
||||||
storage.set("previousServers", JSON.stringify(migrated));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} catch {
|
||||||
// If parsing fails, reset to empty array
|
// If parsing fails, reset to empty array
|
||||||
storage.set("previousServers", "[]");
|
storage.set("previousServers", "[]");
|
||||||
|
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate current session credentials to secure storage.
|
* Update account's token after successful login.
|
||||||
* Should be called on app startup for existing users.
|
*/
|
||||||
|
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(
|
export async function migrateCurrentSessionToSecureStorage(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
@@ -185,17 +515,18 @@ export async function migrateCurrentSessionToSecureStorage(
|
|||||||
username: string,
|
username: string,
|
||||||
serverName?: string,
|
serverName?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existingCredential = await getServerCredential(serverUrl);
|
const existingCredential = await getAccountCredential(serverUrl, userId);
|
||||||
|
|
||||||
// Only save if not already saved
|
// Only save if not already saved
|
||||||
if (!existingCredential) {
|
if (!existingCredential) {
|
||||||
await saveServerCredential({
|
await saveAccountCredential({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverName: serverName || "",
|
serverName: serverName || "",
|
||||||
token,
|
token,
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
|
securityType: "none",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user