feat: add keychains

This commit is contained in:
Khairul Hidayat 2024-11-09 18:57:36 +00:00
parent b50abccae0
commit 0a788b05e5
30 changed files with 699 additions and 102 deletions

View File

@ -16,6 +16,7 @@ export default function Layout() {
}} }}
> >
<Drawer.Screen name="hosts" options={{ title: "Hosts" }} /> <Drawer.Screen name="hosts" options={{ title: "Hosts" }} />
<Drawer.Screen name="keychains" options={{ title: "Keychains" }} />
<Drawer.Screen <Drawer.Screen
name="terminal" name="terminal"
options={{ title: "Terminal", headerShown: media.sm }} options={{ title: "Terminal", headerShown: media.sm }}

View File

@ -0,0 +1,3 @@
import KeychainsPage from "@/pages/keychains/page";
export default KeychainsPage;

View File

@ -26,10 +26,17 @@ export default function RootLayout() {
return ( return (
<Providers> <Providers>
<Stack> <Stack
screenOptions={{
headerShadowVisible: false,
}}
>
<Stack.Screen <Stack.Screen
name="index" name="index"
options={{ headerShown: false, title: "Loading..." }} options={{
headerShown: false,
title: "Loading...",
}}
/> />
<Stack.Screen name="(drawer)" options={{ headerShown: false }} /> <Stack.Screen name="(drawer)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" options={{ title: "Not Found" }} /> <Stack.Screen name="+not-found" options={{ title: "Not Found" }} />

View File

@ -36,20 +36,16 @@ const Providers = ({ children }: Props) => {
}, [theme, colorScheme]); }, [theme, colorScheme]);
return ( return (
<> <QueryClientProvider client={queryClient}>
<AuthProvider /> <AuthProvider />
<ThemeProvider value={navTheme}> <ThemeProvider value={navTheme}>
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}> <TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
<Theme name="blue"> <Theme name="blue">
<PortalProvider shouldAddRootHost> <PortalProvider shouldAddRootHost>{children}</PortalProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</PortalProvider>
</Theme> </Theme>
</TamaguiProvider> </TamaguiProvider>
</ThemeProvider> </ThemeProvider>
</> </QueryClientProvider>
); );
}; };

View File

@ -122,7 +122,6 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => {
} }
function onOpen() { function onOpen() {
console.log("WS Open");
resizeTerminal(); resizeTerminal();
} }

View File

@ -0,0 +1,53 @@
import React, { useMemo } from "react";
import { GetProps, ScrollView, View, ViewStyle } from "tamagui";
type GridItem = { key: string };
type GridViewProps<T extends GridItem> = GetProps<typeof ScrollView> & {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
columns: {
xs?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
};
};
const GridView = <T extends GridItem>({
data,
renderItem,
columns,
gap,
...props
}: GridViewProps<T>) => {
const basisProps = useMemo(() => {
const basis: ViewStyle = { flexBasis: "100%" };
if (columns.xs) basis.flexBasis = `${100 / columns.xs}%`;
if (columns.sm) basis.$gtXs = { flexBasis: `${100 / columns.sm}%` };
if (columns.md) basis.$gtSm = { flexBasis: `${100 / columns.md}%` };
if (columns.lg) basis.$gtMd = { flexBasis: `${100 / columns.lg}%` };
if (columns.xl) basis.$gtLg = { flexBasis: `${100 / columns.xl}%` };
return basis;
}, [columns]);
return (
<ScrollView
{...props}
contentContainerStyle={{
flexDirection: "row",
flexWrap: "wrap",
...(props.contentContainerStyle as object),
}}
>
{data?.map((item, idx) => (
<View key={item.key} p={gap} flexShrink={0} {...basisProps}>
{renderItem(item, idx)}
</View>
))}
</ScrollView>
);
};
export default GridView;

View File

