mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
working private key
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import React from 'react';
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||
import { secretsManager } from '@/lib/secrets-manager';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export type KeyListMode = 'manage' | 'select';
|
||||
|
||||
@@ -10,6 +13,7 @@ export function KeyList(props: {
|
||||
onSelect?: (id: string) => void | Promise<void>;
|
||||
}) {
|
||||
const listKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||
const theme = useTheme();
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -28,15 +32,16 @@ export function KeyList(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
|
||||
<ImportKeyCard onImported={() => listKeysQuery.refetch()} />
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
{
|
||||
backgroundColor: '#2563EB',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
generateMutation.isPending && { opacity: 0.7 },
|
||||
]}
|
||||
@@ -45,19 +50,26 @@ export function KeyList(props: {
|
||||
generateMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.buttonTextOnPrimary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{generateMutation.isPending
|
||||
? 'Generating…'
|
||||
: 'Generate New RSA 4096 Key'}
|
||||
: 'Generate New Key (ed25519)'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{listKeysQuery.isLoading ? (
|
||||
<Text style={{ color: '#9AA0A6' }}>Loading keys…</Text>
|
||||
<Text style={{ color: theme.colors.muted }}>Loading keys…</Text>
|
||||
) : listKeysQuery.isError ? (
|
||||
<Text style={{ color: '#FCA5A5' }}>Error loading keys</Text>
|
||||
<Text style={{ color: theme.colors.danger }}>Error loading keys</Text>
|
||||
) : listKeysQuery.data?.length ? (
|
||||
<View>
|
||||
<View style={{ gap: 12 }}>
|
||||
{listKeysQuery.data.map((k) => (
|
||||
<KeyRow
|
||||
key={k.id}
|
||||
@@ -68,17 +80,261 @@ export function KeyList(props: {
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={{ color: '#9AA0A6' }}>No keys yet</Text>
|
||||
<Text style={{ color: theme.colors.muted }}>No keys yet</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportKeyCard({ onImported }: { onImported: () => void }) {
|
||||
const theme = useTheme();
|
||||
const [mode, setMode] = React.useState<'paste' | 'file'>('paste');
|
||||
const [label, setLabel] = React.useState('Imported Key');
|
||||
const [asDefault, setAsDefault] = React.useState(false);
|
||||
const [content, setContent] = React.useState('');
|
||||
const [fileName, setFileName] = React.useState<string | null>(null);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) throw new Error('No key content provided');
|
||||
const keyId = `key_${Crypto.randomUUID()}`;
|
||||
await secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId,
|
||||
metadata: {
|
||||
priority: 0,
|
||||
label: label || 'Imported Key',
|
||||
isDefault: asDefault,
|
||||
},
|
||||
value: trimmed,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setContent('');
|
||||
setFileName(null);
|
||||
onImported();
|
||||
},
|
||||
});
|
||||
|
||||
const pickFile = React.useCallback(async () => {
|
||||
const res = await DocumentPicker.getDocumentAsync({
|
||||
multiple: false,
|
||||
copyToCacheDirectory: true,
|
||||
type: ['text/*', 'application/*'],
|
||||
});
|
||||
// Newer expo-document-picker: { canceled: boolean, assets?: [{ uri, name, ... }] }
|
||||
const canceled = 'canceled' in res ? res.canceled : false;
|
||||
if (canceled) return;
|
||||
const asset = res.assets?.[0];
|
||||
const file = asset?.file;
|
||||
if (!file) return;
|
||||
setFileName(asset.name ?? null);
|
||||
const data = await file.text();
|
||||
setContent(data);
|
||||
if (asset.name && (!label || label === 'Imported Key')) {
|
||||
setLabel(String(asset.name).replace(/\.[^.]+$/, ''));
|
||||
}
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
fontWeight: '700',
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Import Private Key
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme.colors.inputBackground,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(['paste', 'file'] as const).map((m) => (
|
||||
<Pressable
|
||||
key={m}
|
||||
onPress={() => setMode(m)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
backgroundColor:
|
||||
mode === m
|
||||
? theme.colors.surface
|
||||
: theme.colors.inputBackground,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color:
|
||||
mode === m ? theme.colors.textPrimary : theme.colors.muted,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{m === 'paste' ? 'Paste' : 'File'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{mode === 'paste' ? (
|
||||
<TextInput
|
||||
multiline
|
||||
placeholder="Paste your private key here"
|
||||
placeholderTextColor={theme.colors.muted}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
style={{
|
||||
minHeight: 120,
|
||||
backgroundColor: theme.colors.inputBackground,
|
||||
color: theme.colors.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
fontFamily: 'Menlo, ui-monospace, monospace',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Pressable
|
||||
onPress={pickFile}
|
||||
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' }}
|
||||
>
|
||||
{fileName ? 'Choose Different File' : 'Choose File'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{fileName ? (
|
||||
<Text style={{ color: theme.colors.muted }}>
|
||||
Selected: {fileName}
|
||||
</Text>
|
||||
) : null}
|
||||
{content ? (
|
||||
<TextInput
|
||||
editable={false}
|
||||
multiline
|
||||
value={content.slice(0, 500)}
|
||||
style={{
|
||||
minHeight: 80,
|
||||
backgroundColor: theme.colors.inputBackground,
|
||||
color: theme.colors.textSecondary,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
fontFamily: 'Menlo, ui-monospace, monospace',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.textSecondary, fontSize: 12 }}>
|
||||
Label
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="Display name"
|
||||
placeholderTextColor={theme.colors.muted}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
style={{
|
||||
backgroundColor: theme.colors.inputBackground,
|
||||
color: theme.colors.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setAsDefault((v) => !v)}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: asDefault
|
||||
? theme.colors.primary
|
||||
: theme.colors.transparent,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: theme.colors.textSecondary }}>
|
||||
Set as default
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
disabled={importMutation.isPending}
|
||||
onPress={() => importMutation.mutate()}
|
||||
style={{
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
opacity: importMutation.isPending ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.buttonTextOnPrimary,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{importMutation.isPending ? 'Importing…' : 'Import Key'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{importMutation.isError ? (
|
||||
<Text style={{ color: theme.colors.danger }}>
|
||||
{(importMutation.error as Error).message || 'Import failed'}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyRow(props: {
|
||||
entryId: string;
|
||||
mode: KeyListMode;
|
||||
onSelected?: (id: string) => void | Promise<void>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId));
|
||||
const entry = entryQuery.data;
|
||||
const [label, setLabel] = React.useState(
|
||||
@@ -141,30 +397,35 @@ function KeyRow(props: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#0E172B',
|
||||
backgroundColor: theme.colors.inputBackground,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{entry.manifestEntry.metadata.label ?? entry.manifestEntry.id}
|
||||
{entry.manifestEntry.metadata.isDefault ? ' • Default' : ''}
|
||||
</Text>
|
||||
<Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}>
|
||||
<Text style={{ color: theme.colors.muted, fontSize: 12, marginTop: 2 }}>
|
||||
ID: {entry.manifestEntry.id}
|
||||
</Text>
|
||||
{props.mode === 'manage' ? (
|
||||
<TextInput
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
backgroundColor: '#0E172B',
|
||||
color: '#E5E7EB',
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.inputBackground,
|
||||
color: theme.colors.textPrimary,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
@@ -172,7 +433,7 @@ function KeyRow(props: {
|
||||
marginTop: 8,
|
||||
}}
|
||||
placeholder="Display name"
|
||||
placeholderTextColor="#9AA0A6"
|
||||
placeholderTextColor={theme.colors.muted}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
@@ -185,14 +446,20 @@ function KeyRow(props: {
|
||||
setDefaultMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#2563EB',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.buttonTextOnPrimary,
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -201,9 +468,9 @@ function KeyRow(props: {
|
||||
<Pressable
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: theme.colors.transparent,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
@@ -216,7 +483,13 @@ function KeyRow(props: {
|
||||
}}
|
||||
disabled={renameMutation.isPending}
|
||||
>
|
||||
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{renameMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -224,9 +497,9 @@ function KeyRow(props: {
|
||||
{!entry.manifestEntry.metadata.isDefault ? (
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: theme.colors.transparent,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
@@ -236,16 +509,22 @@ function KeyRow(props: {
|
||||
setDefaultMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Set Default
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: theme.colors.transparent,
|
||||
borderWidth: 1,
|
||||
borderColor: '#7F1D1D',
|
||||
borderColor: theme.colors.danger,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
@@ -255,7 +534,13 @@ function KeyRow(props: {
|
||||
deleteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.danger,
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -18,17 +18,25 @@ export const useSshConnMutation = () => {
|
||||
mutationFn: async (connectionDetails: InputConnectionDetails) => {
|
||||
try {
|
||||
console.log('Connecting to SSH server...');
|
||||
// Resolve security into the RN bridge shape
|
||||
const security =
|
||||
connectionDetails.security.type === 'password'
|
||||
? {
|
||||
type: 'password' as const,
|
||||
password: connectionDetails.security.password,
|
||||
}
|
||||
: {
|
||||
type: 'key' as const,
|
||||
privateKey: await secretsManager.keys.utils
|
||||
.getPrivateKey(connectionDetails.security.keyId)
|
||||
.then((e) => e.value),
|
||||
};
|
||||
|
||||
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' },
|
||||
security,
|
||||
onStatusChange: (status) => {
|
||||
console.log('SSH connection status', status);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user