feat(auth): add multi-account support with PIN/password protection

This commit is contained in:
Fredrik Burmester
2026-01-09 08:37:30 +01:00
parent 8569cd390b
commit 81449963fa
11 changed files with 1794 additions and 192 deletions

View File

@@ -10,6 +10,7 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
Switch,
TouchableOpacity,
View,
} from "react-native";
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -37,6 +43,7 @@ const Login: React.FC = () => {
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
@@ -57,6 +64,14 @@ const Login: React.FC = () => {
password: _password || "",
});
// Save account state
const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
/**
* A way to auto login based on a link
*/
@@ -101,12 +116,34 @@ const Login: React.FC = () => {
const handleLogin = async () => {
Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
// Show save account modal to choose security type
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
// Login without saving
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
if (result.success) {
await login(credentials.username, credentials.password, serverName);
}
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
@@ -118,11 +155,45 @@ const Login: React.FC = () => {
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleQuickLoginWithSavedCredential = async (serverUrl: string) => {
await loginWithSavedCredential(serverUrl);
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
const handleQuickLoginWithSavedCredential = async (
serverUrl: string,
userId: string,
) => {
await loginWithSavedCredential(serverUrl, userId);
};
const handlePasswordLogin = async (
serverUrl: string,
username: string,
password: string,
) => {
await loginWithPassword(serverUrl, username, password);
};
const handleAddAccount = (server: SavedServer) => {
// Server is already selected, go to credential entry
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
/**
@@ -394,6 +465,8 @@ const Login: React.FC = () => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
@@ -470,6 +543,21 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
@@ -546,11 +634,24 @@ const Login: React.FC = () => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
};

View File

@@ -29,6 +29,7 @@
"expo-brightness": "~14.0.8",
"expo-build-properties": "~1.0.10",
"expo-constants": "18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-font": "~14.0.10",
@@ -1007,6 +1008,8 @@
"expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="],
"expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="],
"expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
"expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="],

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

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

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

View File

@@ -7,57 +7,171 @@ import { Swipeable } from "react-native-gesture-handler";
import { useMMKVString } from "react-native-mmkv";
import { Colors } from "@/constants/Colors";
import {
deleteServerCredential,
deleteAccountCredential,
getPreviousServers,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { AccountsSheet } from "./AccountsSheet";
import { Text } from "./common/Text";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { PasswordEntryModal } from "./PasswordEntryModal";
import { PINEntryModal } from "./PINEntryModal";
interface PreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string) => Promise<void>;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
onPasswordLogin?: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
onAddAccount?: (server: SavedServer) => void;
}
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
onPasswordLogin,
onAddAccount,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
// Modal states
const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
const { t } = useTranslation();
const handleServerPress = async (server: SavedServer) => {
if (loadingServer) return; // Prevent double-tap
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
if (server.hasCredentials && onQuickLogin) {
// Quick login with saved credentials
setLoadingServer(server.address);
try {
await onQuickLogin(server.address);
} catch (_error) {
// Token expired/invalid, fall back to manual login
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
setLoadingServer(null);
}
} else {
onServerSelect(server);
const handleAccountLogin = async (
server: SavedServer,
account: SavedServerAccount,
) => {
switch (account.securityType) {
case "none":
// Quick login without protection
if (onQuickLogin) {
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
setLoadingServer(null);
}
}
break;
case "pin":
// Show PIN entry modal
setSelectedServer(server);
setSelectedAccount(account);
setPinModalVisible(true);
break;
case "password":
// Show password entry modal
setSelectedServer(server);
setSelectedAccount(account);
setPasswordModalVisible(true);
break;
}
};
const handleRemoveCredential = async (serverUrl: string) => {
const handleServerPress = async (server: SavedServer) => {
if (loadingServer) return; // Prevent double-tap
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) {
// No saved accounts, go to manual login
onServerSelect(server);
} else {
// Has accounts, show account sheet (allows adding new account too)
setSelectedServer(server);
setAccountsSheetOpen(true);
}
};
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount && onQuickLogin) {
setLoadingServer(selectedServer.address);
try {
await onQuickLogin(selectedServer.address, selectedAccount.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[
{
text: t("common.ok"),
onPress: () => onServerSelect(selectedServer),
},
],
);
} finally {
setLoadingServer(null);
setSelectedAccount(null);
setSelectedServer(null);
}
}
};
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount && onPasswordLogin) {
await onPasswordLogin(
selectedServer.address,
selectedAccount.username,
password,
);
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}
};
const handleForgotPIN = async () => {
if (selectedServer && selectedAccount) {
await deleteAccountCredential(
selectedServer.address,
selectedAccount.userId,
);
refreshServers();
// Go to manual login
onServerSelect(selectedServer);
setSelectedAccount(null);
setSelectedServer(null);
}
};
const handleRemoveFirstCredential = async (serverUrl: string) => {
const server = previousServers.find((s) => s.address === serverUrl);
if (!server || server.accounts.length === 0) return;
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_saved_login_description"),
@@ -67,14 +181,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
text: t("common.remove"),
style: "destructive",
onPress: async () => {
await deleteServerCredential(serverUrl);
// Update UI
const updated = previousServers.map((s) =>
s.address === serverUrl
? { ...s, hasCredentials: false, username: undefined }
: s,
);
setPreviousServers(JSON.stringify(updated));
// Remove first account
await deleteAccountCredential(serverUrl, server.accounts[0].userId);
refreshServers();
},
},
],
@@ -84,11 +193,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
const handleRemoveServer = useCallback(
async (serverUrl: string) => {
await removeServerFromList(serverUrl);
// Update UI
const filtered = previousServers.filter((s) => s.address !== serverUrl);
setPreviousServers(JSON.stringify(filtered));
refreshServers();
},
[previousServers, setPreviousServers],
[setPreviousServers],
);
const renderRightActions = useCallback(
@@ -106,6 +213,39 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
[handleRemoveServer],
);
const getServerSubtitle = (server: SavedServer): string | undefined => {
const accountCount = server.accounts?.length || 0;
if (accountCount > 1) {
return t("server.accounts_count", { count: accountCount });
}
if (accountCount === 1) {
return `${server.accounts[0].username}${t("server.saved")}`;
}
return server.name ? server.address : undefined;
};
const getSecurityIcon = (
server: SavedServer,
): keyof typeof Ionicons.glyphMap | null => {
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) return null;
if (accountCount > 1) {
return "people";
}
const account = server.accounts[0];
switch (account.securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
if (!previousServers.length) return null;
return (
@@ -117,9 +257,10 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
server={s}
loadingServer={loadingServer}
onPress={() => handleServerPress(s)}
onRemoveCredential={() => handleRemoveCredential(s.address)}
onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
renderRightActions={renderRightActions}
t={t}
subtitle={getServerSubtitle(s)}
securityIcon={getSecurityIcon(s)}
/>
))}
<ListItem
@@ -133,6 +274,51 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
{t("server.swipe_to_remove")}
</Text>
{/* Account Selection Sheet */}
<AccountsSheet
open={accountsSheetOpen}
setOpen={setAccountsSheetOpen}
server={selectedServer}
onAccountSelect={(account) => {
if (selectedServer) {
handleAccountLogin(selectedServer, account);
}
}}
onAddAccount={() => {
if (selectedServer && onAddAccount) {
onAddAccount(selectedServer);
}
}}
onAccountDeleted={refreshServers}
/>
{/* PIN Entry Modal */}
<PINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<PasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
};
@@ -146,7 +332,8 @@ interface ServerItemProps {
serverUrl: string,
swipeableRef: React.RefObject<Swipeable | null>,
) => React.ReactNode;
t: (key: string) => string;
subtitle?: string;
securityIcon: keyof typeof Ionicons.glyphMap | null;
}
const ServerItem: React.FC<ServerItemProps> = ({
@@ -155,9 +342,11 @@ const ServerItem: React.FC<ServerItemProps> = ({
onPress,
onRemoveCredential,
renderRightActions,
t,
subtitle,
securityIcon,
}) => {
const swipeableRef = useRef<Swipeable>(null);
const hasAccounts = server.accounts?.length > 0;
return (
<Swipeable
@@ -170,19 +359,13 @@ const ServerItem: React.FC<ServerItemProps> = ({
<ListItem
onPress={onPress}
title={server.name || server.address}
subtitle={
server.hasCredentials
? `${server.username}${t("server.saved")}`
: server.name
? server.address
: undefined
}
subtitle={subtitle}
showArrow={loadingServer !== server.address}
disabled={loadingServer === server.address}
>
{loadingServer === server.address ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : server.hasCredentials ? (
) : hasAccounts && securityIcon ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
@@ -191,7 +374,7 @@ const ServerItem: React.FC<ServerItemProps> = ({
className='p-1'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='key' size={16} color={Colors.primary} />
<Ionicons name={securityIcon} size={16} color={Colors.primary} />
</TouchableOpacity>
) : null}
</ListItem>

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

View File

@@ -48,6 +48,7 @@
"expo-brightness": "~14.0.8",
"expo-build-properties": "~1.0.10",
"expo-constants": "18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-font": "~14.0.10",

View File

@@ -27,11 +27,14 @@ import { useSettings } from "@/utils/atoms/settings";
import { writeErrorLog, writeInfoLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import {
deleteServerCredential,
getServerCredential,
migrateServersList,
type SavedServer,
saveServerCredential,
type AccountSecurityType,
addServerToList,
deleteAccountCredential,
getAccountCredential,
hashPIN,
migrateToMultiAccount,
saveAccountCredential,
updateAccountToken,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
@@ -43,6 +46,12 @@ export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null);
export const wsAtom = atom<WebSocket | null>(null);
interface LoginOptions {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
}
interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
setServer: (server: Server) => Promise<void>;
@@ -51,11 +60,20 @@ interface JellyfinContextValue {
username: string,
password: string,
serverName?: string,
options?: LoginOptions,
) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
loginWithSavedCredential: (serverUrl: string) => Promise<void>;
removeSavedCredential: (serverUrl: string) => Promise<void>;
loginWithSavedCredential: (
serverUrl: string,
userId: string,
) => Promise<void>;
loginWithPassword: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
@@ -207,28 +225,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("serverUrl", server.address);
},
onSuccess: async (_, server) => {
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as SavedServer[];
// Check if we have saved credentials for this server
const existingServer = previousServers.find(
(s) => s.address === server.address,
);
const updatedServers: SavedServer[] = [
{
address: server.address,
name: existingServer?.name,
hasCredentials: existingServer?.hasCredentials ?? false,
username: existingServer?.username,
},
...previousServers.filter((s) => s.address !== server.address),
];
storage.set(
"previousServers",
JSON.stringify(updatedServers.slice(0, 5)),
);
// Add server to the list (will update existing or add new)
addServerToList(server.address);
},
onError: (error) => {
console.error("Failed to set server:", error);
@@ -250,10 +248,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
username,
password,
serverName,
options,
}: {
username: string;
password: string;
serverName?: string;
options?: LoginOptions;
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
@@ -266,15 +266,24 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
// Save credentials to secure storage for quick switching
if (api.basePath) {
await saveServerCredential({
// Save credentials to secure storage if requested
if (api.basePath && options?.saveAccount) {
const securityType = options.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin" && options.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: serverName || "",
token: auth.data.AccessToken,
userId: auth.data.User.Id || "",
username,
savedAt: Date.now(),
securityType,
pinHash,
});
}
@@ -347,10 +356,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
});
const loginWithSavedCredentialMutation = useMutation({
mutationFn: async (serverUrl: string) => {
mutationFn: async ({
serverUrl,
userId,
}: {
serverUrl: string;
userId: string;
}) => {
if (!jellyfin) throw new Error("Jellyfin not initialized");
const credential = await getServerCredential(serverUrl);
const credential = await getAccountCredential(serverUrl, userId);
if (!credential) {
throw new Error("No saved credential found");
}
@@ -372,21 +387,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("token", credential.token);
storage.set("user", JSON.stringify(response.data));
// Update previousServers list
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as SavedServer[];
const updatedServers: SavedServer[] = [
{
address: serverUrl,
name: credential.serverName,
hasCredentials: true,
username: credential.username,
},
...previousServers.filter((s) => s.address !== serverUrl),
].slice(0, 5);
storage.set("previousServers", JSON.stringify(updatedServers));
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
} catch (error) {
@@ -395,7 +395,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
await deleteServerCredential(serverUrl);
await deleteAccountCredential(serverUrl, userId);
throw new Error(t("server.session_expired"));
}
throw error;
@@ -406,9 +406,60 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const loginWithPasswordMutation = useMutation({
mutationFn: async ({
serverUrl,
username,
password,
}: {
serverUrl: string;
username: string;
password: string;
}) => {
if (!jellyfin) throw new Error("Jellyfin not initialized");
// Create API instance for the server
const apiInstance = jellyfin.createApi(serverUrl);
if (!apiInstance) {
throw new Error("Failed to create API instance");
}
// Authenticate with password
const auth = await apiInstance.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
storage.set("serverUrl", serverUrl);
storage.set("token", auth.data.AccessToken);
// Update the saved credential with new token
await updateAccountToken(
serverUrl,
auth.data.User.Id || "",
auth.data.AccessToken,
);
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
}
},
onError: (error) => {
console.error("Password login failed:", error);
throw error;
},
});
const removeSavedCredentialMutation = useMutation({
mutationFn: async (serverUrl: string) => {
await deleteServerCredential(serverUrl);
mutationFn: async ({
serverUrl,
userId,
}: {
serverUrl: string;
userId: string;
}) => {
await deleteAccountCredential(serverUrl, userId);
},
onError: (error) => {
console.error("Failed to remove saved credential:", error);
@@ -429,12 +480,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!jellyfin) return;
try {
// Run migration for server list format (once)
const migrated = storage.getBoolean("credentialsMigrated");
if (!migrated) {
await migrateServersList();
storage.set("credentialsMigrated", true);
}
// Run migration to multi-account format (once)
await migrateToMultiAccount();
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
@@ -452,16 +499,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(response.data);
// Migrate current session to secure storage if not already saved
const existingCredential = await getServerCredential(serverUrl);
if (!existingCredential && storedUser?.Name) {
await saveServerCredential({
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
serverName: "",
token,
userId: storedUser.Id || "",
username: storedUser.Name,
savedAt: Date.now(),
});
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
});
}
}
}
} catch (e) {
@@ -478,14 +531,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName) =>
loginMutation.mutateAsync({ username, password, serverName }),
login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
loginWithSavedCredential: (serverUrl) =>
loginWithSavedCredentialMutation.mutateAsync(serverUrl),
removeSavedCredential: (serverUrl) =>
removeSavedCredentialMutation.mutateAsync(serverUrl),
loginWithSavedCredential: (serverUrl, userId) =>
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
loginWithPassword: (serverUrl, username, password) =>
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
removeSavedCredential: (serverUrl, userId) =>
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
};
useEffect(() => {

View File

@@ -38,7 +38,40 @@
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time."
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
},
"home": {
"checking_server_connection": "Checking server connection...",
@@ -441,7 +474,11 @@
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove"
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
},
"search": {
"search": "Search...",

View File

@@ -1,8 +1,18 @@
import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import { storage } from "./mmkv";
const CREDENTIAL_KEY_PREFIX = "credential_";
const MULTI_ACCOUNT_MIGRATED_KEY = "multiAccountMigrated";
/**
* Security type for saved accounts.
*/
export type AccountSecurityType = "none" | "pin" | "password";
/**
* Credential stored in secure storage for a specific account.
*/
export interface ServerCredential {
serverUrl: string;
serverName: string;
@@ -10,50 +20,118 @@ export interface ServerCredential {
userId: string;
username: string;
savedAt: number;
securityType: AccountSecurityType;
pinHash?: string;
}
/**
* Account summary stored in SavedServer for display in UI.
*/
export interface SavedServerAccount {
userId: string;
username: string;
securityType: AccountSecurityType;
savedAt: number;
}
/**
* Server with multiple saved accounts.
*/
export interface SavedServer {
address: string;
name?: string;
accounts: SavedServerAccount[];
}
/**
* Legacy interface for migration purposes.
*/
interface LegacySavedServer {
address: string;
name?: string;
hasCredentials?: boolean;
username?: string;
}
/**
* Legacy credential interface for migration purposes.
*/
interface LegacyServerCredential {
serverUrl: string;
serverName: string;
token: string;
userId: string;
username: string;
savedAt: number;
}
export interface SavedServer {
address: string;
name?: string;
hasCredentials?: boolean;
username?: string;
}
/**
* Encode server URL to valid secure store key.
* Secure store keys must be alphanumeric with underscores.
* Encode server URL to valid secure store key (legacy, for migration).
*/
export function serverUrlToKey(serverUrl: string): string {
// Use base64 encoding, replace non-alphanumeric chars with underscores
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
}
/**
* Save credentials for a server to secure storage.
* Generate credential key for a specific account (serverUrl + userId).
*/
export async function saveServerCredential(
credential: ServerCredential,
): Promise<void> {
const key = serverUrlToKey(credential.serverUrl);
await SecureStore.setItemAsync(key, JSON.stringify(credential));
export function credentialKey(serverUrl: string, userId: string): string {
const combined = `${serverUrl}:${userId}`;
const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
}
// Update previousServers to mark this server as having credentials
updatePreviousServerCredentialFlag(
credential.serverUrl,
true,
credential.username,
credential.serverName,
/**
* Hash a PIN using SHA256.
*/
export async function hashPIN(pin: string): Promise<string> {
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
pin,
);
}
/**
* Retrieve credentials for a server from secure storage.
* Verify a PIN against stored hash.
*/
export async function getServerCredential(
export async function verifyAccountPIN(
serverUrl: string,
userId: string,
pin: string,
): Promise<boolean> {
const credential = await getAccountCredential(serverUrl, userId);
if (!credential?.pinHash) return false;
const inputHash = await hashPIN(pin);
return inputHash === credential.pinHash;
}
/**
* Save credential for a specific account.
*/
export async function saveAccountCredential(
credential: ServerCredential,
): Promise<void> {
const key = credentialKey(credential.serverUrl, credential.userId);
await SecureStore.setItemAsync(key, JSON.stringify(credential));
// Update previousServers to include this account
addAccountToServer(credential.serverUrl, credential.serverName, {
userId: credential.userId,
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
});
}
/**
* Retrieve credential for a specific account.
*/
export async function getAccountCredential(
serverUrl: string,
userId: string,
): Promise<ServerCredential | null> {
const key = serverUrlToKey(serverUrl);
const key = credentialKey(serverUrl, userId);
const stored = await SecureStore.getItemAsync(key);
if (stored) {
@@ -67,57 +145,138 @@ export async function getServerCredential(
}
/**
* Delete credentials for a server from secure storage.
* Delete credential for a specific account.
*/
export async function deleteServerCredential(serverUrl: string): Promise<void> {
const key = serverUrlToKey(serverUrl);
export async function deleteAccountCredential(
serverUrl: string,
userId: string,
): Promise<void> {
const key = credentialKey(serverUrl, userId);
await SecureStore.deleteItemAsync(key);
// Update previousServers to mark this server as not having credentials
updatePreviousServerCredentialFlag(serverUrl, false);
// Remove account from previousServers
removeAccountFromServer(serverUrl, userId);
}
/**
* Check if credentials exist for a server (without retrieving them).
* Get all credentials for a server (by iterating through accounts).
*/
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
const key = serverUrlToKey(serverUrl);
export async function getServerAccounts(
serverUrl: string,
): Promise<ServerCredential[]> {
const servers = getPreviousServers();
const server = servers.find((s) => s.address === serverUrl);
if (!server) return [];
const credentials: ServerCredential[] = [];
for (const account of server.accounts) {
const credential = await getAccountCredential(serverUrl, account.userId);
if (credential) {
credentials.push(credential);
}
}
return credentials;
}
/**
* Check if credentials exist for a specific account.
*/
export async function hasAccountCredential(
serverUrl: string,
userId: string,
): Promise<boolean> {
const key = credentialKey(serverUrl, userId);
const stored = await SecureStore.getItemAsync(key);
return stored !== null;
}
/**
* Delete all stored credentials for all servers.
* Delete all credentials for all accounts on all servers.
*/
export async function clearAllCredentials(): Promise<void> {
const previousServers = getPreviousServers();
for (const server of previousServers) {
await deleteServerCredential(server.address);
for (const account of server.accounts) {
const key = credentialKey(server.address, account.userId);
await SecureStore.deleteItemAsync(key);
}
}
// Clear all accounts from servers
const clearedServers = previousServers.map((server) => ({
...server,
accounts: [],
}));
storage.set("previousServers", JSON.stringify(clearedServers));
}
/**
* Helper to update the previousServers list in MMKV with credential status.
* Add or update an account in a server's accounts list.
*/
function updatePreviousServerCredentialFlag(
function addAccountToServer(
serverUrl: string,
hasCredentials: boolean,
username?: string,
serverName?: string,
serverName: string,
account: SavedServerAccount,
): void {
const previousServers = getPreviousServers();
let serverFound = false;
const updatedServers = previousServers.map((server) => {
if (server.address === serverUrl) {
serverFound = true;
// Check if account already exists
const existingIndex = server.accounts.findIndex(
(a) => a.userId === account.userId,
);
if (existingIndex >= 0) {
// Update existing account
const updatedAccounts = [...server.accounts];
updatedAccounts[existingIndex] = account;
return {
...server,
name: serverName || server.name,
accounts: updatedAccounts,
};
}
// Add new account
return {
...server,
hasCredentials,
username: username || server.username,
name: serverName || server.name,
accounts: [...server.accounts, account],
};
}
return server;
});
// If server not found, add it
if (!serverFound) {
updatedServers.push({
address: serverUrl,
name: serverName,
accounts: [account],
});
}
storage.set("previousServers", JSON.stringify(updatedServers));
}
/**
* Remove an account from a server's accounts list.
*/
function removeAccountFromServer(serverUrl: string, userId: string): void {
const previousServers = getPreviousServers();
const updatedServers = previousServers.map((server) => {
if (server.address === serverUrl) {
return {
...server,
accounts: server.accounts.filter((a) => a.userId !== userId),
};
}
return server;
});
storage.set("previousServers", JSON.stringify(updatedServers));
}
@@ -137,46 +296,217 @@ export function getPreviousServers(): SavedServer[] {
}
/**
* Remove a server from the previous servers list and delete its credentials.
* Remove a server from the list and delete all its account credentials.
*/
export async function removeServerFromList(serverUrl: string): Promise<void> {
// First delete any saved credentials
await deleteServerCredential(serverUrl);
// Then remove from the list
const previousServers = getPreviousServers();
const filtered = previousServers.filter((s) => s.address !== serverUrl);
const servers = getPreviousServers();
const server = servers.find((s) => s.address === serverUrl);
// Delete all account credentials for this server
if (server) {
for (const account of server.accounts) {
const key = credentialKey(serverUrl, account.userId);
await SecureStore.deleteItemAsync(key);
}
}
// Remove server from list
const filtered = servers.filter((s) => s.address !== serverUrl);
storage.set("previousServers", JSON.stringify(filtered));
}
/**
* Migrate existing previousServers to new format (add hasCredentials: false).
* Add a server to the list without credentials (for server discovery).
*/
export function addServerToList(serverUrl: string, serverName?: string): void {
const servers = getPreviousServers();
const existing = servers.find((s) => s.address === serverUrl);
if (existing) {
// Update name if provided
if (serverName) {
const updated = servers.map((s) =>
s.address === serverUrl ? { ...s, name: serverName } : s,
);
storage.set("previousServers", JSON.stringify(updated));
}
return;
}
// Add new server with empty accounts
const newServer: SavedServer = {
address: serverUrl,
name: serverName,
accounts: [],
};
// Keep max 10 servers
const updatedServers = [newServer, ...servers].slice(0, 10);
storage.set("previousServers", JSON.stringify(updatedServers));
}
/**
* Migrate from legacy single-account format to multi-account format.
* Should be called on app startup.
*/
export async function migrateServersList(): Promise<void> {
export async function migrateToMultiAccount(): Promise<void> {
// Check if already migrated
if (storage.getBoolean(MULTI_ACCOUNT_MIGRATED_KEY)) {
return;
}
const stored = storage.getString("previousServers");
if (!stored) return;
if (!stored) {
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
return;
}
try {
const servers = JSON.parse(stored);
// Check if migration needed (old format doesn't have hasCredentials)
if (servers.length > 0 && servers[0].hasCredentials === undefined) {
const migrated = servers.map((server: SavedServer) => ({
address: server.address,
name: server.name,
hasCredentials: false,
username: undefined,
}));
storage.set("previousServers", JSON.stringify(migrated));
// Check if already in new format (has accounts array)
if (servers.length > 0 && Array.isArray(servers[0].accounts)) {
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
return;
}
// Migrate from legacy format
const migratedServers: SavedServer[] = [];
for (const legacyServer of servers as LegacySavedServer[]) {
const newServer: SavedServer = {
address: legacyServer.address,
name: legacyServer.name,
accounts: [],
};
// Try to get existing credential using legacy key
if (legacyServer.hasCredentials) {
const legacyKey = serverUrlToKey(legacyServer.address);
const storedCred = await SecureStore.getItemAsync(legacyKey);
if (storedCred) {
try {
const legacyCred = JSON.parse(storedCred) as LegacyServerCredential;
// Create new credential with security type
const newCredential: ServerCredential = {
...legacyCred,
securityType: "none", // Existing accounts get no protection (preserve quick-login)
};
// Save with new key format
const newKey = credentialKey(
legacyServer.address,
legacyCred.userId,
);
await SecureStore.setItemAsync(
newKey,
JSON.stringify(newCredential),
);
// Delete old key
await SecureStore.deleteItemAsync(legacyKey);
// Add account to server
newServer.accounts.push({
userId: legacyCred.userId,
username: legacyCred.username,
securityType: "none",
savedAt: legacyCred.savedAt,
});
} catch {
// Skip invalid credentials
}
}
}
migratedServers.push(newServer);
}
storage.set("previousServers", JSON.stringify(migratedServers));
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
} catch {
// If parsing fails, reset to empty array
storage.set("previousServers", "[]");
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
}
}
/**
* Migrate current session credentials to secure storage.
* Should be called on app startup for existing users.
* Update account's token after successful login.
*/
export async function updateAccountToken(
serverUrl: string,
userId: string,
newToken: string,
): Promise<void> {
const credential = await getAccountCredential(serverUrl, userId);
if (credential) {
credential.token = newToken;
credential.savedAt = Date.now();
const key = credentialKey(serverUrl, userId);
await SecureStore.setItemAsync(key, JSON.stringify(credential));
}
}
// Legacy functions for backward compatibility during transition
// These delegate to new functions
/**
* @deprecated Use saveAccountCredential instead
*/
export async function saveServerCredential(
credential: ServerCredential,
): Promise<void> {
return saveAccountCredential(credential);
}
/**
* @deprecated Use getAccountCredential instead
*/
export async function getServerCredential(
serverUrl: string,
): Promise<ServerCredential | null> {
// Try to get first account's credential for backward compatibility
const servers = getPreviousServers();
const server = servers.find((s) => s.address === serverUrl);
if (server && server.accounts.length > 0) {
return getAccountCredential(serverUrl, server.accounts[0].userId);
}
return null;
}
/**
* @deprecated Use deleteAccountCredential instead
*/
export async function deleteServerCredential(serverUrl: string): Promise<void> {
// Delete first account for backward compatibility
const servers = getPreviousServers();
const server = servers.find((s) => s.address === serverUrl);
if (server && server.accounts.length > 0) {
return deleteAccountCredential(serverUrl, server.accounts[0].userId);
}
}
/**
* @deprecated Use hasAccountCredential instead
*/
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
const servers = getPreviousServers();
const server = servers.find((s) => s.address === serverUrl);
return server ? server.accounts.length > 0 : false;
}
/**
* @deprecated Use migrateToMultiAccount instead
*/
export async function migrateServersList(): Promise<void> {
return migrateToMultiAccount();
}
/**
* @deprecated Use saveAccountCredential instead
*/
export async function migrateCurrentSessionToSecureStorage(
serverUrl: string,
@@ -185,17 +515,18 @@ export async function migrateCurrentSessionToSecureStorage(
username: string,
serverName?: string,
): Promise<void> {
const existingCredential = await getServerCredential(serverUrl);
const existingCredential = await getAccountCredential(serverUrl, userId);
// Only save if not already saved
if (!existingCredential) {
await saveServerCredential({
await saveAccountCredential({
serverUrl,
serverName: serverName || "",
token,
userId,
username,
savedAt: Date.now(),
securityType: "none",
});
}
}