diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..01377a5
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/_layout.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
+
+export default function TabsLayout() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
new file mode 100644
index 0000000..4dc944e
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import Host from '../host';
+
+export default function TabsIndex() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(tabs)/settings/_layout.tsx b/apps/mobile/src/app/(tabs)/settings/_layout.tsx
new file mode 100644
index 0000000..f662677
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/settings/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+export default function SettingsStackLayout() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(tabs)/settings/index.tsx b/apps/mobile/src/app/(tabs)/settings/index.tsx
new file mode 100644
index 0000000..74de42e
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/settings/index.tsx
@@ -0,0 +1 @@
+export { default } from '../../settings';
diff --git a/apps/mobile/src/app/(tabs)/settings/key-manager.tsx b/apps/mobile/src/app/(tabs)/settings/key-manager.tsx
new file mode 100644
index 0000000..55b2acb
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/settings/key-manager.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/(tabs)/shell/[connectionId]/[channelId].tsx b/apps/mobile/src/app/(tabs)/shell/[connectionId]/[channelId].tsx
new file mode 100644
index 0000000..a8c2633
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/shell/[connectionId]/[channelId].tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import ShellDetail from '../../../shell/[connectionId]/[channelId]';
+
+export default function TabsShellDetail() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(tabs)/shell/_layout.tsx b/apps/mobile/src/app/(tabs)/shell/_layout.tsx
new file mode 100644
index 0000000..1288ddd
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/shell/_layout.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import ShellStackLayout from '../../shell/_layout';
+
+export default function TabsShellStack() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(tabs)/shell/index.tsx b/apps/mobile/src/app/(tabs)/shell/index.tsx
new file mode 100644
index 0000000..5459cc8
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/shell/index.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import ShellList from '../../shell/index';
+
+export default function TabsShellList() {
+ return ;
+}
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 2a2afc8..62bc354 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
);
diff --git a/apps/mobile/src/app/host.tsx b/apps/mobile/src/app/host.tsx
new file mode 100644
index 0000000..e8220dd
--- /dev/null
+++ b/apps/mobile/src/app/host.tsx
@@ -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 (
+
+
+
+
+ fressh
+ A fast, friendly SSH client
+
+
+ Connect to SSH Server
+ Enter your server credentials
+
+
+
+ {(field) => (
+
+ )}
+
+
+ {(field) => (
+
+ )}
+
+
+ {(field) => (
+
+ )}
+
+
+ {(field) => (
+
+ {
+ field.handleChange(
+ event.nativeEvent.selectedSegmentIndex === 0
+ ? 'password'
+ : 'key',
+ );
+ }}
+ />
+
+ )}
+
+ {securityType === 'password' ? (
+
+ {(field) => (
+
+ )}
+
+ ) : (
+
+ {() => }
+
+ )}
+
+
+ {
+ if (isSubmitting) return;
+ void connectionForm.handleSubmit();
+ }}
+ />
+
+
+
+ {
+ 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,
+ );
+ }
+ }}
+ />
+
+
+
+ );
+}
+
+function KeyIdPickerField() {
+ const field = useFieldContext();
+ 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 (
+ <>
+
+ Private Key
+ {
+ void listPrivateKeysQuery.refetch();
+ setOpen(true);
+ }}
+ >
+ {display}
+
+ {!selected && (
+
+ Open Key Manager to add/select a key
+
+ )}
+
+ setOpen(false)}
+ >
+
+
+
+ Select Key
+ setOpen(false)}
+ >
+ Close
+
+
+ {
+ field.handleChange(id);
+ setOpen(false);
+ }}
+ />
+
+
+
+ >
+ );
+}
+
+function PreviousConnectionsSection(props: {
+ onSelect: (connection: ConnectionDetails) => void;
+}) {
+ const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
+
+ return (
+
+ Previous Connections
+ {listConnectionsQuery.isLoading ? (
+ Loading connections...
+ ) : listConnectionsQuery.isError ? (
+ Error loading connections
+ ) : listConnectionsQuery.data?.length ? (
+
+ {listConnectionsQuery.data?.map((conn) => (
+
+ ))}
+
+ ) : (
+ No saved connections yet
+ )}
+
+ );
+}
+
+function ConnectionRow(props: {
+ id: string;
+ onSelect: (connection: ConnectionDetails) => void;
+}) {
+ const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
+ const details = detailsQuery.data?.value;
+
+ return (
+ {
+ if (details) props.onSelect(details);
+ }}
+ disabled={!details}
+ >
+
+
+ {details ? `${details.username}@${details.host}` : 'Loading...'}
+
+
+ {details ? `Port ${details.port} • ${details.security.type}` : ''}
+
+
+ ›
+
+ );
+}
+
+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',
+ },
+});
diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx
index e0c58fd..d197633 100644
--- a/apps/mobile/src/app/index.tsx
+++ b/apps/mobile/src/app/index.tsx
@@ -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 (
-
-
-
-
- fressh
- A fast, friendly SSH client
-
-
- Connect to SSH Server
- Enter your server credentials
-
-
-
- {(field) => (
-
- )}
-
-
- {(field) => (
-
- )}
-
-
- {(field) => (
-
- )}
-
-
- {(field) => (
-
- {
- field.handleChange(
- event.nativeEvent.selectedSegmentIndex === 0
- ? 'password'
- : 'key',
- );
- }}
- />
-
- )}
-
- {securityType === 'password' ? (
-
- {(field) => (
-
- )}
-
- ) : (
-
- {() => }
-
- )}
-
-
- {
- if (isSubmitting) return;
- void connectionForm.handleSubmit();
- }}
- />
-
-
-
- {
- 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,
- );
- }
- }}
- />
-
-
-
- );
+export default function RootRedirect() {
+ return ;
}
-
-function KeyIdPicker() {
- const field = useFieldContext();
- 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 (
- <>
-
- Private Key
- {
- hasInteractedRef.current = true;
- setManualVisible(true);
- }}
- >
- {display}
-
- {!selected && (
-
- Open Key Manager to add/select a key
-
- )}
-
- {
- 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 (
-
- Previous Connections
- {listConnectionsQuery.isLoading ? (
- Loading connections...
- ) : listConnectionsQuery.isError ? (
- Error loading connections
- ) : listConnectionsQuery.data?.length ? (
-
- {listConnectionsQuery.data?.map((conn) => (
-
- ))}
-
- ) : (
- No saved connections yet
- )}
-
- );
-}
-
-function ConnectionRow(props: {
- id: string;
- onSelect: (connection: ConnectionDetails) => void;
-}) {
- const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
- const details = detailsQuery.data?.value;
-
- return (
- {
- if (details) props.onSelect(details);
- }}
- disabled={!details}
- >
-
-
- {details ? `${details.username}@${details.host}` : 'Loading...'}
-
-
- {details ? `Port ${details.port} • ${details.security.type}` : ''}
-
-
- ›
-
- );
-}
-
-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,
- },
-});
diff --git a/apps/mobile/src/app/settings.tsx b/apps/mobile/src/app/settings.tsx
index 2d43feb..1505e66 100644
--- a/apps/mobile/src/app/settings.tsx
+++ b/apps/mobile/src/app/settings.tsx
@@ -6,14 +6,11 @@ export default function Tab() {
Settings
Manage Keys
-
- Open Key Picker (modal)
-
);
}
diff --git a/apps/mobile/src/components/key-manager-modal.tsx b/apps/mobile/src/components/key-manager-modal.tsx
deleted file mode 100644
index e7c2b25..0000000
--- a/apps/mobile/src/components/key-manager-modal.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Manage Keys
-
- Close
-
-
-
-
-
- {generateMutation.isPending
- ? 'Generating…'
- : 'Generate New RSA 4096 Key'}
-
-
-
- {listKeysQuery.isLoading ? (
-
-
- Loading keys…
-
- ) : listKeysQuery.isError ? (
- Error loading keys
- ) : listKeysQuery.data?.length ? (
-
- {listKeysQuery.data.map((k) => (
- {
- if (props.onSelect) props.onSelect(k.id);
- props.onClose();
- }}
- onDelete={() => handleDelete(k.id)}
- onSetDefault={() => handleSetDefault(k.id)}
- />
- ))}
-
- ) : (
- No keys yet
- )}
-
-
-
- );
-}
-
-function KeyRow(props: {
- entry: Awaited<
- ReturnType
- >[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 (
-
-
-
- {(props.entry.metadata?.label ?? props.entry.id) +
- (isDefault ? ' • Default' : '')}
-
- ID: {props.entry.id}
- {isEditing ? (
-
- ) : null}
-
-
- {props.onSelect ? (
-
-
-
- ) : null}
- {!isDefault ? (
-
- Set Default
-
- ) : null}
- {isEditing ? (
-
-
- {renameMutation.isPending ? 'Saving…' : 'Save'}
-
-
- ) : (
- setIsEditing(true)}
- >
- Rename
-
- )}
-
- Delete
-
-
-
- );
-}
-
-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;