mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
744 lines
19 KiB
TypeScript
744 lines
19 KiB
TypeScript
import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
|
|
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
|
import { useStore } from '@tanstack/react-form';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import React, { useEffect } from 'react';
|
|
import {
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useAppForm, useFieldContext } from '@/components/form-components';
|
|
import { KeyList } from '@/components/key-manager/KeyList';
|
|
import { rootLogger } from '@/lib/logger';
|
|
import { useSshConnMutation } from '@/lib/query-fns';
|
|
import {
|
|
connectionDetailsSchema,
|
|
secretsManager,
|
|
type InputConnectionDetails,
|
|
} from '@/lib/secrets-manager';
|
|
import { useTheme } from '@/lib/theme';
|
|
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
|
|
|
|
const logger = rootLogger.extend('TabsIndex');
|
|
|
|
export default function TabsIndex() {
|
|
return <Host />;
|
|
}
|
|
|
|
const defaultValues: InputConnectionDetails = {
|
|
host: '',
|
|
port: 22,
|
|
username: '',
|
|
security: {
|
|
type: 'password',
|
|
password: '',
|
|
},
|
|
};
|
|
|
|
function Host() {
|
|
const theme = useTheme();
|
|
const [lastConnectionProgressEvent, setLastConnectionProgressEvent] =
|
|
React.useState<SshConnectionProgress | null>(null);
|
|
|
|
const sshConnMutation = useSshConnMutation({
|
|
onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
|
|
});
|
|
const marginBottom = useBottomTabSpacing();
|
|
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).then(() => {
|
|
setLastConnectionProgressEvent(null);
|
|
}),
|
|
},
|
|
});
|
|
|
|
const securityType = useStore(
|
|
connectionForm.store,
|
|
(state) => state.values.security.type,
|
|
);
|
|
const formErrors = useStore(connectionForm.store, (state) => state.errorMap);
|
|
useEffect(() => {
|
|
if (!formErrors || Object.keys(formErrors).length === 0) return;
|
|
logger.info('formErrors', JSON.stringify(formErrors, null, 2));
|
|
}, [formErrors]);
|
|
|
|
const isSubmitting = useStore(
|
|
connectionForm.store,
|
|
(state) => state.isSubmitting,
|
|
);
|
|
|
|
const buttonLabel = (() => {
|
|
if (!sshConnMutation.isPending) return 'Connect';
|
|
if (lastConnectionProgressEvent === null) return 'TCP Connecting...';
|
|
if (lastConnectionProgressEvent === 'tcpConnected')
|
|
return 'SSH Handshake...';
|
|
if (lastConnectionProgressEvent === 'sshHandshake')
|
|
return 'Authenticating...';
|
|
return 'Connected!';
|
|
})();
|
|
|
|
return (
|
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
<ScrollView
|
|
contentContainerStyle={[{ marginBottom }]}
|
|
keyboardShouldPersistTaps="handled"
|
|
style={{ backgroundColor: theme.colors.background }}
|
|
>
|
|
<View
|
|
style={[
|
|
{
|
|
flex: 1,
|
|
padding: 24,
|
|
backgroundColor: theme.colors.background,
|
|
justifyContent: 'center',
|
|
},
|
|
{ backgroundColor: theme.colors.background },
|
|
]}
|
|
>
|
|
<View style={{ marginBottom: 16, alignItems: 'center' }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 28,
|
|
fontWeight: '800',
|
|
color: theme.colors.textPrimary,
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
fressh
|
|
</Text>
|
|
<Text
|
|
style={{ marginTop: 4, fontSize: 13, color: theme.colors.muted }}
|
|
>
|
|
A fast, friendly SSH client
|
|
</Text>
|
|
</View>
|
|
<View
|
|
style={{
|
|
backgroundColor: theme.colors.surface,
|
|
borderRadius: 20,
|
|
padding: 24,
|
|
marginHorizontal: 4,
|
|
shadowColor: theme.colors.shadow,
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 16,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
elevation: 8,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.borderStrong,
|
|
}}
|
|
>
|
|
{/* Status lives inside the Connect button via submittingTitle */}
|
|
|
|
<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={{ marginBottom: 12 }}>
|
|
<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={{ marginTop: 20 }}>
|
|
<connectionForm.SubmitButton
|
|
title="Connect"
|
|
submittingTitle={buttonLabel}
|
|
testID="connect"
|
|
onPress={() => {
|
|
logger.info('Connect button pressed', { isSubmitting });
|
|
if (isSubmitting) return;
|
|
void connectionForm.handleSubmit();
|
|
}}
|
|
/>
|
|
</View>
|
|
{sshConnMutation.isError ? (
|
|
<Text style={{ color: theme.colors.danger, marginTop: 8 }}>
|
|
{String(
|
|
(sshConnMutation.error as Error)?.message ??
|
|
'Failed to connect',
|
|
)}
|
|
</Text>
|
|
) : null}
|
|
</connectionForm.AppForm>
|
|
</View>
|
|
<PreviousConnectionsSection
|
|
onFillForm={(connection) => {
|
|
connectionForm.setFieldValue('host', connection.host);
|
|
connectionForm.setFieldValue('port', connection.port);
|
|
connectionForm.setFieldValue('username', connection.username);
|
|
if (connection.security.type === 'password') {
|
|
connectionForm.setFieldValue(
|
|
'security.password',
|
|
connection.security.password,
|
|
);
|
|
connectionForm.setFieldValue('security.type', 'password');
|
|
} else {
|
|
connectionForm.setFieldValue(
|
|
'security.keyId',
|
|
connection.security.keyId,
|
|
);
|
|
connectionForm.setFieldValue('security.type', 'key');
|
|
}
|
|
}}
|
|
/>
|
|
</View>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
function KeyIdPickerField() {
|
|
const theme = useTheme();
|
|
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 ?? [];
|
|
|
|
const fieldValue = field.state.value;
|
|
const defaultPickId = defaultPick?.id;
|
|
const fieldHandleChange = field.handleChange;
|
|
|
|
React.useEffect(() => {
|
|
if (!fieldValue && defaultPickId) {
|
|
fieldHandleChange(defaultPickId);
|
|
}
|
|
}, [fieldValue, defaultPickId, fieldHandleChange]);
|
|
|
|
const computedSelectedId = field.state.value;
|
|
const selected = keys.find((k) => k.id === computedSelectedId);
|
|
const display = selected ? (selected.metadata.label ?? selected.id) : 'None';
|
|
const meta = field.state.meta as { errors?: unknown[] };
|
|
const firstErr = meta?.errors?.[0] as { message: string } | undefined;
|
|
const fieldError =
|
|
firstErr &&
|
|
typeof firstErr === 'object' &&
|
|
typeof firstErr.message === 'string'
|
|
? firstErr.message
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
<View style={{ marginBottom: 12 }}>
|
|
<Text
|
|
style={{
|
|
marginBottom: 6,
|
|
fontSize: 14,
|
|
color: theme.colors.textSecondary,
|
|
fontWeight: '600',
|
|
}}
|
|
>
|
|
Private Key
|
|
</Text>
|
|
<Pressable
|
|
style={[
|
|
{
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.inputBackground,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 12,
|
|
justifyContent: 'center',
|
|
},
|
|
]}
|
|
onPress={() => {
|
|
void listPrivateKeysQuery.refetch();
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
<Text style={{ color: theme.colors.textPrimary }}>{display}</Text>
|
|
</Pressable>
|
|
{!selected && (
|
|
<Text style={{ color: theme.colors.muted, fontSize: 14 }}>
|
|
Open Key Manager to add/select a key
|
|
</Text>
|
|
)}
|
|
</View>
|
|
{fieldError ? (
|
|
<Text
|
|
style={{ color: theme.colors.danger, fontSize: 12, marginTop: 6 }}
|
|
>
|
|
{fieldError}
|
|
</Text>
|
|
) : null}
|
|
<Modal
|
|
visible={open}
|
|
transparent
|
|
animationType="slide"
|
|
onRequestClose={() => {
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: theme.colors.overlay,
|
|
justifyContent: 'flex-end',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: theme.colors.background,
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
padding: 16,
|
|
borderColor: theme.colors.borderStrong,
|
|
borderWidth: 1,
|
|
maxHeight: '85%',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textPrimary,
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
}}
|
|
>
|
|
Select Key
|
|
</Text>
|
|
<Pressable
|
|
style={{
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 6,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
}}
|
|
onPress={() => {
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textSecondary,
|
|
fontWeight: '600',
|
|
}}
|
|
>
|
|
Close
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
<KeyList
|
|
mode="select"
|
|
onSelect={(id) => {
|
|
field.handleChange(id);
|
|
setOpen(false);
|
|
}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PreviousConnectionsSection(props: {
|
|
onFillForm: (connection: InputConnectionDetails) => void;
|
|
}) {
|
|
const theme = useTheme();
|
|
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
|
|
|
|
return (
|
|
<View style={{ marginTop: 20 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: theme.colors.textPrimary,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
Previous Connections
|
|
</Text>
|
|
{listConnectionsQuery.isLoading ? (
|
|
<Text style={{ color: theme.colors.muted, fontSize: 14 }}>
|
|
Loading connections...
|
|
</Text>
|
|
) : listConnectionsQuery.isError ? (
|
|
<Text
|
|
style={{ marginTop: 6, color: theme.colors.danger, fontSize: 12 }}
|
|
>
|
|
Error loading connections
|
|
</Text>
|
|
) : listConnectionsQuery.data?.length ? (
|
|
<View>
|
|
{listConnectionsQuery.data.map((conn) => (
|
|
<ConnectionRow
|
|
key={conn.id}
|
|
id={conn.id}
|
|
onFillForm={props.onFillForm}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<Text style={{ color: theme.colors.muted, fontSize: 14 }}>
|
|
No saved connections yet
|
|
</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ConnectionRow(props: {
|
|
id: string;
|
|
onFillForm: (connection: InputConnectionDetails) => void;
|
|
}) {
|
|
const theme = useTheme();
|
|
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
|
|
const details = detailsQuery.data?.value;
|
|
const [open, setOpen] = React.useState(false);
|
|
const [renameOpen, setRenameOpen] = React.useState(false);
|
|
const [newId, setNewId] = React.useState(props.id);
|
|
const listQuery = useQuery(secretsManager.connections.query.list);
|
|
|
|
return (
|
|
<Pressable
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: theme.colors.inputBackground,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 12,
|
|
marginBottom: 8,
|
|
}}
|
|
onPress={() => {
|
|
if (details) props.onFillForm(details);
|
|
}}
|
|
disabled={!details}
|
|
>
|
|
<View style={{ flex: 1, marginRight: 12 }}>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textPrimary,
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
}}
|
|
>
|
|
{details ? `${details.username}@${details.host}` : 'Loading...'}
|
|
</Text>
|
|
<Text style={{ color: theme.colors.muted, marginTop: 2, fontSize: 12 }}>
|
|
{details ? `Port ${details.port} • ${details.security.type}` : ''}
|
|
</Text>
|
|
</View>
|
|
<Pressable onPress={() => setOpen(true)} hitSlop={8}>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.muted,
|
|
fontSize: 22,
|
|
paddingHorizontal: 4,
|
|
}}
|
|
>
|
|
⋯
|
|
</Text>
|
|
</Pressable>
|
|
|
|
{/* Actions Modal */}
|
|
<Modal
|
|
transparent
|
|
visible={open}
|
|
animationType="fade"
|
|
onRequestClose={() => setOpen(false)}
|
|
>
|
|
<Pressable
|
|
style={{ flex: 1, backgroundColor: theme.colors.overlay }}
|
|
onPress={() => setOpen(false)}
|
|
>
|
|
<View
|
|
style={{
|
|
marginTop: 'auto',
|
|
backgroundColor: theme.colors.background,
|
|
padding: 16,
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.borderStrong,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textPrimary,
|
|
fontWeight: '700',
|
|
fontSize: 16,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
Connection Actions
|
|
</Text>
|
|
<View style={{ gap: 8 }}>
|
|
{/* Keep only rename/delete/cancel. Tap row fills the form */}
|
|
<Pressable
|
|
onPress={() => {
|
|
setOpen(false);
|
|
setRenameOpen(true);
|
|
setNewId(props.id);
|
|
}}
|
|
style={{
|
|
backgroundColor: theme.colors.transparent,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textSecondary,
|
|
fontWeight: '600',
|
|
}}
|
|
>
|
|
Rename
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={async () => {
|
|
setOpen(false);
|
|
await secretsManager.connections.utils.deleteConnection(
|
|
props.id,
|
|
);
|
|
await listQuery.refetch();
|
|
}}
|
|
style={{
|
|
backgroundColor: theme.colors.transparent,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.danger,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text style={{ color: theme.colors.danger, fontWeight: '700' }}>
|
|
Delete
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => setOpen(false)}
|
|
style={{
|
|
backgroundColor: theme.colors.transparent,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textSecondary,
|
|
fontWeight: '600',
|
|
}}
|
|
>
|
|
Cancel
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
</Modal>
|
|
|
|
{/* Rename Modal */}
|
|
<Modal
|
|
transparent
|
|
visible={renameOpen}
|
|
animationType="fade"
|
|
onRequestClose={() => setRenameOpen(false)}
|
|
>
|
|
<Pressable
|
|
style={{ flex: 1, backgroundColor: theme.colors.overlay }}
|
|
onPress={() => setRenameOpen(false)}
|
|
>
|
|
<View
|
|
style={{
|
|
marginTop: 'auto',
|
|
backgroundColor: theme.colors.background,
|
|
padding: 16,
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.borderStrong,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textPrimary,
|
|
fontWeight: '700',
|
|
fontSize: 16,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
Rename Connection
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.muted,
|
|
fontSize: 12,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
Enter a new identifier for this saved connection
|
|
</Text>
|
|
<TextInput
|
|
value={newId}
|
|
onChangeText={setNewId}
|
|
autoCapitalize="none"
|
|
style={{
|
|
backgroundColor: theme.colors.inputBackground,
|
|
color: theme.colors.textPrimary,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
marginBottom: 12,
|
|
}}
|
|
/>
|
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
<Pressable
|
|
onPress={async () => {
|
|
if (!details) return;
|
|
if (!newId || newId === props.id) {
|
|
setRenameOpen(false);
|
|
return;
|
|
}
|
|
// Recreate under new id then delete old
|
|
await secretsManager.connections.utils.upsertConnection({
|
|
details,
|
|
priority: 0,
|
|
label: newId,
|
|
});
|
|
await listQuery.refetch();
|
|
setRenameOpen(false);
|
|
}}
|
|
style={{
|
|
backgroundColor: theme.colors.primary,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.buttonTextOnPrimary,
|
|
fontWeight: '700',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Save
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => setRenameOpen(false)}
|
|
style={{
|
|
backgroundColor: theme.colors.transparent,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: theme.colors.textSecondary,
|
|
fontWeight: '600',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Cancel
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
</Modal>
|
|
</Pressable>
|
|
);
|
|
}
|