@ -1,6 +1,6 @@
import { Controller, FieldValues } from "react-hook-form"; import { Controller, FieldValues } from "react-hook-form";
import { FormFieldBaseProps } from "./utility"; import { FormFieldBaseProps } from "./utility";
import { Input, View } from "tamagui"; import { Input, TextArea } from "tamagui";
import { ComponentPropsWithoutRef } from "react"; import { ComponentPropsWithoutRef } from "react";
import { ErrorMessage } from "./form"; import { ErrorMessage } from "./form";
@ -24,4 +24,24 @@ export const InputField = <T extends FieldValues>({
/> />
); );
type TextAreaFieldProps<T extends FieldValues> = FormFieldBaseProps<T> &
ComponentPropsWithoutRef<typeof TextArea>;
export const TextAreaField = <T extends FieldValues>({
form,
name,
...props
}: TextAreaFieldProps<T>) => (
<Controller
control={form.control}
name={name}
render={({ field, fieldState }) => (
<>
<TextArea {...field} {...props} />
<ErrorMessage error={fieldState.error} />
</>
)}
/>
);
export default Input; export default Input;

View File

@ -22,23 +22,24 @@ const Modal = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange} modal> <Dialog open={open} onOpenChange={onOpenChange} modal>
<Adapt when="sm"> <Adapt when="sm" platform="touch">
<Sheet <Sheet
animation="quick" animation="quick"
zIndex={999} zIndex={999}
modal modal
dismissOnSnapToBottom dismissOnSnapToBottom
disableDrag // disableDrag
> >
<Sheet.Frame>
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay <Sheet.Overlay
animation="quicker" opacity={0.1}
animation="quick"
enterStyle={{ opacity: 0 }} enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }} exitStyle={{ opacity: 0 }}
zIndex={0} zIndex={0}
/> />
<Sheet.Frame>
<Adapt.Contents />
</Sheet.Frame>
</Sheet> </Sheet>
</Adapt> </Adapt>

View File

@ -9,6 +9,7 @@ const StyledPressable = styled(Button, {
unstyled: true, unstyled: true,
backgroundColor: "$colorTransparent", backgroundColor: "$colorTransparent",
borderWidth: 0, borderWidth: 0,
padding: 0,
cursor: "pointer", cursor: "pointer",
}); });

View File

