feat: add keychains

This commit is contained in:
2024-11-09 18:57:36 +00:00
parent b50abccae0
commit 0a788b05e5
30 changed files with 699 additions and 102 deletions
@@ -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;
@@ -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>
</>
);
};
@@ -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;
@@ -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
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"] });
},
});
};
+31
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 />
</>
);
}
+79
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" },
];