mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
Better nav primitives
This commit is contained in:
21
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
21
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="index">
|
||||||
|
<Label>Host</Label>
|
||||||
|
<Icon sf="house.fill" drawable="custom_android_drawable" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="shell">
|
||||||
|
<Icon sf="gear" drawable="custom_settings_drawable" />
|
||||||
|
<Label>Shell</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="settings">
|
||||||
|
<Icon sf="gear" drawable="custom_settings_drawable" />
|
||||||
|
<Label>Settings</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/mobile/src/app/(tabs)/index.tsx
Normal file
6
apps/mobile/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Host from '../host';
|
||||||
|
|
||||||
|
export default function TabsIndex() {
|
||||||
|
return <Host />;
|
||||||
|
}
|
||||||
5
apps/mobile/src/app/(tabs)/settings/_layout.tsx
Normal file
5
apps/mobile/src/app/(tabs)/settings/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function SettingsStackLayout() {
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
1
apps/mobile/src/app/(tabs)/settings/index.tsx
Normal file
1
apps/mobile/src/app/(tabs)/settings/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from '../../settings';
|
||||||
13
apps/mobile/src/app/(tabs)/settings/key-manager.tsx
Normal file
13
apps/mobile/src/app/(tabs)/settings/key-manager.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { KeyList } from '@/components/key-manager/KeyList';
|
||||||
|
|
||||||
|
export default function SettingsKeyManager() {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0B1324' }}>
|
||||||
|
<Stack.Screen options={{ title: 'Manage Keys' }} />
|
||||||
|
<KeyList mode="manage" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ShellDetail from '../../../shell/[connectionId]/[channelId]';
|
||||||
|
|
||||||
|
export default function TabsShellDetail() {
|
||||||
|
return <ShellDetail />;
|
||||||
|
}
|
||||||
6
apps/mobile/src/app/(tabs)/shell/_layout.tsx
Normal file
6
apps/mobile/src/app/(tabs)/shell/_layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ShellStackLayout from '../../shell/_layout';
|
||||||
|
|
||||||
|
export default function TabsShellStack() {
|
||||||
|
return <ShellStackLayout />;
|
||||||
|
}
|
||||||
6
apps/mobile/src/app/(tabs)/shell/index.tsx
Normal file
6
apps/mobile/src/app/(tabs)/shell/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ShellList from '../../shell/index';
|
||||||
|
|
||||||
|
export default function TabsShellList() {
|
||||||
|
return <ShellList />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
import { Stack } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { queryClient } from '../lib/utils';
|
import { queryClient } from '../lib/utils';
|
||||||
import { ThemeProvider } from '../theme';
|
import { ThemeProvider } from '../theme';
|
||||||
@@ -9,26 +9,11 @@ console.log('Fressh App Init', {
|
|||||||
isLiquidGlassAvailable: isLiquidGlassAvailable(),
|
isLiquidGlassAvailable: isLiquidGlassAvailable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://docs.expo.dev/versions/latest/sdk/navigation-bar/
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<NativeTabs>
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
<NativeTabs.Trigger name="index">
|
|
||||||
<Label>Host</Label>
|
|
||||||
<Icon sf="house.fill" drawable="custom_android_drawable" />
|
|
||||||
</NativeTabs.Trigger>
|
|
||||||
<NativeTabs.Trigger name="shell">
|
|
||||||
<Icon sf="gear" drawable="custom_settings_drawable" />
|
|
||||||
<Label>Shell</Label>
|
|
||||||
</NativeTabs.Trigger>
|
|
||||||
<NativeTabs.Trigger name="settings">
|
|
||||||
<Icon sf="gear" drawable="custom_settings_drawable" />
|
|
||||||
<Label>Settings</Label>
|
|
||||||
</NativeTabs.Trigger>
|
|
||||||
</NativeTabs>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
572
apps/mobile/src/app/host.tsx
Normal file
572
apps/mobile/src/app/host.tsx
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||||
|
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
||||||
|
import { useStore } from '@tanstack/react-form';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from 'react-native-safe-area-context';
|
||||||
|
import { AbortSignalTimeout } from '@/lib/utils';
|
||||||
|
import { useAppForm, useFieldContext } from '../components/form-components';
|
||||||
|
import {
|
||||||
|
type ConnectionDetails,
|
||||||
|
connectionDetailsSchema,
|
||||||
|
secretsManager,
|
||||||
|
} from '../lib/secrets-manager';
|
||||||
|
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { KeyList } from '@/components/key-manager/KeyList';
|
||||||
|
|
||||||
|
const defaultValues: ConnectionDetails = {
|
||||||
|
host: 'test.rebex.net',
|
||||||
|
port: 22,
|
||||||
|
username: 'demo',
|
||||||
|
security: {
|
||||||
|
type: 'password',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSshConnMutation = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (connectionDetails: ConnectionDetails) => {
|
||||||
|
try {
|
||||||
|
console.log('Connecting to SSH server...');
|
||||||
|
const sshConnection = await RnRussh.connect({
|
||||||
|
host: connectionDetails.host,
|
||||||
|
port: connectionDetails.port,
|
||||||
|
username: connectionDetails.username,
|
||||||
|
security:
|
||||||
|
connectionDetails.security.type === 'password'
|
||||||
|
? {
|
||||||
|
type: 'password',
|
||||||
|
password: connectionDetails.security.password,
|
||||||
|
}
|
||||||
|
: { type: 'key', privateKey: 'TODO' },
|
||||||
|
onStatusChange: (status) => {
|
||||||
|
console.log('SSH connection status', status);
|
||||||
|
},
|
||||||
|
abortSignal: AbortSignalTimeout(5_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await secretsManager.connections.utils.upsertConnection({
|
||||||
|
id: 'default',
|
||||||
|
details: connectionDetails,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
const shellInterface = await sshConnection.startShell({
|
||||||
|
pty: 'Xterm',
|
||||||
|
onStatusChange: (status) => {
|
||||||
|
console.log('SSH shell status', status);
|
||||||
|
},
|
||||||
|
abortSignal: AbortSignalTimeout(5_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelId = shellInterface.channelId as number;
|
||||||
|
const connectionId =
|
||||||
|
sshConnection.connectionId ??
|
||||||
|
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||||
|
console.log('Connected to SSH server', connectionId, channelId);
|
||||||
|
router.push({
|
||||||
|
pathname: '/shell/[connectionId]/[channelId]',
|
||||||
|
params: {
|
||||||
|
connectionId: connectionId,
|
||||||
|
channelId: String(channelId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to SSH server', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Host() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const sshConnMutation = useSshConnMutation();
|
||||||
|
const connectionForm = useAppForm({
|
||||||
|
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
||||||
|
defaultValues,
|
||||||
|
validators: {
|
||||||
|
onChange: connectionDetailsSchema,
|
||||||
|
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const securityType = useStore(
|
||||||
|
connectionForm.store,
|
||||||
|
(state) => state.values.security.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSubmitting = useStore(
|
||||||
|
connectionForm.store,
|
||||||
|
(state) => state.isSubmitting,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: Math.max(32, insets.bottom + 24) },
|
||||||
|
]}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
style={{ backgroundColor: theme.colors.background }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: theme.colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.appName}>fressh</Text>
|
||||||
|
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Connect to SSH Server</Text>
|
||||||
|
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
||||||
|
|
||||||
|
<connectionForm.AppForm>
|
||||||
|
<connectionForm.AppField name="host">
|
||||||
|
{(field) => (
|
||||||
|
<field.TextField
|
||||||
|
label="Host"
|
||||||
|
testID="host"
|
||||||
|
placeholder="example.com or 192.168.0.10"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
<connectionForm.AppField name="port">
|
||||||
|
{(field) => (
|
||||||
|
<field.NumberField
|
||||||
|
label="Port"
|
||||||
|
placeholder="22"
|
||||||
|
testID="port"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
<connectionForm.AppField name="username">
|
||||||
|
{(field) => (
|
||||||
|
<field.TextField
|
||||||
|
label="Username"
|
||||||
|
testID="username"
|
||||||
|
placeholder="root"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
<connectionForm.AppField name="security.type">
|
||||||
|
{(field) => (
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<SegmentedControl
|
||||||
|
values={['Password', 'Private Key']}
|
||||||
|
selectedIndex={field.state.value === 'password' ? 0 : 1}
|
||||||
|
onChange={(event) => {
|
||||||
|
field.handleChange(
|
||||||
|
event.nativeEvent.selectedSegmentIndex === 0
|
||||||
|
? 'password'
|
||||||
|
: 'key',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
{securityType === 'password' ? (
|
||||||
|
<connectionForm.AppField name="security.password">
|
||||||
|
{(field) => (
|
||||||
|
<field.TextField
|
||||||
|
label="Password"
|
||||||
|
testID="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
) : (
|
||||||
|
<connectionForm.AppField name="security.keyId">
|
||||||
|
{() => <KeyIdPickerField />}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<connectionForm.SubmitButton
|
||||||
|
title="Connect"
|
||||||
|
testID="connect"
|
||||||
|
onPress={() => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
void connectionForm.handleSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</connectionForm.AppForm>
|
||||||
|
</View>
|
||||||
|
<PreviousConnectionsSection
|
||||||
|
onSelect={(connection) => {
|
||||||
|
connectionForm.setFieldValue('host', connection.host);
|
||||||
|
connectionForm.setFieldValue('port', connection.port);
|
||||||
|
connectionForm.setFieldValue('username', connection.username);
|
||||||
|
connectionForm.setFieldValue(
|
||||||
|
'security.type',
|
||||||
|
connection.security.type,
|
||||||
|
);
|
||||||
|
if (connection.security.type === 'password') {
|
||||||
|
connectionForm.setFieldValue(
|
||||||
|
'security.password',
|
||||||
|
connection.security.password,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
connectionForm.setFieldValue(
|
||||||
|
'security.keyId',
|
||||||
|
connection.security.keyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyIdPickerField() {
|
||||||
|
const field = useFieldContext<string>();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||||
|
const defaultPick = React.useMemo(() => {
|
||||||
|
const keys = listPrivateKeysQuery.data ?? [];
|
||||||
|
const def = keys.find((k) => k.metadata?.isDefault);
|
||||||
|
return def ?? keys[0];
|
||||||
|
}, [listPrivateKeysQuery.data]);
|
||||||
|
const keys = listPrivateKeysQuery.data ?? [];
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!field.state.value && defaultPick?.id) {
|
||||||
|
field.handleChange(defaultPick.id);
|
||||||
|
}
|
||||||
|
}, [field.state.value, defaultPick?.id]);
|
||||||
|
|
||||||
|
const computedSelectedId = field.state.value ?? defaultPick?.id;
|
||||||
|
const selected = keys.find((k) => k.id === computedSelectedId);
|
||||||
|
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Private Key</Text>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.input, { justifyContent: 'center' }]}
|
||||||
|
onPress={() => {
|
||||||
|
void listPrivateKeysQuery.refetch();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#E5E7EB' }}>{display}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{!selected && (
|
||||||
|
<Text style={styles.mutedText}>
|
||||||
|
Open Key Manager to add/select a key
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.title}>Select Key</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalCloseButton}
|
||||||
|
onPress={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalCloseText}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<KeyList
|
||||||
|
mode="select"
|
||||||
|
onSelect={async (id) => {
|
||||||
|
field.handleChange(id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviousConnectionsSection(props: {
|
||||||
|
onSelect: (connection: ConnectionDetails) => void;
|
||||||
|
}) {
|
||||||
|
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.listSection}>
|
||||||
|
<Text style={styles.listTitle}>Previous Connections</Text>
|
||||||
|
{listConnectionsQuery.isLoading ? (
|
||||||
|
<Text style={styles.mutedText}>Loading connections...</Text>
|
||||||
|
) : listConnectionsQuery.isError ? (
|
||||||
|
<Text style={styles.errorText}>Error loading connections</Text>
|
||||||
|
) : listConnectionsQuery.data?.length ? (
|
||||||
|
<View style={styles.listContainer}>
|
||||||
|
{listConnectionsQuery.data?.map((conn) => (
|
||||||
|
<ConnectionRow
|
||||||
|
key={conn.id}
|
||||||
|
id={conn.id}
|
||||||
|
onSelect={props.onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.mutedText}>No saved connections yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionRow(props: {
|
||||||
|
id: string;
|
||||||
|
onSelect: (connection: ConnectionDetails) => void;
|
||||||
|
}) {
|
||||||
|
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
|
||||||
|
const details = detailsQuery.data?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => {
|
||||||
|
if (details) props.onSelect(details);
|
||||||
|
}}
|
||||||
|
disabled={!details}
|
||||||
|
>
|
||||||
|
<View style={styles.rowTextContainer}>
|
||||||
|
<Text style={styles.rowTitle}>
|
||||||
|
{details ? `${details.username}@${details.host}` : 'Loading...'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowSubtitle}>
|
||||||
|
{details ? `Port ${details.port} • ${details.security.type}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowChevron}>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: '#0B1324',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
appName: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
appTagline: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9AA0A6',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#111B34',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 24,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 16,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E293B',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
marginBottom: 6,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#9AA0A6',
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#C6CBD3',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
backgroundColor: '#0E172B',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: 6,
|
||||||
|
color: '#FCA5A5',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
color: '#9AA0A6',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#2563EB',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#C6CBD3',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
listSection: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
listTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
// Intentionally empty for RN compatibility
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#0E172B',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
rowTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
rowTitle: {
|
||||||
|
color: '#E5E7EB',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
rowSubtitle: {
|
||||||
|
color: '#9AA0A6',
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
rowChevron: {
|
||||||
|
color: '#9AA0A6',
|
||||||
|
fontSize: 22,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
backgroundColor: '#0B1324',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderColor: '#1E293B',
|
||||||
|
borderWidth: 1,
|
||||||
|
maxHeight: '85%',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
modalCloseButton: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
},
|
||||||
|
modalCloseText: {
|
||||||
|
color: '#C6CBD3',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,516 +1,5 @@
|
|||||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
import { Redirect } from 'expo-router';
|
||||||
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
|
||||||
import { useStore } from '@tanstack/react-form';
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import {
|
|
||||||
SafeAreaView,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from 'react-native-safe-area-context';
|
|
||||||
import { AbortSignalTimeout } from '@/lib/utils';
|
|
||||||
import { useAppForm, useFieldContext } from '../components/form-components';
|
|
||||||
import { KeyManagerModal } from '../components/key-manager-modal';
|
|
||||||
import {
|
|
||||||
type ConnectionDetails,
|
|
||||||
connectionDetailsSchema,
|
|
||||||
secretsManager,
|
|
||||||
} from '../lib/secrets-manager';
|
|
||||||
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
|
|
||||||
import { useTheme } from '../theme';
|
|
||||||
|
|
||||||
const defaultValues: ConnectionDetails = {
|
export default function RootRedirect() {
|
||||||
host: 'test.rebex.net',
|
return <Redirect href="/(tabs)" />;
|
||||||
port: 22,
|
|
||||||
username: 'demo',
|
|
||||||
security: {
|
|
||||||
type: 'password',
|
|
||||||
password: 'password',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const useSshConnMutation = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (connectionDetails: ConnectionDetails) => {
|
|
||||||
try {
|
|
||||||
console.log('Connecting to SSH server...');
|
|
||||||
const sshConnection = await RnRussh.connect({
|
|
||||||
host: connectionDetails.host,
|
|
||||||
port: connectionDetails.port,
|
|
||||||
username: connectionDetails.username,
|
|
||||||
security:
|
|
||||||
connectionDetails.security.type === 'password'
|
|
||||||
? {
|
|
||||||
type: 'password',
|
|
||||||
password: connectionDetails.security.password,
|
|
||||||
}
|
|
||||||
: { type: 'key', privateKey: 'TODO' },
|
|
||||||
onStatusChange: (status) => {
|
|
||||||
console.log('SSH connection status', status);
|
|
||||||
},
|
|
||||||
abortSignal: AbortSignalTimeout(5_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
await secretsManager.connections.utils.upsertConnection({
|
|
||||||
id: 'default',
|
|
||||||
details: connectionDetails,
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
const shellInterface = await sshConnection.startShell({
|
|
||||||
pty: 'Xterm',
|
|
||||||
onStatusChange: (status) => {
|
|
||||||
console.log('SSH shell status', status);
|
|
||||||
},
|
|
||||||
abortSignal: AbortSignalTimeout(5_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
const channelId = shellInterface.channelId as number;
|
|
||||||
const connectionId =
|
|
||||||
sshConnection.connectionId ??
|
|
||||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
|
||||||
console.log('Connected to SSH server', connectionId, channelId);
|
|
||||||
router.push({
|
|
||||||
pathname:
|
|
||||||
'/shell/' +
|
|
||||||
encodeURIComponent(connectionId) +
|
|
||||||
'/' +
|
|
||||||
String(channelId),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting to SSH server', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Index() {
|
|
||||||
const theme = useTheme();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const sshConnMutation = useSshConnMutation();
|
|
||||||
const connectionForm = useAppForm({
|
|
||||||
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
|
||||||
defaultValues,
|
|
||||||
validators: {
|
|
||||||
onChange: connectionDetailsSchema,
|
|
||||||
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const securityType = useStore(
|
|
||||||
connectionForm.store,
|
|
||||||
(state) => state.values.security.type,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSubmitting = useStore(
|
|
||||||
connectionForm.store,
|
|
||||||
(state) => state.isSubmitting,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.scrollContent,
|
|
||||||
{ paddingBottom: Math.max(32, insets.bottom + 24) },
|
|
||||||
]}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
style={{ backgroundColor: theme.colors.background }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{ backgroundColor: theme.colors.background },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.appName}>fressh</Text>
|
|
||||||
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.title}>Connect to SSH Server</Text>
|
|
||||||
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
|
||||||
|
|
||||||
<connectionForm.AppForm>
|
|
||||||
<connectionForm.AppField name="host">
|
|
||||||
{(field) => (
|
|
||||||
<field.TextField
|
|
||||||
label="Host"
|
|
||||||
testID="host"
|
|
||||||
placeholder="example.com or 192.168.0.10"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
<connectionForm.AppField name="port">
|
|
||||||
{(field) => (
|
|
||||||
<field.NumberField
|
|
||||||
label="Port"
|
|
||||||
placeholder="22"
|
|
||||||
testID="port"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
<connectionForm.AppField name="username">
|
|
||||||
{(field) => (
|
|
||||||
<field.TextField
|
|
||||||
label="Username"
|
|
||||||
testID="username"
|
|
||||||
placeholder="root"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
<connectionForm.AppField name="security.type">
|
|
||||||
{(field) => (
|
|
||||||
<View style={styles.inputGroup}>
|
|
||||||
<SegmentedControl
|
|
||||||
values={['Password', 'Private Key']}
|
|
||||||
selectedIndex={field.state.value === 'password' ? 0 : 1}
|
|
||||||
onChange={(event) => {
|
|
||||||
field.handleChange(
|
|
||||||
event.nativeEvent.selectedSegmentIndex === 0
|
|
||||||
? 'password'
|
|
||||||
: 'key',
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
{securityType === 'password' ? (
|
|
||||||
<connectionForm.AppField name="security.password">
|
|
||||||
{(field) => (
|
|
||||||
<field.TextField
|
|
||||||
label="Password"
|
|
||||||
testID="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
secureTextEntry
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
) : (
|
|
||||||
<connectionForm.AppField name="security.keyId">
|
|
||||||
{() => <KeyIdPicker />}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<connectionForm.SubmitButton
|
|
||||||
title="Connect"
|
|
||||||
testID="connect"
|
|
||||||
onPress={() => {
|
|
||||||
if (isSubmitting) return;
|
|
||||||
void connectionForm.handleSubmit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</connectionForm.AppForm>
|
|
||||||
</View>
|
|
||||||
<PreviousConnectionsSection
|
|
||||||
onSelect={(connection) => {
|
|
||||||
connectionForm.setFieldValue('host', connection.host);
|
|
||||||
connectionForm.setFieldValue('port', connection.port);
|
|
||||||
connectionForm.setFieldValue('username', connection.username);
|
|
||||||
connectionForm.setFieldValue(
|
|
||||||
'security.type',
|
|
||||||
connection.security.type,
|
|
||||||
);
|
|
||||||
if (connection.security.type === 'password') {
|
|
||||||
connectionForm.setFieldValue(
|
|
||||||
'security.password',
|
|
||||||
connection.security.password,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
connectionForm.setFieldValue(
|
|
||||||
'security.keyId',
|
|
||||||
connection.security.keyId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyIdPicker() {
|
|
||||||
const field = useFieldContext<string>();
|
|
||||||
const hasInteractedRef = React.useRef(false);
|
|
||||||
const [manualVisible, setManualVisible] = React.useState(false);
|
|
||||||
|
|
||||||
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
|
||||||
const defaultPick = React.useMemo(() => {
|
|
||||||
const keys = listPrivateKeysQuery.data ?? [];
|
|
||||||
const def = keys.find((k) => k.metadata?.isDefault);
|
|
||||||
return def ?? keys[0];
|
|
||||||
}, [listPrivateKeysQuery.data]);
|
|
||||||
const keys = listPrivateKeysQuery.data ?? [];
|
|
||||||
|
|
||||||
const computedSelectedId = field.state.value ?? defaultPick?.id;
|
|
||||||
const selected = keys.find((k) => k.id === computedSelectedId);
|
|
||||||
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
|
|
||||||
|
|
||||||
const isEmpty = (listPrivateKeysQuery.data?.length ?? 0) === 0;
|
|
||||||
const visible = manualVisible || (!hasInteractedRef.current && isEmpty);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View style={styles.inputGroup}>
|
|
||||||
<Text style={styles.label}>Private Key</Text>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.input, { justifyContent: 'center' }]}
|
|
||||||
onPress={() => {
|
|
||||||
hasInteractedRef.current = true;
|
|
||||||
setManualVisible(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#E5E7EB' }}>{display}</Text>
|
|
||||||
</Pressable>
|
|
||||||
{!selected && (
|
|
||||||
<Text style={styles.mutedText}>
|
|
||||||
Open Key Manager to add/select a key
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<KeyManagerModal
|
|
||||||
visible={visible}
|
|
||||||
selectedKeyId={computedSelectedId}
|
|
||||||
onSelect={(id) => {
|
|
||||||
hasInteractedRef.current = true;
|
|
||||||
field.handleChange(id);
|
|
||||||
}}
|
|
||||||
onClose={() => {
|
|
||||||
hasInteractedRef.current = true;
|
|
||||||
setManualVisible(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviousConnectionsSection(props: {
|
|
||||||
onSelect: (connection: ConnectionDetails) => void;
|
|
||||||
}) {
|
|
||||||
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.listSection}>
|
|
||||||
<Text style={styles.listTitle}>Previous Connections</Text>
|
|
||||||
{listConnectionsQuery.isLoading ? (
|
|
||||||
<Text style={styles.mutedText}>Loading connections...</Text>
|
|
||||||
) : listConnectionsQuery.isError ? (
|
|
||||||
<Text style={styles.errorText}>Error loading connections</Text>
|
|
||||||
) : listConnectionsQuery.data?.length ? (
|
|
||||||
<View style={styles.listContainer}>
|
|
||||||
{listConnectionsQuery.data?.map((conn) => (
|
|
||||||
<ConnectionRow
|
|
||||||
key={conn.id}
|
|
||||||
id={conn.id}
|
|
||||||
onSelect={props.onSelect}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.mutedText}>No saved connections yet</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionRow(props: {
|
|
||||||
id: string;
|
|
||||||
onSelect: (connection: ConnectionDetails) => void;
|
|
||||||
}) {
|
|
||||||
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
|
|
||||||
const details = detailsQuery.data?.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={styles.row}
|
|
||||||
onPress={() => {
|
|
||||||
if (details) props.onSelect(details);
|
|
||||||
}}
|
|
||||||
disabled={!details}
|
|
||||||
>
|
|
||||||
<View style={styles.rowTextContainer}>
|
|
||||||
<Text style={styles.rowTitle}>
|
|
||||||
{details ? `${details.username}@${details.host}` : 'Loading...'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.rowSubtitle}>
|
|
||||||
{details ? `Port ${details.port} • ${details.security.type}` : ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.rowChevron}>›</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: '#0B1324',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingBottom: 32,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
appName: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
appTagline: {
|
|
||||||
marginTop: 4,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#9AA0A6',
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#111B34',
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 24,
|
|
||||||
marginHorizontal: 4,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 16,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
elevation: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#1E293B',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
marginBottom: 6,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#9AA0A6',
|
|
||||||
marginBottom: 24,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
inputGroup: {
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
marginTop: 6,
|
|
||||||
color: '#FCA5A5',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
mutedText: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
shadowColor: '#2563EB',
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 16,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
|
||||||
backgroundColor: '#3B82F6',
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 12,
|
|
||||||
},
|
|
||||||
secondaryButtonText: {
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
listSection: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
listTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
listContainer: {
|
|
||||||
// Intentionally empty for RN compatibility
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
rowTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
rowTitle: {
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
rowSubtitle: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
marginTop: 2,
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
rowChevron: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
fontSize: 22,
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,14 +6,11 @@ export default function Tab() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
|
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
|
||||||
<Link
|
<Link
|
||||||
href="/(shared)/key-manager"
|
href="/(tabs)/settings/key-manager"
|
||||||
style={{ color: '#60A5FA', marginBottom: 8 }}
|
style={{ color: '#60A5FA', marginBottom: 8 }}
|
||||||
>
|
>
|
||||||
Manage Keys
|
Manage Keys
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/(modals)/key-manager?select=1" style={{ color: '#60A5FA' }}>
|
|
||||||
Open Key Picker (modal)
|
|
||||||
</Link>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native';
|
|
||||||
import { secretsManager } from '../lib/secrets-manager';
|
|
||||||
|
|
||||||
export function KeyManagerModal(props: {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
selectedKeyId?: string;
|
|
||||||
onSelect?: (keyId: string) => void;
|
|
||||||
}) {
|
|
||||||
const listKeysQuery = useQuery(secretsManager.keys.query.list);
|
|
||||||
|
|
||||||
const generateMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const id = `key_${Date.now()}`;
|
|
||||||
const pair = await secretsManager.keys.utils.generateKeyPair({
|
|
||||||
type: 'rsa',
|
|
||||||
keySize: 4096,
|
|
||||||
});
|
|
||||||
await secretsManager.keys.utils.upsertPrivateKey({
|
|
||||||
keyId: id,
|
|
||||||
metadata: { priority: 0, label: 'New Key', isDefault: false },
|
|
||||||
value: pair,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleDelete(keyId: string) {
|
|
||||||
await secretsManager.keys.utils.deletePrivateKey(keyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetDefault(keyId: string) {
|
|
||||||
const entries = await secretsManager.keys.utils.listEntriesWithValues();
|
|
||||||
await Promise.all(
|
|
||||||
entries.map((e) =>
|
|
||||||
secretsManager.keys.utils.upsertPrivateKey({
|
|
||||||
keyId: e.id,
|
|
||||||
value: e.value,
|
|
||||||
metadata: {
|
|
||||||
priority: e.metadata.priority,
|
|
||||||
label: e.metadata.label,
|
|
||||||
isDefault: e.id === keyId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerate() {
|
|
||||||
await generateMutation.mutateAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={props.visible} transparent animationType="slide">
|
|
||||||
<View style={styles.overlay}>
|
|
||||||
<View style={styles.sheet}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.title}>Manage Keys</Text>
|
|
||||||
<Pressable style={styles.closeBtn} onPress={props.onClose}>
|
|
||||||
<Text style={styles.closeText}>Close</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={[
|
|
||||||
styles.primaryButton,
|
|
||||||
generateMutation.isPending && { opacity: 0.7 },
|
|
||||||
]}
|
|
||||||
disabled={generateMutation.isPending}
|
|
||||||
onPress={handleGenerate}
|
|
||||||
>
|
|
||||||
<Text style={styles.primaryButtonText}>
|
|
||||||
{generateMutation.isPending
|
|
||||||
? 'Generating…'
|
|
||||||
: 'Generate New RSA 4096 Key'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{listKeysQuery.isLoading ? (
|
|
||||||
<View style={styles.centerRow}>
|
|
||||||
<ActivityIndicator color="#9AA0A6" />
|
|
||||||
<Text style={styles.muted}> Loading keys…</Text>
|
|
||||||
</View>
|
|
||||||
) : listKeysQuery.isError ? (
|
|
||||||
<Text style={styles.error}>Error loading keys</Text>
|
|
||||||
) : listKeysQuery.data?.length ? (
|
|
||||||
<View>
|
|
||||||
{listKeysQuery.data.map((k) => (
|
|
||||||
<KeyRow
|
|
||||||
key={k.id}
|
|
||||||
entry={k}
|
|
||||||
selected={props.selectedKeyId === k.id}
|
|
||||||
onSelect={() => {
|
|
||||||
if (props.onSelect) props.onSelect(k.id);
|
|
||||||
props.onClose();
|
|
||||||
}}
|
|
||||||
onDelete={() => handleDelete(k.id)}
|
|
||||||
onSetDefault={() => handleSetDefault(k.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.muted}>No keys yet</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyRow(props: {
|
|
||||||
entry: Awaited<
|
|
||||||
ReturnType<typeof secretsManager.keys.utils.listEntriesWithValues>
|
|
||||||
>[number];
|
|
||||||
selected?: boolean;
|
|
||||||
onSelect?: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onSetDefault: () => void;
|
|
||||||
}) {
|
|
||||||
const [isEditing, setIsEditing] = React.useState(false);
|
|
||||||
const [label, setLabel] = React.useState(props.entry.metadata?.label ?? '');
|
|
||||||
const isDefault = props.entry.metadata?.isDefault;
|
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
|
||||||
mutationFn: async (newLabel: string) => {
|
|
||||||
await secretsManager.keys.utils.upsertPrivateKey({
|
|
||||||
keyId: props.entry.id,
|
|
||||||
value: props.entry.value,
|
|
||||||
metadata: {
|
|
||||||
priority: props.entry.metadata.priority,
|
|
||||||
label: newLabel,
|
|
||||||
isDefault: props.entry.metadata.isDefault,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => setIsEditing(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function saveLabel() {
|
|
||||||
await renameMutation.mutateAsync(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable style={styles.row} onPress={props.onSelect}>
|
|
||||||
<View style={{ flex: 1, marginRight: 8 }}>
|
|
||||||
<Text style={styles.rowTitle}>
|
|
||||||
{(props.entry.metadata?.label ?? props.entry.id) +
|
|
||||||
(isDefault ? ' • Default' : '')}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.rowSub}>ID: {props.entry.id}</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder="Display name"
|
|
||||||
placeholderTextColor="#9AA0A6"
|
|
||||||
value={label}
|
|
||||||
onChangeText={setLabel}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.rowActions}>
|
|
||||||
{props.onSelect ? (
|
|
||||||
<View style={styles.radioWrap}>
|
|
||||||
<View
|
|
||||||
style={[styles.radio, props.selected && styles.radioSelected]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{!isDefault ? (
|
|
||||||
<Pressable
|
|
||||||
style={styles.secondaryButton}
|
|
||||||
onPress={props.onSetDefault}
|
|
||||||
>
|
|
||||||
<Text style={styles.secondaryButtonText}>Set Default</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
{isEditing ? (
|
|
||||||
<Pressable
|
|
||||||
style={[
|
|
||||||
styles.secondaryButton,
|
|
||||||
renameMutation.isPending && { opacity: 0.6 },
|
|
||||||
]}
|
|
||||||
onPress={saveLabel}
|
|
||||||
disabled={renameMutation.isPending}
|
|
||||||
>
|
|
||||||
<Text style={styles.secondaryButtonText}>
|
|
||||||
{renameMutation.isPending ? 'Saving…' : 'Save'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<Pressable
|
|
||||||
style={styles.secondaryButton}
|
|
||||||
onPress={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
<Text style={styles.secondaryButtonText}>Rename</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
<Pressable style={styles.dangerButton} onPress={props.onDelete}>
|
|
||||||
<Text style={styles.dangerButtonText}>Delete</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
overlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
sheet: {
|
|
||||||
backgroundColor: '#0B1324',
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
borderColor: '#1E293B',
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
closeBtn: {
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
},
|
|
||||||
closeText: {
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
fontSize: 16,
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
primaryButton: {
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingVertical: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
primaryButtonText: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
centerRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
color: '#FCA5A5',
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
rowTitle: {
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
rowSub: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
rowActions: {
|
|
||||||
gap: 6,
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
},
|
|
||||||
radioWrap: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginRight: 6,
|
|
||||||
},
|
|
||||||
radio: {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 16,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderWidth: 2,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
radioSelected: {
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
borderColor: '#2563EB',
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
secondaryButtonText: {
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
dangerButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#7F1D1D',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
dangerButtonText: {
|
|
||||||
color: '#FCA5A5',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default KeyManagerModal;
|
|
||||||
Reference in New Issue
Block a user