@ -1,6 +1,6 @@
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, FieldValues } from "react-hook-form";
import { Select as BaseSelect } from "tamagui"; import { Adapt, Select as BaseSelect, Sheet, Text } from "tamagui";
import { FormFieldBaseProps } from "./utility"; import { FormFieldBaseProps } from "./utility";
import { ErrorMessage } from "./form"; import { ErrorMessage } from "./form";
import Icons from "./icons"; import Icons from "./icons";
@ -42,6 +42,24 @@ const Select = forwardRef<SelectRef, SelectProps>(
<BaseSelect.Value placeholder={placeholder} /> <BaseSelect.Value placeholder={placeholder} />
</BaseSelect.Trigger> </BaseSelect.Trigger>
<Adapt when="sm" platform="touch">
<Sheet native modal dismissOnSnapToBottom snapPoints={[40, 60, 80]}>
<Sheet.Overlay
opacity={0.1}
animation="quick"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
zIndex={0}
/>
{/* <Sheet.Handle /> */}
<Sheet.Frame>
<Sheet.ScrollView contentContainerStyle={{ py: "$3" }}>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
</Sheet>
</Adapt>
<BaseSelect.Content> <BaseSelect.Content>
<BaseSelect.ScrollUpButton /> <BaseSelect.ScrollUpButton />
<BaseSelect.Viewport> <BaseSelect.Viewport>

View File

@ -1,14 +1,26 @@
import Icons from "@/components/ui/icons"; import Icons from "@/components/ui/icons";
import { keyFormModal } from "@/pages/keychains/components/form";
import { initialValues as keychainInitialValues } from "@/pages/keychains/schema/form";
import React from "react"; import React from "react";
import { Button, Label, XStack } from "tamagui"; import { Button, Label, XStack } from "tamagui";
export default function CredentialsSection() { type Props = {
type?: "user" | "rsa" | "pve" | "cert";
};
export default function CredentialsSection({ type = "user" }: Props) {
return ( return (
<XStack gap="$3"> <XStack gap="$3">
<Label flex={1} h="$3"> <Label flex={1} h="$3">
Credentials Credentials
</Label> </Label>
<Button size="$3" icon={<Icons size={16} name="plus" />}> <Button
size="$3"
icon={<Icons size={16} name="plus" />}
onPress={() =>
keyFormModal.onOpen({ ...keychainInitialValues, type } as never)
}
>
Add Add
</Button> </Button>
</XStack> </XStack>

View File

@ -0,0 +1,59 @@
import { View, Text, Button, Card, XStack } from "tamagui";
import React from "react";
import { MultiTapPressable } from "@/components/ui/pressable";
import Icons from "@/components/ui/icons";
import OSIcons from "@/components/ui/os-icons";
type HostItemProps = {
host: any;
onMultiTap: () => void;
onTap: () => void;
onEdit?: (() => void) | null;
};
const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => {
return (
<MultiTapPressable
cursor="pointer"
group
numberOfTaps={2}
onMultiTap={onMultiTap}
onTap={onTap}
>
<Card bordered p="$4">
<XStack>
<OSIcons
name={host.os}
size={18}
mr="$2"
fallback="desktop-classic"
/>
<View flex={1}>
<Text>{host.label}</Text>
<Text fontSize="$3" mt="$2">
{host.host}
</Text>
</View>
{onEdit != null && (
<Button
circular
display="none"
$sm={{ display: "block" }}
$group-hover={{ display: "block" }}
onPress={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Icons name="pencil" size={16} />
</Button>
)}
</XStack>
</Card>
</MultiTapPressable>
);
};
export default HostItem;

View File

@ -1,14 +1,13 @@
import { View, Text, Button, ScrollView, Spinner, Card, XStack } from "tamagui"; import { View, Text, Spinner } from "tamagui";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api"; import api from "@/lib/api";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { MultiTapPressable } from "@/components/ui/pressable";
import Icons from "@/components/ui/icons";
import SearchInput from "@/components/ui/search-input"; import SearchInput from "@/components/ui/search-input";
import { useTermSession } from "@/stores/terminal-sessions"; import { useTermSession } from "@/stores/terminal-sessions";
import { hostFormModal } from "./form"; import { hostFormModal } from "./form";
import OSIcons from "@/components/ui/os-icons"; import GridView from "@/components/ui/grid-view";
import HostItem from "./host-item";
type HostsListProps = { type HostsListProps = {
allowEdit?: boolean; allowEdit?: boolean;
@ -38,10 +37,10 @@ const HostsList = ({ allowEdit = true }: HostsListProps) => {
}); });
} }
return items; return items.map((i: any) => ({ ...i, key: i.id }));
}, [hosts.data, search]); }, [hosts.data, search]);
const onOpen = (host: any) => { const onEdit = (host: any) => {
if (!allowEdit) return; if (!allowEdit) return;
hostFormModal.onOpen(host); hostFormModal.onOpen(host);
}; };
@ -80,67 +79,23 @@ const HostsList = ({ allowEdit = true }: HostsListProps) => {
<Text mt="$4">Loading...</Text> <Text mt="$4">Loading...</Text>
</View> </View>
) : ( ) : (
<ScrollView <GridView
contentContainerStyle={{ data={hostsList}
padding: "$3", columns={{ sm: 2, lg: 3, xl: 4 }}
paddingTop: 0, contentContainerStyle={{ p: "$2", pt: 0 }}
flexDirection: "row", gap="$2.5"
flexWrap: "wrap", renderItem={(host: any) => (
}} <HostItem
> host={host}
{hostsList?.map((host: any) => ( onTap={() => {}}
<MultiTapPressable
key={host.id}
flexBasis="100%"
cursor="pointer"
$gtXs={{ flexBasis: "50%" }}
$gtMd={{ flexBasis: "33.3%" }}
$gtLg={{ flexBasis: "25%" }}
$gtXl={{ flexBasis: "20%" }}
p="$2"
group
numberOfTaps={2}
onMultiTap={() => onOpenTerminal(host)} onMultiTap={() => onOpenTerminal(host)}
onTap={() => onOpen(host)} onEdit={allowEdit ? () => onEdit(host) : null}
> />
<Card bordered p="$4"> )}
<XStack> />
<OSIcons
name={host.os}
size={18}
mr="$2"
fallback="desktop-classic"
/>
<View flex={1}>
<Text>{host.label}</Text>
<Text fontSize="$3" mt="$2">
{host.host}
</Text>
</View>
{allowEdit && (
<Button
circular
display="none"
$sm={{ display: "block" }}
$group-hover={{ display: "block" }}
onPress={(e) => {
e.stopPropagation();
onOpen(host);
}}
>
<Icons name="pencil" size={16} />
</Button>
)}
</XStack>
</Card>
</MultiTapPressable>
))}
</ScrollView>
)} )}
</> </>
); );
}; };
export default HostsList; export default React.memo(HostsList);

