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;