mirror of
				https://github.com/khairul169/vaulterm.git
				synced 2025-11-04 05:31:11 +07:00 
			
		
		
		
	feat: add keychains
This commit is contained in:
		
							parent
							
								
									b50abccae0
								
							
						
					
					
						commit
						0a788b05e5
					
				@ -16,6 +16,7 @@ export default function Layout() {
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <Drawer.Screen name="hosts" options={{ title: "Hosts" }} />
 | 
			
		||||
        <Drawer.Screen name="keychains" options={{ title: "Keychains" }} />
 | 
			
		||||
        <Drawer.Screen
 | 
			
		||||
          name="terminal"
 | 
			
		||||
          options={{ title: "Terminal", headerShown: media.sm }}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								frontend/app/(drawer)/keychains.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								frontend/app/(drawer)/keychains.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
import KeychainsPage from "@/pages/keychains/page";
 | 
			
		||||
 | 
			
		||||
export default KeychainsPage;
 | 
			
		||||
@ -26,10 +26,17 @@ export default function RootLayout() {
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Providers>
 | 
			
		||||
      <Stack>
 | 
			
		||||
      <Stack
 | 
			
		||||
        screenOptions={{
 | 
			
		||||
          headerShadowVisible: false,
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <Stack.Screen
 | 
			
		||||
          name="index"
 | 
			
		||||
          options={{ headerShown: false, title: "Loading..." }}
 | 
			
		||||
          options={{
 | 
			
		||||
            headerShown: false,
 | 
			
		||||
            title: "Loading...",
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <Stack.Screen name="(drawer)" options={{ headerShown: false }} />
 | 
			
		||||
        <Stack.Screen name="+not-found" options={{ title: "Not Found" }} />
 | 
			
		||||
 | 
			
		||||
@ -36,20 +36,16 @@ const Providers = ({ children }: Props) => {
 | 
			
		||||
  }, [theme, colorScheme]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
    <QueryClientProvider client={queryClient}>
 | 
			
		||||
      <AuthProvider />
 | 
			
		||||
      <ThemeProvider value={navTheme}>
 | 
			
		||||
        <TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
 | 
			
		||||
          <Theme name="blue">
 | 
			
		||||
            <PortalProvider shouldAddRootHost>
 | 
			
		||||
              <QueryClientProvider client={queryClient}>
 | 
			
		||||
                {children}
 | 
			
		||||
              </QueryClientProvider>
 | 
			
		||||
            </PortalProvider>
 | 
			
		||||
            <PortalProvider shouldAddRootHost>{children}</PortalProvider>
 | 
			
		||||
          </Theme>
 | 
			
		||||
        </TamaguiProvider>
 | 
			
		||||
      </ThemeProvider>
 | 
			
		||||
    </>
 | 
			
		||||
    </QueryClientProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -122,7 +122,6 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function onOpen() {
 | 
			
		||||
      console.log("WS Open");
 | 
			
		||||
      resizeTerminal();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										53
									
								
								frontend/components/ui/grid-view.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								frontend/components/ui/grid-view.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { Controller, FieldValues } from "react-hook-form";
 | 
			
		||||
import { FormFieldBaseProps } from "./utility";
 | 
			
		||||
import { Input, View } from "tamagui";
 | 
			
		||||
import { Input, TextArea } from "tamagui";
 | 
			
		||||
import { ComponentPropsWithoutRef } from "react";
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
@ -22,23 +22,24 @@ const Modal = ({
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={open} onOpenChange={onOpenChange} modal>
 | 
			
		||||
      <Adapt when="sm">
 | 
			
		||||
      <Adapt when="sm" platform="touch">
 | 
			
		||||
        <Sheet
 | 
			
		||||
          animation="quick"
 | 
			
		||||
          zIndex={999}
 | 
			
		||||
          modal
 | 
			
		||||
          dismissOnSnapToBottom
 | 
			
		||||
          disableDrag
 | 
			
		||||
          // disableDrag
 | 
			
		||||
        >
 | 
			
		||||
          <Sheet.Frame>
 | 
			
		||||
            <Adapt.Contents />
 | 
			
		||||
          </Sheet.Frame>
 | 
			
		||||
          <Sheet.Overlay
 | 
			
		||||
            animation="quicker"
 | 
			
		||||
            opacity={0.1}
 | 
			
		||||
            animation="quick"
 | 
			
		||||
            enterStyle={{ opacity: 0 }}
 | 
			
		||||
            exitStyle={{ opacity: 0 }}
 | 
			
		||||
            zIndex={0}
 | 
			
		||||
          />
 | 
			
		||||
          <Sheet.Frame>
 | 
			
		||||
            <Adapt.Contents />
 | 
			
		||||
          </Sheet.Frame>
 | 
			
		||||
        </Sheet>
 | 
			
		||||
      </Adapt>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,7 @@ const StyledPressable = styled(Button, {
 | 
			
		||||
  unstyled: true,
 | 
			
		||||
  backgroundColor: "$colorTransparent",
 | 
			
		||||
  borderWidth: 0,
 | 
			
		||||
  padding: 0,
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import React, { forwardRef } from "react";
 | 
			
		||||
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 { ErrorMessage } from "./form";
 | 
			
		||||
import Icons from "./icons";
 | 
			
		||||
@ -42,6 +42,24 @@ const Select = forwardRef<SelectRef, SelectProps>(
 | 
			
		||||
          <BaseSelect.Value placeholder={placeholder} />
 | 
			
		||||
        </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.ScrollUpButton />
 | 
			
		||||
          <BaseSelect.Viewport>
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,26 @@
 | 
			
		||||
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 { Button, Label, XStack } from "tamagui";
 | 
			
		||||
 | 
			
		||||
export default function CredentialsSection() {
 | 
			
		||||
type Props = {
 | 
			
		||||
  type?: "user" | "rsa" | "pve" | "cert";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function CredentialsSection({ type = "user" }: Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <XStack gap="$3">
 | 
			
		||||
      <Label flex={1} h="$3">
 | 
			
		||||
        Credentials
 | 
			
		||||
      </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
 | 
			
		||||
      </Button>
 | 
			
		||||
    </XStack>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										59
									
								
								frontend/pages/hosts/components/host-item.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/pages/hosts/components/host-item.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
@ -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 { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import api from "@/lib/api";
 | 
			
		||||
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 { useTermSession } from "@/stores/terminal-sessions";
 | 
			
		||||
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 = {
 | 
			
		||||
  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]);
 | 
			
		||||
 | 
			
		||||
  const onOpen = (host: any) => {
 | 
			
		||||
  const onEdit = (host: any) => {
 | 
			
		||||
    if (!allowEdit) return;
 | 
			
		||||
    hostFormModal.onOpen(host);
 | 
			
		||||
  };
 | 
			
		||||
@ -80,67 +79,23 @@ const HostsList = ({ allowEdit = true }: HostsListProps) => {
 | 
			
		||||
          <Text mt="$4">Loading...</Text>
 | 
			
		||||
        </View>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <ScrollView
 | 
			
		||||
          contentContainerStyle={{
 | 
			
		||||
            padding: "$3",
 | 
			
		||||
            paddingTop: 0,
 | 
			
		||||
            flexDirection: "row",
 | 
			
		||||
            flexWrap: "wrap",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {hostsList?.map((host: any) => (
 | 
			
		||||
            <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}
 | 
			
		||||
        <GridView
 | 
			
		||||
          data={hostsList}
 | 
			
		||||
          columns={{ sm: 2, lg: 3, xl: 4 }}
 | 
			
		||||
          contentContainerStyle={{ p: "$2", pt: 0 }}
 | 
			
		||||
          gap="$2.5"
 | 
			
		||||
          renderItem={(host: any) => (
 | 
			
		||||
            <HostItem
 | 
			
		||||
              host={host}
 | 
			
		||||
              onTap={() => {}}
 | 
			
		||||
              onMultiTap={() => onOpenTerminal(host)}
 | 
			
		||||
              onTap={() => onOpen(host)}
 | 
			
		||||
            >
 | 
			
		||||
              <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>
 | 
			
		||||
              onEdit={allowEdit ? () => onEdit(host) : null}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default HostsList;
 | 
			
		||||
export default React.memo(HostsList);
 | 
			
		||||
 | 
			
		||||
@ -42,7 +42,7 @@ export const IncusFormFields = ({ form }: MiscFormFieldProps) => {
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <CredentialsSection />
 | 
			
		||||
      <CredentialsSection type="cert" />
 | 
			
		||||
 | 
			
		||||
      <FormField label="Client Certificate">
 | 
			
		||||
        <SelectField
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,7 @@ export const PVEFormFields = ({ form }: MiscFormFieldProps) => {
 | 
			
		||||
        />
 | 
			
		||||
      </FormField>
 | 
			
		||||
 | 
			
		||||
      <CredentialsSection />
 | 
			
		||||
      <CredentialsSection type="pve" />
 | 
			
		||||
 | 
			
		||||
      <FormField label="Account">
 | 
			
		||||
        <SelectField
 | 
			
		||||
 | 
			
		||||
@ -2,18 +2,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { FormSchema } from "../schema/form";
 | 
			
		||||
import api, { queryClient } from "@/lib/api";
 | 
			
		||||
import { useMemo } from "react";
 | 
			
		||||
 | 
			
		||||
export const useKeychains = () => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["keychains"],
 | 
			
		||||
    queryFn: () => api("/keychains"),
 | 
			
		||||
    select: (i) => i.rows,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
import { useKeychains } from "@/pages/keychains/hooks/query";
 | 
			
		||||
 | 
			
		||||
export const useKeychainsOptions = () => {
 | 
			
		||||
  const keys = useKeychains();
 | 
			
		||||
 | 
			
		||||
  const data = useMemo(() => {
 | 
			
		||||
    const items: any[] = keys.data || [];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ import HostsList from "./components/hosts-list";
 | 
			
		||||
import HostForm, { hostFormModal } from "./components/form";
 | 
			
		||||
import Icons from "@/components/ui/icons";
 | 
			
		||||
import { initialValues } from "./schema/form";
 | 
			
		||||
import KeyForm from "../keychains/components/form";
 | 
			
		||||
 | 
			
		||||
export default function HostsPage() {
 | 
			
		||||
  return (
 | 
			
		||||
@ -26,6 +27,7 @@ export default function HostsPage() {
 | 
			
		||||
 | 
			
		||||
      <HostsList />
 | 
			
		||||
      <HostForm />
 | 
			
		||||
      <KeyForm />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										87
									
								
								frontend/pages/keychains/components/form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								frontend/pages/keychains/components/form.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										68
									
								
								frontend/pages/keychains/components/input-fields.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								frontend/pages/keychains/components/input-fields.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										56
									
								
								frontend/pages/keychains/components/key-item.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								frontend/pages/keychains/components/key-item.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										60
									
								
								frontend/pages/keychains/components/key-list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								frontend/pages/keychains/components/key-list.tsx
									
									
									
									
									
										Normal 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);
 | 
			
		||||
							
								
								
									
										25
									
								
								frontend/pages/keychains/hooks/query.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/pages/keychains/hooks/query.ts
									
									
									
									
									
										Normal 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"] });
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										31
									
								
								frontend/pages/keychains/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								frontend/pages/keychains/page.tsx
									
									
									
									
									
										Normal 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 />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										79
									
								
								frontend/pages/keychains/schema/form.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								frontend/pages/keychains/schema/form.ts
									
									
									
									
									
										Normal 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" },
 | 
			
		||||
];
 | 
			
		||||
@ -49,6 +49,6 @@ func (r *Hosts) Create(item *models.Host) error {
 | 
			
		||||
	return r.db.Create(item).Error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r *Hosts) Update(item *models.Host) error {
 | 
			
		||||
	return r.db.Save(item).Error
 | 
			
		||||
func (r *Hosts) Update(id string, item *models.Host) error {
 | 
			
		||||
	return r.db.Where("id = ?", id).Updates(item).Error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -93,7 +93,7 @@ func update(c *fiber.Ctx) error {
 | 
			
		||||
	}
 | 
			
		||||
	item.OS = osName
 | 
			
		||||
 | 
			
		||||
	if err := repo.Update(item); err != nil {
 | 
			
		||||
	if err := repo.Update(id, item); err != nil {
 | 
			
		||||
		return utils.ResponseError(c, err, 500)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -32,6 +32,12 @@ func (r *Keychains) Get(id string) (*models.Keychain, error) {
 | 
			
		||||
	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 {
 | 
			
		||||
	models.Keychain
 | 
			
		||||
	Data map[string]interface{}
 | 
			
		||||
@ -50,3 +56,7 @@ func (r *Keychains) GetDecrypted(id string) (*KeychainDecrypted, error) {
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
package keychains
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/gofiber/fiber/v2"
 | 
			
		||||
@ -13,18 +14,46 @@ func Router(app *fiber.App) {
 | 
			
		||||
 | 
			
		||||
	router.Get("/", getAll)
 | 
			
		||||
	router.Post("/", create)
 | 
			
		||||
	router.Put("/:id", update)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GetAllResult struct {
 | 
			
		||||
	*models.Keychain
 | 
			
		||||
	Data map[string]interface{} `json:"data"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getAll(c *fiber.Ctx) error {
 | 
			
		||||
	withData := c.Query("withData")
 | 
			
		||||
 | 
			
		||||
	repo := NewRepository()
 | 
			
		||||
	rows, err := repo.GetAll()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return utils.ResponseError(c, err, 500)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return c.JSON(fiber.Map{
 | 
			
		||||
		"rows": rows,
 | 
			
		||||
	})
 | 
			
		||||
	if withData != "true" {
 | 
			
		||||
		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 {
 | 
			
		||||
@ -50,3 +79,33 @@ func create(c *fiber.Ctx) error {
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -50,12 +50,14 @@ func sshHandler(c *websocket.Conn, data *models.HostDecrypted) {
 | 
			
		||||
func pveHandler(c *websocket.Conn, data *models.HostDecrypted) {
 | 
			
		||||
	client := c.Query("client")
 | 
			
		||||
	username, _ := data.Key["username"].(string)
 | 
			
		||||
	realm, _ := data.Key["realm"].(string)
 | 
			
		||||
	password, _ := data.Key["password"].(string)
 | 
			
		||||
 | 
			
		||||
	pve := &lib.PVEServer{
 | 
			
		||||
		HostName: data.Host.Host,
 | 
			
		||||
		Port:     data.Port,
 | 
			
		||||
		Username: username,
 | 
			
		||||
		Realm:    realm,
 | 
			
		||||
		Password: password,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ type PVEServer struct {
 | 
			
		||||
	HostName string
 | 
			
		||||
	Port     int
 | 
			
		||||
	Username string
 | 
			
		||||
	Realm    string
 | 
			
		||||
	Password string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -72,7 +73,7 @@ func (pve *PVEServer) GetAccessTicket() (*PVEAccessTicket, error) {
 | 
			
		||||
 | 
			
		||||
	// note for myself: don't forget the realm
 | 
			
		||||
	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,
 | 
			
		||||
	}})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user