Files
fressh/apps/mobile/src/app/(tabs)/index.tsx
2025-09-19 23:05:34 -04:00

742 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 { useSshConnMutation } from '@/lib/query-fns';
import {
connectionDetailsSchema,
secretsManager,
type InputConnectionDetails,
} from '@/lib/secrets-manager';
import { useTheme } from '@/lib/theme';
import { useBottomTabPadding } from '@/lib/useBottomTabPadding';
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 { paddingBottom, onLayout } = useBottomTabPadding(12);
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;
console.log('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={[{ paddingBottom }]}
keyboardShouldPersistTaps="handled"
style={{ backgroundColor: theme.colors.background }}
onLayout={onLayout}
>
<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={() => {
console.log('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>
);
}