View File

@ -42,7 +42,7 @@ export const IncusFormFields = ({ form }: MiscFormFieldProps) => {
</> </>
)} )}
<CredentialsSection /> <CredentialsSection type="cert" />
<FormField label="Client Certificate"> <FormField label="Client Certificate">
<SelectField <SelectField

View File

@ -31,7 +31,7 @@ export const PVEFormFields = ({ form }: MiscFormFieldProps) => {
/> />
</FormField> </FormField>
<CredentialsSection /> <CredentialsSection type="pve" />
<FormField label="Account"> <FormField label="Account">
<SelectField <SelectField

View File

@ -2,18 +2,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { FormSchema } from "../schema/form"; import { FormSchema } from "../schema/form";
import api, { queryClient } from "@/lib/api"; import api, { queryClient } from "@/lib/api";
import { useMemo } from "react"; import { useMemo } from "react";
import { useKeychains } from "@/pages/keychains/hooks/query";
export const useKeychains = () => {
return useQuery({
queryKey: ["keychains"],
queryFn: () => api("/keychains"),
select: (i) => i.rows,
});
};
export const useKeychainsOptions = () => { export const useKeychainsOptions = () => {
const keys = useKeychains(); const keys = useKeychains();
const data = useMemo(() => { const data = useMemo(() => {
const items: any[] = keys.data || []; const items: any[] = keys.data || [];

View File

@ -5,6 +5,7 @@ import HostsList from "./components/hosts-list";
import HostForm, { hostFormModal } from "./components/form"; import HostForm, { hostFormModal } from "./components/form";
import Icons from "@/components/ui/icons"; import Icons from "@/components/ui/icons";
import { initialValues } from "./schema/form"; import { initialValues } from "./schema/form";
import KeyForm from "../keychains/components/form";
export default function HostsPage() { export default function HostsPage() {
return ( return (
@ -26,6 +27,7 @@ export default function HostsPage() {
<HostsList /> <HostsList />
<HostForm /> <HostForm />
<KeyForm />
</> </>
); );
} }

View File

@ -0,0 +1,87 @@
import Icons from "@/components/ui/icons";
import Modal from "@/components/ui/modal";
import { SelectField } from "@/components/ui/select";
import { useZForm } from "@/hooks/useZForm";
import { createDisclosure } from "@/lib/utils";
import React from "react";
import { ScrollView, Sheet, XStack } from "tamagui";
import { FormSchema, formSchema, typeOptions } from "../schema/form";
import { InputField } from "@/components/ui/input";
import FormField from "@/components/ui/form";
import { useSaveKeychain } from "../hooks/query";
import { ErrorAlert } from "@/components/ui/alert";
import Button from "@/components/ui/button";
import {
UserTypeInputFields,
PVETypeInputFields,
RSATypeInputFields,
CertTypeInputFields,
} from "./input-fields";
export const keyFormModal = createDisclosure<FormSchema>();
const KeyForm = () => {
const { data } = keyFormModal.use();
const form = useZForm(formSchema, data);
const isEditing = data?.id != null;
const type = form.watch("type");
const saveMutation = useSaveKeychain();
const onSubmit = form.handleSubmit((values) => {
saveMutation.mutate(values, {
onSuccess: () => {
keyFormModal.onClose();
form.reset();
},
});
});
return (
<Modal
disclosure={keyFormModal}
title="Keychain"
description={`${isEditing ? "Edit" : "Add new"} key.`}
>
<ErrorAlert mx="$4" mb="$4" error={saveMutation.error} />
<Sheet.ScrollView
contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}
>
<FormField label="Label">
<InputField f={1} form={form} name="label" placeholder="Label..." />
</FormField>
<FormField label="Type">
<SelectField form={form} name="type" items={typeOptions} />
</FormField>
{type === "user" ? (
<UserTypeInputFields form={form} />
) : type === "pve" ? (
<PVETypeInputFields form={form} />
) : type === "rsa" ? (
<RSATypeInputFields form={form} />
) : type === "cert" ? (
<CertTypeInputFields form={form} />
) : null}
</Sheet.ScrollView>
<XStack p="$4" gap="$4">
<Button flex={1} onPress={keyFormModal.onClose} bg="$colorTransparent">
Cancel
</Button>
<Button
flex={1}
icon={<Icons name="content-save" size={18} />}
onPress={onSubmit}
isLoading={saveMutation.isPending}
>
Save
</Button>
</XStack>
</Modal>
);
};
export default KeyForm;

View File

@ -0,0 +1,68 @@
import React, { useMemo } from "react";
import { UseFormReturn } from "react-hook-form";
import { FormSchema, pveRealms } from "../schema/form";
import FormField from "@/components/ui/form";
import { InputField, TextAreaField } from "@/components/ui/input";
import { SelectField } from "@/components/ui/select";
type Props = {
form: UseFormReturn<FormSchema>;
};
export const UserTypeInputFields = ({ form }: Props) => {
return (
<>
<FormField label="Username">
<InputField f={1} form={form} name="data.username" />
</FormField>
<FormField label="Password">
<InputField f={1} form={form} name="data.password" />
</FormField>
</>
);
};
export const PVETypeInputFields = ({ form }: Props) => {
return (
<>
<FormField label="Username">
<InputField f={1} form={form} name="data.username" />
</FormField>
<FormField label="Realm">
<SelectField form={form} name="data.realm" items={pveRealms} />
</FormField>
<FormField label="Password">
<InputField f={1} form={form} name="data.password" />
</FormField>
</>
);
};
export const RSATypeInputFields = ({ form }: Props) => {
return (
<>
{/* <FormField label="Public Key">
<TextAreaField rows={7} f={1} form={form} name="data.public" />
</FormField> */}
<FormField label="Private Key">
<TextAreaField rows={7} f={1} form={form} name="data.private" />
</FormField>
<FormField label="Passphrase">
<InputField f={1} form={form} name="data.passphrase" />
</FormField>
</>
);
};
export const CertTypeInputFields = ({ form }: Props) => {
return (
<>
<FormField label="Client Certificate">
<TextAreaField rows={7} f={1} form={form} name="data.cert" />
</FormField>
<FormField label="Client Key">
<TextAreaField rows={7} f={1} form={form} name="data.key" />
</FormField>
</>
);
};

View File

@ -0,0 +1,56 @@
import { View, Text, Button, Card, XStack } from "tamagui";
import React from "react";
import Pressable from "@/components/ui/pressable";
import Icons from "@/components/ui/icons";
type KeyItemProps = {
data: any;
onPress?: () => void;
};
const icons: Record<string, string> = {
user: "account",
pve: "account-key",
rsa: "key",
cert: "certificate",
};
const KeyItem = ({ data, onPress }: KeyItemProps) => {
return (
<Pressable group onPress={onPress}>
<Card bordered px="$4" py="$3">
<XStack alignItems="center">
<Icons
name={(icons[data.type] || "key") as never}
size={20}
mr="$3"
/>
<View flex={1}>
<Text textAlign="left">{data.label}</Text>
<Text textAlign="left" fontSize="$3" mt="$1">
{data.type}
</Text>
</View>
<Button
circular
opacity={0}
$sm={{ opacity: 1 }}
animation="quickest"
animateOnly={["opacity"]}
$group-hover={{ opacity: 1 }}
onPress={(e) => {
e.stopPropagation();
onPress?.();
}}
>
<Icons name="pencil" size={16} />
</Button>
</XStack>
</Card>
</Pressable>
);
};
export default KeyItem;

View File

@ -0,0 +1,60 @@
import { View, Text, Spinner } from "tamagui";
import React, { useMemo, useState } from "react";
import SearchInput from "@/components/ui/search-input";
import GridView from "@/components/ui/grid-view";
import { useKeychains } from "../hooks/query";
import KeyItem from "./key-item";
import { keyFormModal } from "./form";
const KeyList = () => {
const [search, setSearch] = useState("");
const keys = useKeychains({ withData: true });
const keyList = useMemo(() => {
let items = keys.data || [];
if (search) {
items = items.filter((item: any) => {
const q = search.toLowerCase();
return item.label.toLowerCase().includes(q);
});
}
return items.map((i: any) => ({ ...i, key: i.id }));
}, [keys.data, search]);
const onEdit = (item: any) => {
keyFormModal.onOpen(item);
};
return (
<>
<View p="$4" pb="$3">
<SearchInput
placeholder="Search key..."
value={search}
onChangeText={setSearch}
/>
</View>
{keys.isLoading ? (
<View alignItems="center" justifyContent="center" flex={1}>
<Spinner size="large" />
<Text mt="$4">Loading...</Text>
</View>
) : (
<GridView
data={keyList}
columns={{ sm: 2, lg: 3, xl: 4 }}
contentContainerStyle={{ p: "$2", pt: 0 }}
gap="$2.5"
renderItem={(item: any) => (
<KeyItem data={item} onPress={() => onEdit(item)} />
)}
/>
)}
</>
);
};
export default React.memo(KeyList);

View File

@ -0,0 +1,25 @@
import api, { queryClient } from "@/lib/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FormSchema } from "../schema/form";
export const useKeychains = (query?: any) => {
return useQuery({
queryKey: ["keychains", query],
queryFn: () => api("/keychains", { query }),
select: (i) => i.rows,
});
};
export const useSaveKeychain = () => {
return useMutation({
mutationFn: async (body: FormSchema) => {
return body.id
? api(`/keychains/${body.id}`, { method: "PUT", body })
: api(`/keychains`, { method: "POST", body });
},
onError: (e) => console.error(e),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keychains"] });
},
});
};

View File

@ -0,0 +1,31 @@
import React from "react";
import KeyList from "./components/key-list";
import KeyForm, { keyFormModal } from "./components/form";
import Drawer from "expo-router/drawer";
import { Button } from "tamagui";
import Icons from "@/components/ui/icons";
import { initialValues } from "./schema/form";
export default function KeychainsPage() {
return (
<>
<Drawer.Screen
options={{
headerRight: () => (
<Button
bg="$colorTransparent"
icon={<Icons name="plus" size={24} />}
onPress={() => keyFormModal.onOpen(initialValues)}
$gtSm={{ mr: "$3" }}
>
New
</Button>
),
}}
/>
<KeyList />
<KeyForm />
</>
);
}

View File

@ -0,0 +1,79 @@
import { SelectItem } from "@/components/ui/select";
import { z } from "zod";
const baseSchema = z.object({
id: z.string().ulid().nullish(),
label: z.string().min(1, { message: "Label is required" }),
});
const userTypeSchema = baseSchema.merge(
z.object({
type: z.literal("user"),
data: z.object({
username: z.string().min(1, { message: "Username is required" }),
password: z.string().nullish(),
}),
})
);
const pveTypeSchema = baseSchema.merge(
z.object({
type: z.literal("pve"),
data: z.object({
username: z.string().min(1, { message: "Username is required" }),
realm: z.enum(["pam", "pve"]),
password: z.string().min(1, { message: "Password is required" }),
}),
})
);
const rsaTypeSchema = baseSchema.merge(
z.object({
type: z.literal("rsa"),
data: z.object({
public: z.string().nullish(),
private: z.string().min(1, { message: "Private Key is required" }),
passphrase: z.string().nullish(),
}),
})
);
const certTypeSchema = baseSchema.merge(
z.object({
type: z.literal("cert"),
data: z.object({
cert: z.string().min(1, { message: "Certificate is required" }),
key: z.string().min(1, { message: "Key is required" }),
}),
})
);
export const formSchema = z.discriminatedUnion("type", [
userTypeSchema,
pveTypeSchema,
rsaTypeSchema,
certTypeSchema,
]);
export type FormSchema = z.infer<typeof formSchema>;
export const initialValues: FormSchema = {
type: "user",
label: "",
data: {
username: "",
password: "",
},
};
export const typeOptions: SelectItem[] = [
{ label: "User Key", value: "user" },
{ label: "ProxmoxVE Key", value: "pve" },
{ label: "RSA Key", value: "rsa" },
{ label: "Client Certificate", value: "cert" },
];
export const pveRealms: SelectItem[] = [
{ label: "Linux PAM", value: "pam" },
{ label: "Proxmox VE", value: "pve" },
];

View File

@ -49,6 +49,6 @@ func (r *Hosts) Create(item *models.Host) error {
return r.db.Create(item).Error return r.db.Create(item).Error
} }
func (r *Hosts) Update(item *models.Host) error { func (r *Hosts) Update(id string, item *models.Host) error {
return r.db.Save(item).Error return r.db.Where("id = ?", id).Updates(item).Error
} }

View File

@ -93,7 +93,7 @@ func update(c *fiber.Ctx) error {
} }
item.OS = osName item.OS = osName
if err := repo.Update(item); err != nil { if err := repo.Update(id, item); err != nil {
return utils.ResponseError(c, err, 500) return utils.ResponseError(c, err, 500)
} }

View File

@ -32,6 +32,12 @@ func (r *Keychains) Get(id string) (*models.Keychain, error) {
return &keychain, nil return &keychain, nil
} }
func (r *Keychains) Exists(id string) (bool, error) {
var count int64
ret := r.db.Model(&models.Keychain{}).Where("id = ?", id).Count(&count)
return count > 0, ret.Error
}
type KeychainDecrypted struct { type KeychainDecrypted struct {
models.Keychain models.Keychain
Data map[string]interface{} Data map[string]interface{}
@ -50,3 +56,7 @@ func (r *Keychains) GetDecrypted(id string) (*KeychainDecrypted, error) {
return &KeychainDecrypted{Keychain: *keychain, Data: data}, nil return &KeychainDecrypted{Keychain: *keychain, Data: data}, nil
} }
func (r *Keychains) Update(id string, item *models.Keychain) error {
return r.db.Where("id = ?", id).Updates(item).Error
}

View File

@ -1,6 +1,7 @@
package keychains package keychains
import ( import (
"fmt"
"net/http" "net/http"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -13,18 +14,46 @@ func Router(app *fiber.App) {
router.Get("/", getAll) router.Get("/", getAll)
router.Post("/", create) router.Post("/", create)
router.Put("/:id", update)
}
type GetAllResult struct {
*models.Keychain
Data map[string]interface{} `json:"data"`
} }
func getAll(c *fiber.Ctx) error { func getAll(c *fiber.Ctx) error {
withData := c.Query("withData")
repo := NewRepository() repo := NewRepository()
rows, err := repo.GetAll() rows, err := repo.GetAll()
if err != nil { if err != nil {
return utils.ResponseError(c, err, 500) return utils.ResponseError(c, err, 500)
} }
return c.JSON(fiber.Map{ if withData != "true" {
"rows": rows, return c.JSON(fiber.Map{"rows": rows})
}) }
res := make([]*GetAllResult, len(rows))
doneCh := make(chan struct{})
// Decrypt data
for i, item := range rows {
go func(i int, item *models.Keychain) {
var data map[string]interface{}
item.DecryptData(&data)
res[i] = &GetAllResult{item, data}
doneCh <- struct{}{}
}(i, item)
}
for range rows {
<-doneCh
}
return c.JSON(fiber.Map{"rows": res})
} }
func create(c *fiber.Ctx) error { func create(c *fiber.Ctx) error {
@ -50,3 +79,33 @@ func create(c *fiber.Ctx) error {
return c.Status(http.StatusCreated).JSON(item) return c.Status(http.StatusCreated).JSON(item)
} }
func update(c *fiber.Ctx) error {
var body CreateKeychainSchema
if err := c.BodyParser(&body); err != nil {
return utils.ResponseError(c, err, 500)
}
repo := NewRepository()
id := c.Params("id")
exist, _ := repo.Exists(id)
if !exist {
return utils.ResponseError(c, fmt.Errorf("key %s not found", id), 404)
}
item := &models.Keychain{
Type: body.Type,
Label: body.Label,
}
if err := item.EncryptData(body.Data); err != nil {
return utils.ResponseError(c, err, 500)
}
if err := repo.Update(id, item); err != nil {
return utils.ResponseError(c, err, 500)
}
return c.JSON(item)
}

View File

@ -50,12 +50,14 @@ func sshHandler(c *websocket.Conn, data *models.HostDecrypted) {
func pveHandler(c *websocket.Conn, data *models.HostDecrypted) { func pveHandler(c *websocket.Conn, data *models.HostDecrypted) {
client := c.Query("client") client := c.Query("client")
username, _ := data.Key["username"].(string) username, _ := data.Key["username"].(string)
realm, _ := data.Key["realm"].(string)
password, _ := data.Key["password"].(string) password, _ := data.Key["password"].(string)
pve := &lib.PVEServer{ pve := &lib.PVEServer{
HostName: data.Host.Host, HostName: data.Host.Host,
Port: data.Port, Port: data.Port,
Username: username, Username: username,
Realm: realm,
Password: password, Password: password,
} }

View File

@ -13,6 +13,7 @@ type PVEServer struct {
HostName string HostName string
Port int Port int
Username string Username string
Realm string
Password string Password string
} }
@ -72,7 +73,7 @@ func (pve *PVEServer) GetAccessTicket() (*PVEAccessTicket, error) {
// note for myself: don't forget the realm // note for myself: don't forget the realm
body, err := fetch("POST", url, &PVERequestInit{Body: map[string]string{ body, err := fetch("POST", url, &PVERequestInit{Body: map[string]string{
"username": pve.Username, "username": fmt.Sprintf("%s@%s", pve.Username, pve.Realm),
"password": pve.Password, "password": pve.Password,
}}) }})
if err != nil { if err != nil {