mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12: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 { 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 { queryClient } from '../lib/utils';
|
||||
import { ThemeProvider } from '../theme';
|
||||
@@ -9,26 +9,11 @@ console.log('Fressh App Init', {
|
||||
isLiquidGlassAvailable: isLiquidGlassAvailable(),
|
||||
});
|
||||
|
||||
// https://docs.expo.dev/versions/latest/sdk/navigation-bar/
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<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>
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</ThemeProvider>
|
||||
</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 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';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
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/' +
|
||||
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>
|
||||
);
|
||||
export default function RootRedirect() {
|
||||
return <Redirect href="/(tabs)" />;
|
||||
}
|
||||
|
||||
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}>
|
||||
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
|
||||
<Link
|
||||
href="/(shared)/key-manager"
|
||||
href="/(tabs)/settings/key-manager"
|
||||
style={{ color: '#60A5FA', marginBottom: 8 }}
|
||||
>
|
||||
Manage Keys
|
||||
</Link>
|
||||
<Link href="/(modals)/key-manager?select=1" style={{ color: '#60A5FA' }}>
|
||||
Open Key Picker (modal)
|
||||
</Link>
|
||||
</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