Begin form abstraction (#515)

This commit is contained in:
Brendan Allan
2023-01-12 07:26:59 -08:00
committed by GitHub
parent 9966d8eb65
commit ae94ada4f8
35 changed files with 1049 additions and 963 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -12,19 +12,19 @@ members = [
]
[workspace.dependencies]
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "1e30449d2981cbe0bb9252511493474eb35b2696", features = [
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", tag = "0.6.4", features = [
"rspc",
"sqlite-create-many",
"migrations",
"sqlite",
], default-features = false }
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "1e30449d2981cbe0bb9252511493474eb35b2696", features = [
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust.git", tag = "0.6.4", features = [
"rspc",
"sqlite-create-many",
"migrations",
"sqlite",
], default-features = false }
prisma-client-rust-sdk = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "1e30449d2981cbe0bb9252511493474eb35b2696", features = [
prisma-client-rust-sdk = { git = "https://github.com/Brendonovich/prisma-client-rust.git", tag = "0.6.4", features = [
"sqlite",
], default-features = false }

View File

@@ -28,7 +28,6 @@
"@types/babel-core": "^6.25.7",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/tailwindcss": "^3.1.0",
"@vitejs/plugin-react": "^2.1.0",
"prettier": "^2.7.1",
"sass": "^1.55.0",

View File

@@ -4,11 +4,11 @@ mod crdt;
pub use crdt::*;
// pub use db::*;
use prisma_client_rust::ModelActions;
use prisma_client_rust::ModelTypes;
use serde_value::Value;
use std::collections::BTreeMap;
pub trait CreateCRDTMutation<T: ModelActions> {
pub trait CreateCRDTMutation<T: ModelTypes> {
fn operation_from_data(
d: &BTreeMap<String, Value>,
typ: CreateOperationType,
@@ -22,7 +22,7 @@ pub enum CreateOperationType {
Relation,
}
impl<T: ModelActions> CreateCRDTMutation<T> for prisma_client_rust::Create<'_, T> {
impl<T: ModelTypes> CreateCRDTMutation<T> for prisma_client_rust::Create<'_, T> {
fn operation_from_data(
_: &BTreeMap<String, Value>,
typ: CreateOperationType,

View File

@@ -20,6 +20,7 @@
"dependencies": {
"@fontsource/inter": "^4.5.13",
"@headlessui/react": "^1.7.3",
"@hookform/resolvers": "^2.9.10",
"@loadable/component": "^5.15.2",
"@radix-ui/react-progress": "^1.0.1",
"@radix-ui/react-slider": "^1.1.0",
@@ -41,6 +42,7 @@
"@zxcvbn-ts/language-en": "^2.1.0",
"autoprefixer": "^10.4.12",
"byte-size": "^8.1.0",
"class-variance-authority": "^0.4.0",
"clsx": "^1.2.1",
"crypto-random-string": "^5.0.0",
"dayjs": "^1.11.5",
@@ -58,7 +60,8 @@
"tailwindcss": "^3.1.8",
"use-count-up": "^3.0.1",
"use-debounce": "^8.0.4",
"valtio": "^1.7.4"
"valtio": "^1.7.4",
"zod": "^3.20.2"
},
"devDependencies": {
"@sd/config": "workspace:*",
@@ -69,7 +72,6 @@
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/react-router-dom": "^5.3.3",
"@types/tailwindcss": "^3.1.0",
"@vitejs/plugin-react": "^1.3.1",
"prettier": "^2.7.1",
"typescript": "^4.8.4",

View File

@@ -1,47 +1,52 @@
import { LocationCreateArgs, useBridgeMutation, useLibraryMutation } from '@sd/client';
import { useLibraryMutation } from '@sd/client';
import { Input } from '@sd/ui';
import { Dialog } from '@sd/ui';
import { useQueryClient } from '@tanstack/react-query';
import { PropsWithChildren, useState } from 'react';
import { forms } from '@sd/ui';
export default function AddLocationDialog({
children,
onSubmit,
open,
setOpen
}: PropsWithChildren<{ onSubmit?: () => void; open: boolean; setOpen: (state: boolean) => void }>) {
// BEFORE MERGE: Remove default value
const [locationUrl, setLocationUrl] = useState(
'/Users/jamie/Projects/spacedrive/packages/test-files/files'
);
const { useZodForm, z } = forms;
const schema = z.object({ path: z.string() });
interface Props {
open: boolean;
setOpen: (state: boolean) => void;
}
export default function AddLocationDialog({ open, setOpen }: Props) {
const createLocation = useLibraryMutation('locations.create', {
onSuccess: () => setOpen(false)
});
const form = useZodForm({
schema,
defaultValues: {
// BEFORE MERGE: Remove default value
path: '/Users/jamie/Projects/spacedrive/packages/test-files/files'
}
});
return (
<Dialog
form={form}
onSubmit={form.handleSubmit(async ({ path }) => {
await createLocation.mutateAsync({
path,
indexer_rules_ids: []
});
})}
open={open}
setOpen={setOpen}
title="Add Location URL"
description="As you are using the browser version of Spacedrive you will (for now) need to specify an absolute URL of a directory local to the remote node."
ctaAction={() =>
createLocation.mutate({
path: locationUrl,
indexer_rules_ids: []
} as LocationCreateArgs)
}
loading={createLocation.isLoading}
submitDisabled={!locationUrl}
ctaLabel="Add"
trigger={null}
>
<Input
className="flex-grow w-full mt-3"
value={locationUrl}
placeholder="/Users/jamie/Movies"
onChange={(e) => setLocationUrl(e.target.value)}
required
{...form.register('path')}
/>
</Dialog>
);

View File

@@ -1,6 +1,8 @@
import { Button, Dialog, Input } from '@sd/ui';
import { Clipboard } from 'phosphor-react';
import { useZodForm, z } from '@sd/ui/src/forms';
export const GenericAlertDialogState = {
open: false,
title: '',
@@ -28,16 +30,18 @@ export interface AlertDialogProps {
}
export const AlertDialog = (props: AlertDialogProps) => {
const form = useZodForm({ schema: z.object({}) });
// maybe a copy-to-clipboard button would be beneficial too
return (
<Dialog
form={form}
onSubmit={form.handleSubmit(() => {
props.setOpen(false);
})}
open={props.open}
setOpen={props.setOpen}
title={props.title}
description={props.description}
ctaAction={() => {
props.setOpen(false);
}}
ctaLabel={props.label !== undefined ? props.label : 'Done'}
>
{props.inputBox && (

View File

@@ -1,17 +1,19 @@
import { useLibraryMutation } from '@sd/client';
import { Button, Dialog, Input } from '@sd/ui';
import { Button, Dialog } from '@sd/ui';
import { forms } from '@sd/ui';
import { Eye, EyeSlash } from 'phosphor-react';
import { ReactNode, useState } from 'react';
import { useForm } from 'react-hook-form';
import { usePlatform } from '../../util/Platform';
import { GenericAlertDialogProps } from './AlertDialog';
type FormValues = {
masterPassword: string;
secretKey: string;
filePath: string;
};
const { Input, useZodForm, z } = forms;
const schema = z.object({
masterPassword: z.string(),
secretKey: z.string(),
filePath: z.string()
});
export interface BackupRestorationDialogProps {
trigger: ReactNode;
@@ -53,12 +55,8 @@ export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => {
const MPCurrentEyeIcon = show.masterPassword ? EyeSlash : Eye;
const SKCurrentEyeIcon = show.secretKey ? EyeSlash : Eye;
const form = useForm<FormValues>({
defaultValues: {
masterPassword: '',
secretKey: '',
filePath: ''
}
const form = useZodForm({
schema
});
const onSubmit = form.handleSubmit((data) => {
@@ -75,75 +73,73 @@ export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => {
});
return (
<form onSubmit={onSubmit}>
<Dialog
open={show.backupRestoreDialog}
setOpen={(e) => setShow((old) => ({ ...old, backupRestoreDialog: e }))}
title="Restore Keys"
description="Restore keys from a backup."
loading={restoreKeystoreMutation.isLoading}
ctaLabel="Restore"
trigger={props.trigger}
>
<div className="relative flex flex-grow mt-3 mb-2">
<Input
className="flex-grow !py-0.5"
placeholder="Master Password"
required
type={show.masterPassword ? 'text' : 'password'}
{...form.register('masterPassword', { required: true })}
/>
<Button
onClick={() => setShow((old) => ({ ...old, masterPassword: !old.masterPassword }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MPCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-3">
<Input
className="flex-grow !py-0.5"
placeholder="Secret Key"
{...form.register('secretKey', { required: false })}
type={show.secretKey ? 'text' : 'password'}
/>
<Button
onClick={() => setShow((old) => ({ ...old, secretKey: !old.secretKey }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<SKCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-2">
<Button
size="sm"
variant={form.watch('filePath') !== '' ? 'accent' : 'gray'}
type="button"
onClick={() => {
if (!platform.openFilePickerDialog) {
// TODO: Support opening locations on web
props.setAlertDialogData({
open: true,
title: 'Error',
description: '',
value: "System dialogs aren't supported on this platform.",
inputBox: false
});
return;
}
platform.openFilePickerDialog().then((result) => {
if (result) form.setValue('filePath', result as string);
<Dialog
form={form}
onSubmit={onSubmit}
open={show.backupRestoreDialog}
setOpen={(e) => setShow((old) => ({ ...old, backupRestoreDialog: e }))}
title="Restore Keys"
description="Restore keys from a backup."
loading={restoreKeystoreMutation.isLoading}
ctaLabel="Restore"
trigger={props.trigger}
>
<div className="relative flex flex-grow mt-3 mb-2">
<Input
className="flex-grow !py-0.5"
placeholder="Master Password"
type={show.masterPassword ? 'text' : 'password'}
{...form.register('masterPassword', { required: true })}
/>
<Button
onClick={() => setShow((old) => ({ ...old, masterPassword: !old.masterPassword }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MPCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-3">
<Input
className="flex-grow !py-0.5"
placeholder="Secret Key"
type={show.secretKey ? 'text' : 'password'}
{...form.register('secretKey')}
/>
<Button
onClick={() => setShow((old) => ({ ...old, secretKey: !old.secretKey }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<SKCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-2">
<Button
size="sm"
variant={form.watch('filePath') !== '' ? 'accent' : 'gray'}
type="button"
onClick={() => {
if (!platform.openFilePickerDialog) {
// TODO: Support opening locations on web
props.setAlertDialogData({
open: true,
title: 'Error',
description: '',
value: "System dialogs aren't supported on this platform.",
inputBox: false
});
}}
>
Select File
</Button>
</div>
</Dialog>
</form>
return;
}
platform.openFilePickerDialog().then((result) => {
if (result) form.setValue('filePath', result as string);
});
}}
>
Select File
</Button>
</div>
</Dialog>
);
};

View File

@@ -1,29 +1,39 @@
import { Algorithm, useBridgeMutation } from '@sd/client';
import { Button, Dialog, Input, Select, SelectOption } from '@sd/ui';
import { Button, Dialog, Select, SelectOption } from '@sd/ui';
import { forms } from '@sd/ui';
import { useQueryClient } from '@tanstack/react-query';
import cryptoRandomString from 'crypto-random-string';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
import { PropsWithChildren, useState } from 'react';
import { useForm } from 'react-hook-form';
import { getHashingAlgorithmSettings } from '~/screens/settings/library/KeysSetting';
import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting';
import { generatePassword } from '../key/KeyMounter';
import { PasswordMeter } from '../key/PasswordMeter';
export default function CreateLibraryDialog({
children,
onSubmit,
open,
setOpen
}: PropsWithChildren<{ onSubmit?: () => void; open: boolean; setOpen: (state: boolean) => void }>) {
const { Input, z, useZodForm } = forms;
const schema = z.object({
name: z.string(),
password: z.string(),
password_validate: z.string(),
secret_key: z.string(),
algorithm: z.string(),
hashing_algorithm: z.string()
});
interface Props {
onSubmit?: () => void;
open: boolean;
setOpen: (state: boolean) => void;
}
export default function CreateLibraryDialog(props: PropsWithChildren<Props>) {
const queryClient = useQueryClient();
const form = useForm({
const form = useZodForm({
schema,
defaultValues: {
name: '',
password: '' as string,
password_validate: '' as string,
secret_key: '' as string | null,
password: '',
algorithm: 'XChaCha20Poly1305',
hashing_algorithm: 'Argon2id-s'
}
@@ -43,23 +53,21 @@ export default function CreateLibraryDialog({
library
]);
if (onSubmit) onSubmit();
setOpen(false);
props.onSubmit?.();
props.setOpen(false);
form.reset();
},
onError: (err: any) => {
console.error(err);
}
});
const doSubmit = form.handleSubmit((data) => {
if (data.secret_key === '') {
data.secret_key = null;
}
const _onSubmit = form.handleSubmit(async (data) => {
if (data.password !== data.password_validate) {
alert('Passwords are not the same');
} else {
return createLibrary.mutateAsync({
await createLibrary.mutateAsync({
...data,
algorithm: data.algorithm as Algorithm,
hashing_algorithm: getHashingAlgorithmSettings(data.hashing_algorithm)
@@ -69,170 +77,159 @@ export default function CreateLibraryDialog({
return (
<Dialog
open={open}
setOpen={setOpen}
form={form}
onSubmit={_onSubmit}
open={props.open}
setOpen={props.setOpen}
title="Create New Library"
description="Choose a name for your new library, you can configure this and more settings from the library settings later on."
ctaAction={doSubmit}
loading={form.formState.isSubmitting}
submitDisabled={!form.formState.isValid}
ctaLabel="Create"
trigger={children}
trigger={props.children}
>
<form onSubmit={doSubmit}>
<div className="relative flex flex-col">
<p className="text-sm mt-2 mb-2 font-bold">Library name</p>
<div className="relative flex flex-col">
<p className="text-sm mt-2 mb-2 font-bold">Library name</p>
<Input
className="flex-grow w-full"
placeholder="My Cool Library"
{...form.register('name', { required: true })}
/>
</div>
{/* TODO: Proper UI for this. Maybe checkbox for encrypted or not and then reveal these fields. Select encrypted by default. */}
{/* <span className="text-sm">Make the secret key field empty to skip key setup.</span> */}
<div className="relative flex flex-col">
<p className="text-center mt-2 mb-1 text-[0.95rem] font-bold">Key Manager</p>
<div className="w-full my-1 h-[2px] bg-gray-500" />
<p className="text-sm mt-2 mb-2 font-bold">Master password</p>
<div className="relative flex flex-grow mb-2">
<Input
className="flex-grow w-full"
placeholder="My Cool Library"
disabled={form.formState.isSubmitting}
{...form.register('name', { required: true })}
className="flex-grow !py-0.5"
placeholder="Password"
type={showMasterPassword1 ? 'text' : 'password'}
{...form.register('password')}
/>
</div>
<Button
onClick={() => {
const password = generatePassword(32);
{/* TODO: Proper UI for this. Maybe checkbox for encrypted or not and then reveal these fields. Select encrypted by default. */}
{/* <span className="text-sm">Make the secret key field empty to skip key setup.</span> */}
form.setValue('password', password);
form.setValue('password_validate', password);
<div className="relative flex flex-col">
<p className="text-center mt-2 mb-1 text-[0.95rem] font-bold">Key Manager</p>
<div className="w-full my-1 h-[2px] bg-gray-500" />
setShowMasterPassword1(true);
setShowMasterPassword2(true);
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(form.watch('password') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShowMasterPassword1(!showMasterPassword1)}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<MP1CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
</div>
<div className="relative flex flex-col">
<p className="text-sm mt-2 mb-2 font-bold">Master password (again)</p>
<div className="relative flex flex-grow mb-2">
<Input
className="flex-grow !py-0.5"
placeholder="Password"
type={showMasterPassword2 ? 'text' : 'password'}
{...form.register('password_validate')}
/>
<Button
onClick={() => setShowMasterPassword2(!showMasterPassword2)}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<MP2CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
</div>
<div className="relative flex flex-col">
<p className="text-sm mt-2 mb-2 font-bold">Key secret (optional)</p>
<div className="relative flex flex-grow mb-2">
<Input
className="flex-grow !py-0.5"
placeholder="Secret"
type={showSecretKey ? 'text' : 'password'}
{...form.register('secret_key', { required: true })}
/>
<Button
onClick={() => {
form.setValue('secret_key', cryptoRandomString({ length: 24 }));
setShowSecretKey(true);
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(form.watch('secret_key') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShowSecretKey(!showSecretKey)}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<SKCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-sm mt-2 mb-2 font-bold">Master password</p>
<div className="relative flex flex-grow mb-2">
<Input
className="flex-grow !py-0.5"
disabled={form.formState.isSubmitting}
{...form.register('password')}
placeholder="Password"
type={showMasterPassword1 ? 'text' : 'password'}
/>
<Button
onClick={() => {
const password = generatePassword(32);
form.setValue('password', password);
form.setValue('password_validate', password);
setShowMasterPassword1(true);
setShowMasterPassword2(true);
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
type="button"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(form.watch('password') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShowMasterPassword1(!showMasterPassword1)}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MP1CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-sm font-bold">Encryption</span>
<Select
className="mt-2"
value={form.watch('algorithm')}
onChange={(e) => form.setValue('algorithm', e)}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
<div className="relative flex flex-col">
<p className="text-sm mt-2 mb-2 font-bold">Master password (again)</p>
<div className="relative flex flex-grow mb-2">
<Input
className="flex-grow !py-0.5"
disabled={form.formState.isSubmitting}
{...form.register('password_validate')}
placeholder="Password"
type={showMasterPassword2 ? 'text' : 'password'}
/>
<Button
onClick={() => setShowMasterPassword2(!showMasterPassword2)}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MP2CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
</div>
<div className="relative flex flex-col">
<p className="text-sm mt-2 mb-2 font-bold">Key secret (optional)</p>
<div className="relative flex flex-grow mb-2">
<Input
className="flex-grow !py-0.5"
placeholder="Secret"
disabled={form.formState.isSubmitting}
{...form.register('secret_key')}
type={showSecretKey ? 'text' : 'password'}
/>
<Button
onClick={() => {
form.setValue('secret_key', cryptoRandomString({ length: 24 }));
setShowSecretKey(true);
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
type="button"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(form.watch('secret_key') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShowSecretKey(!showSecretKey)}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<SKCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-col">
<span className="text-sm font-bold">Hashing</span>
<Select
className="mt-2"
value={form.watch('hashing_algorithm')}
onChange={(e) => form.setValue('hashing_algorithm', e)}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-sm font-bold">Encryption</span>
<Select
className="mt-2"
value={form.watch('algorithm')}
onChange={(e) => form.setValue('algorithm', e)}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
<div className="flex flex-col">
<span className="text-sm font-bold">Hashing</span>
<Select
className="mt-2"
value={form.watch('hashing_algorithm')}
onChange={(e) => form.setValue('hashing_algorithm', e)}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
</div>
<PasswordMeter password={form.watch('password')} />
</form>
<PasswordMeter password={form.watch('password')} />
</Dialog>
);
}

View File

@@ -1,28 +1,29 @@
import { RadioGroup } from '@headlessui/react';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, Dialog, Input, Switch } from '@sd/ui';
import { Button, Dialog } from '@sd/ui';
import { Eye, EyeSlash, Info } from 'phosphor-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { usePlatform } from '../../util/Platform';
import { Tooltip } from '../tooltip/Tooltip';
import { GenericAlertDialogProps } from './AlertDialog';
import { Input, Switch, useZodForm, z } from '@sd/ui/src/forms';
interface DecryptDialogProps {
open: boolean;
setOpen: (isShowing: boolean) => void;
location_id: number | null;
path_id: number | undefined;
location_id: number;
path_id: number;
setAlertDialogData: (data: GenericAlertDialogProps) => void;
}
type FormValues = {
type: 'password' | 'key';
outputPath: string;
password: string;
saveToKeyManager: boolean;
};
const schema = z.object({
type: z.union([z.literal('password'), z.literal('key')]),
outputPath: z.string(),
password: z.string(),
saveToKeyManager: z.boolean()
});
export const DecryptFileDialog = (props: DecryptDialogProps) => {
const platform = usePlatform();
@@ -67,11 +68,10 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
const PasswordCurrentEyeIcon = show.password ? EyeSlash : Eye;
const form = useForm<FormValues>({
const form = useZodForm({
schema,
defaultValues: {
type: hasMountedKeys ? 'key' : 'password',
outputPath: '',
password: '',
saveToKeyManager: true
}
});
@@ -83,127 +83,123 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
props.setOpen(false);
props.location_id &&
props.path_id &&
decryptFile.mutate({
location_id: props.location_id,
path_id: props.path_id,
output_path: output,
password: pw,
save_to_library: save
});
decryptFile.mutate({
location_id: props.location_id,
path_id: props.path_id,
output_path: output,
password: pw,
save_to_library: save
});
form.reset();
});
return (
<form onSubmit={onSubmit}>
<Dialog
open={props.open}
setOpen={props.setOpen}
title="Decrypt a file"
description="Leave the output file blank for the default."
loading={decryptFile.isLoading}
ctaLabel="Decrypt"
<Dialog
form={form}
onSubmit={onSubmit}
open={props.open}
setOpen={props.setOpen}
title="Decrypt a file"
description="Leave the output file blank for the default."
loading={decryptFile.isLoading}
ctaLabel="Decrypt"
>
<RadioGroup
value={form.watch('type')}
onChange={(e: 'key' | 'password') => form.setValue('type', e)}
className="mt-2"
>
<RadioGroup
value={form.watch('type')}
onChange={(e: 'key' | 'password') => form.setValue('type', e)}
className="mt-2"
>
<span className="text-xs font-bold">Key Type</span>
<div className="flex flex-row gap-2 mt-2">
<RadioGroup.Option disabled={!hasMountedKeys} value="key">
{({ checked }) => (
<Button
type="button"
disabled={!hasMountedKeys}
size="sm"
variant={checked ? 'accent' : 'gray'}
>
Key Manager
</Button>
)}
</RadioGroup.Option>
<RadioGroup.Option value="password">
{({ checked }) => (
<Button type="button" size="sm" variant={checked ? 'accent' : 'gray'}>
Password
</Button>
)}
</RadioGroup.Option>
</div>
</RadioGroup>
{form.watch('type') === 'password' && (
<>
<div className="relative flex flex-grow mt-3 mb-2">
<Input
className={`flex-grow w-max !py-0.5`}
placeholder="Password"
{...form.register('password', { required: false })}
type={show.password ? 'text' : 'password'}
required
/>
<span className="text-xs font-bold">Key Type</span>
<div className="flex flex-row gap-2 mt-2">
<RadioGroup.Option disabled={!hasMountedKeys} value="key">
{({ checked }) => (
<Button
onClick={() => setShow((old) => ({ ...old, password: !old.password }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
disabled={!hasMountedKeys}
size="sm"
variant={checked ? 'accent' : 'gray'}
>
<PasswordCurrentEyeIcon className="w-4 h-4" />
Key Manager
</Button>
</div>
<div className="relative flex flex-grow mt-3 mb-2">
<div className="space-x-2">
<Switch
className="bg-app-selected"
size="sm"
checked={form.watch('saveToKeyManager')}
onCheckedChange={(e) => form.setValue('saveToKeyManager', e)}
/>
</div>
<span className="ml-3 text-xs font-medium mt-0.5">Save to Key Manager</span>
<Tooltip label="This key will be saved to the key manager">
<Info className="w-4 h-4 ml-1.5 text-ink-faint mt-0.5" />
</Tooltip>
</div>
</>
)}
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Output file</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="password">
{({ checked }) => (
<Button type="button" size="sm" variant={checked ? 'accent' : 'gray'}>
Password
</Button>
)}
</RadioGroup.Option>
</div>
</RadioGroup>
{form.watch('type') === 'password' && (
<>
<div className="relative flex flex-grow mt-3 mb-2">
<Input
className={`flex-grow w-max !py-0.5`}
placeholder="Password"
type={show.password ? 'text' : 'password'}
{...form.register('password', { required: true })}
/>
<Button
size="sm"
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
className="h-[23px] text-xs leading-3 mt-2"
onClick={() => setShow((old) => ({ ...old, password: !old.password }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
onClick={() => {
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
props.setAlertDialogData({
open: true,
title: 'Error',
description: '',
value: "System dialogs aren't supported on this platform.",
inputBox: false
});
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) form.setValue('outputPath', result as string);
});
}}
>
Select
<PasswordCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mt-3 mb-2">
<div className="space-x-2">
<Switch
className="bg-app-selected"
size="sm"
{...form.register('saveToKeyManager')}
/>
</div>
<span className="ml-3 text-xs font-medium mt-0.5">Save to Key Manager</span>
<Tooltip label="This key will be saved to the key manager">
<Info className="w-4 h-4 ml-1.5 text-ink-faint mt-0.5" />
</Tooltip>
</div>
</>
)}
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Output file</span>
<Button
size="sm"
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
className="h-[23px] text-xs leading-3 mt-2"
type="button"
onClick={() => {
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
props.setAlertDialogData({
open: true,
title: 'Error',
description: '',
value: "System dialogs aren't supported on this platform.",
inputBox: false
});
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) form.setValue('outputPath', result as string);
});
}}
>
Select
</Button>
</div>
</Dialog>
</form>
</div>
</Dialog>
);
};

View File

@@ -1,34 +1,40 @@
import { useBridgeMutation } from '@sd/client';
import { Dialog } from '@sd/ui';
import { forms } from '@sd/ui';
import { useQueryClient } from '@tanstack/react-query';
import { PropsWithChildren, useState } from 'react';
export default function DeleteLibraryDialog(
props: PropsWithChildren<{
libraryUuid: string;
}>
) {
const { useZodForm, z } = forms;
interface Props {
libraryUuid: string;
}
export default function DeleteLibraryDialog(props: PropsWithChildren<Props>) {
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const queryClient = useQueryClient();
const { mutate: deleteLib, isLoading: libDeletePending } = useBridgeMutation('library.delete', {
const deleteLib = useBridgeMutation('library.delete', {
onSuccess: () => {
setOpenDeleteModal(false);
queryClient.invalidateQueries(['library.list']);
}
});
const form = useZodForm({ schema: z.object({}) });
return (
<Dialog
form={form}
onSubmit={async () => {
await deleteLib.mutateAsync(props.libraryUuid);
}}
open={openDeleteModal}
setOpen={setOpenDeleteModal}
title="Delete Library"
description="Deleting a library will permanently the database, the files themselves will not be deleted."
ctaAction={() => {
deleteLib(props.libraryUuid);
}}
loading={libDeletePending}
loading={deleteLib.isLoading}
ctaDanger
ctaLabel="Delete"
trigger={props.children}

View File

@@ -1,14 +1,13 @@
import { Algorithm, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, Dialog, Select, SelectOption } from '@sd/ui';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting';
import { usePlatform } from '~/util/Platform';
import { getHashingAlgorithmString } from '../../screens/settings/library/KeysSetting';
import { usePlatform } from '../../util/Platform';
import { SelectOptionKeyList } from '../key/KeyList';
import { Checkbox } from '../primitive/Checkbox';
import { GenericAlertDialogProps } from './AlertDialog';
import { CheckBox, useZodForm, z } from '@sd/ui/src/forms';
interface EncryptDialogProps {
open: boolean;
setOpen: (isShowing: boolean) => void;
@@ -17,14 +16,14 @@ interface EncryptDialogProps {
setAlertDialogData: (data: GenericAlertDialogProps) => void;
}
type FormValues = {
key: string;
encryptionAlgo: string;
hashingAlgo: string;
metadata: boolean;
previewMedia: boolean;
outputPath: string;
};
const schema = z.object({
key: z.string(),
encryptionAlgo: z.string(),
hashingAlgo: z.string(),
metadata: z.boolean(),
previewMedia: z.boolean(),
outputPath: z.string()
});
export const EncryptFileDialog = (props: EncryptDialogProps) => {
const platform = usePlatform();
@@ -66,19 +65,11 @@ export const EncryptFileDialog = (props: EncryptDialogProps) => {
}
});
const form = useForm<FormValues>({
defaultValues: {
key: '',
encryptionAlgo: 'XChaCha20Poly1305',
hashingAlgo: 'Argon2id-s',
metadata: false,
previewMedia: false,
outputPath: ''
}
const form = useZodForm({
schema
});
const onSubmit = form.handleSubmit((data) => {
const output = data.outputPath !== '' ? data.outputPath : null;
props.setOpen(false);
props.location_id &&
@@ -90,113 +81,107 @@ export const EncryptFileDialog = (props: EncryptDialogProps) => {
path_id: props.path_id,
metadata: data.metadata,
preview_media: data.previewMedia,
output_path: output
output_path: data.outputPath || null
});
form.reset();
});
return (
<form onSubmit={onSubmit}>
<Dialog
open={props.open}
setOpen={props.setOpen}
title="Encrypt a file"
description="Configure your encryption settings. Leave the output file blank for the default."
loading={encryptFile.isLoading}
ctaLabel="Encrypt"
>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Key</span>
<Select
className="mt-2"
value={form.watch('key')}
onChange={(e) => {
UpdateKey(e);
}}
>
{mountedUuids.data && <SelectOptionKeyList keys={mountedUuids.data} />}
</Select>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Output file</span>
<Dialog
form={form}
onSubmit={onSubmit}
open={props.open}
setOpen={props.setOpen}
title="Encrypt a file"
description="Configure your encryption settings. Leave the output file blank for the default."
loading={encryptFile.isLoading}
ctaLabel="Encrypt"
>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Key</span>
<Select
className="mt-2"
value={form.watch('key')}
onChange={(e) => {
UpdateKey(e);
}}
>
{mountedUuids.data && <SelectOptionKeyList keys={mountedUuids.data} />}
</Select>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Output file</span>
<Button
size="sm"
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
className="h-[23px] text-xs leading-3 mt-2"
type="button"
onClick={() => {
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
props.setAlertDialogData({
open: true,
title: 'Error',
description: '',
value: "System dialogs aren't supported on this platform.",
inputBox: false
});
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) form.setValue('outputPath', result as string);
<Button
size="sm"
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
className="h-[23px] text-xs leading-3 mt-2"
type="button"
onClick={() => {
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
props.setAlertDialogData({
open: true,
title: 'Error',
description: '',
value: "System dialogs aren't supported on this platform.",
inputBox: false
});
}}
>
Select
</Button>
</div>
</div>
return;
}
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Encryption</span>
<Select
className="mt-2"
value={form.watch('encryptionAlgo')}
onChange={(e) => form.setValue('encryptionAlgo', e)}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select
className="mt-2 text-gray-400/80"
onChange={() => {}}
disabled
value={form.watch('hashingAlgo')}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
platform.saveFilePickerDialog().then((result) => {
if (result) form.setValue('outputPath', result as string);
});
}}
>
Select
</Button>
</div>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex">
<span className="text-sm font-bold mr-3 ml-0.5 mt-0.5">Metadata</span>
<Checkbox
checked={form.watch('metadata')}
onChange={(e) => form.setValue('metadata', e.target.checked)}
/>
</div>
<div className="flex">
<span className="text-sm font-bold mr-3 ml-0.5 mt-0.5">Preview Media</span>
<Checkbox
checked={form.watch('previewMedia')}
onChange={(e) => form.setValue('previewMedia', e.target.checked)}
/>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Encryption</span>
<Select
className="mt-2"
value={form.watch('encryptionAlgo')}
onChange={(e) => form.setValue('encryptionAlgo', e)}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
</Dialog>
</form>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select
className="mt-2 text-gray-400/80"
onChange={() => {}}
value={form.watch('hashingAlgo')}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex">
<span className="text-sm font-bold mr-3 ml-0.5 mt-0.5">Metadata</span>
<CheckBox {...form.register('metadata')} />
</div>
<div className="flex">
<span className="text-sm font-bold mr-3 ml-0.5 mt-0.5">Preview Media</span>
<CheckBox {...form.register('previewMedia')} />
</div>
</div>
</Dialog>
);
};

View File

@@ -7,6 +7,8 @@ import { ReactNode, useState } from 'react';
import { getHashingAlgorithmString } from '../../screens/settings/library/KeysSetting';
import { SelectOptionKeyList } from '../key/KeyList';
import { useZodForm, z } from '@sd/ui/src/forms';
interface KeyViewerDialogProps {
trigger: ReactNode;
}
@@ -34,7 +36,11 @@ export const KeyUpdater = (props: {
return <></>;
};
const schema = z.object({});
export const KeyViewerDialog = (props: KeyViewerDialogProps) => {
const form = useZodForm({ schema });
const keys = useLibraryQuery(['keys.list'], {
onSuccess: (data) => {
if (key === '' && data.length !== 0) {
@@ -51,102 +57,101 @@ export const KeyViewerDialog = (props: KeyViewerDialogProps) => {
const [hashingAlgo, setHashingAlgo] = useState('');
return (
<>
<Dialog
open={showKeyViewerDialog}
setOpen={setShowKeyViewerDialog}
trigger={props.trigger}
title="View Key Values"
description="Here you can view the values of your keys."
ctaLabel="Done"
ctaAction={() => {
setShowKeyViewerDialog(false);
}}
>
<KeyUpdater
uuid={key}
setKey={setKeyValue}
setEncryptionAlgo={setEncryptionAlgo}
setHashingAlgo={setHashingAlgo}
setContentSalt={setContentSalt}
/>
<Dialog
form={form}
onSubmit={async () => {
setShowKeyViewerDialog(false);
}}
open={showKeyViewerDialog}
setOpen={setShowKeyViewerDialog}
trigger={props.trigger}
title="View Key Values"
description="Here you can view the values of your keys."
ctaLabel="Done"
>
<KeyUpdater
uuid={key}
setKey={setKeyValue}
setEncryptionAlgo={setEncryptionAlgo}
setHashingAlgo={setHashingAlgo}
setContentSalt={setContentSalt}
/>
<div className="grid w-full gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Key</span>
<Select
className="mt-2 flex-grow"
value={key}
onChange={(e) => {
setKey(e);
<div className="grid w-full gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Key</span>
<Select
className="mt-2 flex-grow"
value={key}
onChange={(e) => {
setKey(e);
}}
>
{keys.data && <SelectOptionKeyList keys={keys.data.map((key) => key.uuid)} />}
</Select>
</div>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Encryption</span>
<Select
className="mt-2 w-[150px] text-gray-300"
value={encryptionAlgo}
disabled
onChange={() => {}}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select className="mt-2 text-gray-300" value={hashingAlgo} disabled onChange={() => {}}>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
</div>
<div className="grid w-full gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold mb-2">Content Salt (hex)</span>
<div className="relative flex flex-grow">
<Input value={contentSalt} disabled className="flex-grow !py-0.5" />
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(contentSalt);
}}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
{keys.data && <SelectOptionKeyList keys={keys.data.map((key) => key.uuid)} />}
</Select>
<Clipboard className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Encryption</span>
<Select
className="mt-2 w-[150px] text-gray-300"
value={encryptionAlgo}
disabled
onChange={() => {}}
</div>
<div className="grid w-full gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold mb-2">Key Value</span>
<div className="relative flex flex-grow">
<Input value={keyValue} disabled className="flex-grow !py-0.5" />
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(keyValue);
}}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select className="mt-2 text-gray-300" value={hashingAlgo} disabled onChange={() => {}}>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
<Clipboard className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid w-full gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold mb-2">Content Salt (hex)</span>
<div className="relative flex flex-grow">
<Input value={contentSalt} disabled className="flex-grow !py-0.5" />
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(contentSalt);
}}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="grid w-full gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold mb-2">Key Value</span>
<div className="relative flex flex-grow">
<Input value={keyValue} disabled className="flex-grow !py-0.5" />
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(keyValue);
}}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</Dialog>
</>
</div>
</Dialog>
);
};

View File

@@ -3,25 +3,26 @@ import { Button, Dialog, Input, Select, SelectOption } from '@sd/ui';
import cryptoRandomString from 'crypto-random-string';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
import { ReactNode, useState } from 'react';
import { useForm } from 'react-hook-form';
import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting';
import { generatePassword } from '../key/KeyMounter';
import { PasswordMeter } from '../key/PasswordMeter';
import { GenericAlertDialogProps } from './AlertDialog';
import { useZodForm, z } from '@sd/ui/src/forms';
export interface MasterPasswordChangeDialogProps {
trigger: ReactNode;
setAlertDialogData: (data: GenericAlertDialogProps) => void;
}
type FormValues = {
masterPassword: string;
masterPassword2: string;
secretKey: string | null;
encryptionAlgo: string;
hashingAlgo: string;
};
const schema = z.object({
masterPassword: z.string(),
masterPassword2: z.string(),
secretKey: z.string().nullable(),
encryptionAlgo: z.string(),
hashingAlgo: z.string()
});
export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProps) => {
const changeMasterPassword = useLibraryMutation('keys.changeMasterPassword', {
@@ -59,11 +60,9 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp
const MP2CurrentEyeIcon = show.masterPassword2 ? EyeSlash : Eye;
const SKCurrentEyeIcon = show.secretKey ? EyeSlash : Eye;
const form = useForm<FormValues>({
const form = useZodForm({
schema,
defaultValues: {
masterPassword: '',
masterPassword2: '',
secretKey: '',
encryptionAlgo: 'XChaCha20Poly1305',
hashingAlgo: 'Argon2id-s'
}
@@ -94,151 +93,148 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp
});
return (
<form onSubmit={onSubmit}>
<Dialog
open={show.masterPasswordDialog}
setOpen={(e) => {
setShow((old) => ({ ...old, masterPasswordDialog: e }));
}}
title="Change Master Password"
description="Select a new master password for your key manager. Leave the key secret blank to disable it."
ctaDanger={true}
loading={changeMasterPassword.isLoading}
ctaLabel="Change"
trigger={props.trigger}
>
<div className="relative flex flex-grow mt-3 mb-2">
<Input
className={`flex-grow w-max !py-0.5`}
placeholder="New password"
required
{...form.register('masterPassword', { required: true })}
type={show.masterPassword ? 'text' : 'password'}
/>
<Button
onClick={() => {
const password = generatePassword(32);
form.setValue('masterPassword', password);
form.setValue('masterPassword2', password);
setShow((old) => ({
...old,
masterPassword: true,
masterPassword2: true
}));
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
type="button"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(form.watch('masterPassword') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShow((old) => ({ ...old, masterPassword: !old.masterPassword }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MP1CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-2">
<Input
className={`flex-grow !py-0.5}`}
placeholder="New password (again)"
required
{...form.register('masterPassword2', { required: true })}
type={show.masterPassword2 ? 'text' : 'password'}
/>
<Button
onClick={() => setShow((old) => ({ ...old, masterPassword2: !old.masterPassword2 }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MP2CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<Dialog
form={form}
onSubmit={onSubmit}
open={show.masterPasswordDialog}
setOpen={(e) => {
setShow((old) => ({ ...old, masterPasswordDialog: e }));
}}
title="Change Master Password"
description="Select a new master password for your key manager. Leave the key secret blank to disable it."
ctaDanger={true}
loading={changeMasterPassword.isLoading}
ctaLabel="Change"
trigger={props.trigger}
>
<div className="relative flex flex-grow mt-3 mb-2">
<Input
className={`flex-grow w-max !py-0.5`}
placeholder="New password"
type={show.masterPassword ? 'text' : 'password'}
{...form.register('masterPassword', { required: true })}
/>
<Button
onClick={() => {
const password = generatePassword(32);
form.setValue('masterPassword', password);
form.setValue('masterPassword2', password);
setShow((old) => ({
...old,
masterPassword: true,
masterPassword2: true
}));
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
type="button"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(form.watch('masterPassword') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShow((old) => ({ ...old, masterPassword: !old.masterPassword }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MP1CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-2">
<Input
className={`flex-grow !py-0.5}`}
placeholder="New password (again)"
type={show.masterPassword2 ? 'text' : 'password'}
{...form.register('masterPassword2', { required: true })}
/>
<Button
onClick={() => setShow((old) => ({ ...old, masterPassword2: !old.masterPassword2 }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<MP2CurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-2">
<Input
className={`flex-grow !py-0.5}`}
placeholder="Key secret"
{...form.register('secretKey', { required: false })}
type={show.secretKey ? 'text' : 'password'}
/>
<Button
// onClick={() => setmasterPassword2(!masterPassword2)}
onClick={() => {
form.setValue('secretKey', cryptoRandomString({ length: 24 }));
setShow((old) => ({ ...old, secretKey: true }));
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
type="button"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(form.watch('secretKey') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShow((old) => ({ ...old, secretKey: !old.secretKey }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<SKCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<div className="relative flex flex-grow mb-2">
<Input
className={`flex-grow !py-0.5}`}
placeholder="Key secret"
type={show.secretKey ? 'text' : 'password'}
{...form.register('secretKey', { required: false })}
/>
<Button
onClick={() => {
form.setValue('secretKey', cryptoRandomString({ length: 24 }));
setShow((old) => ({ ...old, secretKey: true }));
}}
size="icon"
className="border-none absolute right-[65px] top-[5px]"
type="button"
>
<ArrowsClockwise className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(form.watch('secretKey') as string);
}}
size="icon"
className="border-none absolute right-[35px] top-[5px]"
>
<Clipboard className="w-4 h-4" />
</Button>
<Button
onClick={() => setShow((old) => ({ ...old, secretKey: !old.secretKey }))}
size="icon"
className="border-none absolute right-[5px] top-[5px]"
type="button"
>
<SKCurrentEyeIcon className="w-4 h-4" />
</Button>
</div>
<PasswordMeter password={form.watch('masterPassword')} />
<PasswordMeter password={form.watch('masterPassword')} />
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Encryption</span>
<Select
className="mt-2"
value={form.watch('encryptionAlgo')}
onChange={(e) => form.setValue('encryptionAlgo', e)}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select
className="mt-2"
value={form.watch('hashingAlgo')}
onChange={(e) => form.setValue('hashingAlgo', e)}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
<div className="flex flex-col">
<span className="text-xs font-bold">Encryption</span>
<Select
className="mt-2"
value={form.watch('encryptionAlgo')}
onChange={(e) => form.setValue('encryptionAlgo', e)}
>
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
</div>
</Dialog>
</form>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select
className="mt-2"
value={form.watch('hashingAlgo')}
onChange={(e) => form.setValue('hashingAlgo', e)}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
<SelectOption value="BalloonBlake3-s">BLAKE3-Balloon (standard)</SelectOption>
<SelectOption value="BalloonBlake3-h">BLAKE3-Balloon (hardened)</SelectOption>
<SelectOption value="BalloonBlake3-p">BLAKE3-Balloon (paranoid)</SelectOption>
</Select>
</div>
</div>
</Dialog>
);
};

View File

@@ -41,6 +41,8 @@ export default function Explorer(props: Props) {
}
});
const selectedItem = props.data?.items[expStore.selectedRowIndex];
return (
<>
<div className="relative">
@@ -97,19 +99,19 @@ export default function Explorer(props: Props) {
value={alertDialogData.value}
inputBox={alertDialogData.inputBox}
/>
{props.data && props.data.items[expStore.selectedRowIndex] && (
{selectedItem && (
<EncryptFileDialog
location_id={expStore.locationId}
path_id={props.data?.items[expStore.selectedRowIndex].id}
path_id={selectedItem.id}
open={showEncryptDialog}
setOpen={setShowEncryptDialog}
setAlertDialogData={setAlertDialogData}
/>
)}
{props.data && props.data.items[expStore.selectedRowIndex] && (
{selectedItem && expStore.locationId !== null && (
<DecryptFileDialog
location_id={expStore.locationId}
path_id={props.data?.items[expStore.selectedRowIndex].id}
path_id={selectedItem.id}
open={showDecryptDialog}
setOpen={setShowDecryptDialog}
setAlertDialogData={setAlertDialogData}

View File

@@ -1,14 +1,10 @@
import { useLibraryMutation } from '@sd/client';
import { Button, Input, OverlayPanel, cva, tw } from '@sd/ui';
import { Button, Input, OverlayPanel, cva } from '@sd/ui';
import clsx from 'clsx';
import {
ArrowsClockwise,
CaretLeft,
CaretRight,
ClockCounterClockwise,
Columns,
HourglassSimple,
IconProps,
Key,
List,
MonitorPlay,
@@ -64,13 +60,15 @@ const topBarButtonStyle = cva(
const TOP_BAR_ICON_STYLE = 'm-0.5 w-5 h-5 text-ink-dull';
const TopBarButton = forwardRef<HTMLButtonElement, TopBarButtonProps>((props, ref) => {
return (
<Button {...props} ref={ref} className={clsx(topBarButtonStyle(props), props.className)}>
{props.children}
</Button>
);
});
const TopBarButton = forwardRef<HTMLButtonElement, TopBarButtonProps>(
({ active, rounding, className, ...props }, ref) => {
return (
<Button {...props} ref={ref} className={topBarButtonStyle({ active, rounding, className })}>
{props.children}
</Button>
);
}
);
const SearchBar = forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
const {

View File

@@ -7,6 +7,8 @@ import { useState } from 'react';
import { Folder } from '../icons/Folder';
import { useZodForm, z } from '@sd/ui/src/forms';
interface LocationListItemProps {
location: Location & { node: Node };
}
@@ -26,6 +28,8 @@ export default function LocationListItem({ location }: LocationListItemProps) {
}
);
const form = useZodForm({ schema: z.object({}) });
if (hide) return <></>;
return (
@@ -53,13 +57,14 @@ export default function LocationListItem({ location }: LocationListItemProps) {
</span>
</Button>
<Dialog
form={form}
onSubmit={form.handleSubmit(() => {
deleteLoc(location.id);
})}
open={open}
setOpen={setOpen}
title="Delete Location"
description="Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted."
ctaAction={() => {
deleteLoc(location.id);
}}
loading={locDeletePending}
ctaDanger
ctaLabel="Delete"

View File

@@ -1,7 +1,7 @@
import { Button } from '@sd/ui';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@sd/ui';
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
// TODO: This page requires styling for now it is just a placeholder.

View File

@@ -1,21 +0,0 @@
import clsx from 'clsx';
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
primaryColor?: string;
}
export const Checkbox: React.FC<CheckboxProps> = (props) => {
return (
<input
{...props}
type="checkbox"
style={{}}
className={clsx(
`
form-check-input appearance-none h-4 w-4 border border-gray-300 rounded-sm bg-white checked:bg-blue-600 checked:border-blue-600 focus:outline-none transition duration-200 mt-1 align-top bg-no-repeat bg-center bg-contain float-left mr-2
`,
props.className
)}
/>
);
};

View File

@@ -1,16 +1,16 @@
import clsx from 'clsx';
import { useCallback, useRef, useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { UseControllerProps, useController } from 'react-hook-form';
import useClickOutside from '../../hooks/useClickOutside';
interface PopoverPickerProps {
value: string;
onChange: (color: string) => void;
interface PopoverPickerProps extends UseControllerProps {
className?: string;
}
export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps) => {
export const PopoverPicker = ({ className, ...props }: PopoverPickerProps) => {
const { field } = useController(props);
const popover = useRef<HTMLDivElement | null>(null);
const [isOpen, toggle] = useState(false);
@@ -21,7 +21,7 @@ export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps
<div className={clsx('relative flex items-center mt-3', className)}>
<div
className={clsx('w-5 h-5 rounded-full shadow ', isOpen && 'dark:border-gray-500')}
style={{ backgroundColor: value }}
style={{ backgroundColor: field.value }}
onClick={() => toggle(true)}
/>
{/* <span className="inline ml-2 text-sm text-gray-200">Pick Color</span> */}
@@ -32,7 +32,7 @@ export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps
className="absolute left-0 rounded-md shadow"
ref={popover}
>
<HexColorPicker color={value} onChange={onChange} />
<HexColorPicker color={field.value} onChange={field.onChange} />
</div>
)}
</div>

View File

@@ -1,65 +1,71 @@
import { Tag, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { TagUpdateArgs } from '@sd/client';
import { Button, Card, Dialog, Input, Switch } from '@sd/ui';
import { Button, Card, Dialog, Switch } from '@sd/ui';
import clsx from 'clsx';
import { Trash } from 'phosphor-react';
import { useCallback, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useDebounce } from 'rooks';
import { InputContainer } from '~/components/primitive/InputContainer';
import { PopoverPicker } from '~/components/primitive/PopoverPicker';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Form, Input, useZodForm, z } from '@sd/ui/src/forms';
export default function TagsSettings() {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
// creating new tag state
const [newColor, setNewColor] = useState('#A717D9');
const [newName, setNewName] = useState('');
const { data: tags } = useLibraryQuery(['tags.list']);
const [selectedTag, setSelectedTag] = useState<null | Tag>(tags?.[0] ?? null);
const { mutate: createTag, isLoading } = useLibraryMutation('tags.create', {
const createTag = useLibraryMutation('tags.create', {
onError: (e) => {
console.error('error', e);
},
onSuccess: (_) => {
setOpenCreateModal(false);
}
});
const updateTag = useLibraryMutation('tags.update');
const deleteTag = useLibraryMutation('tags.delete', {
onSuccess: () => {
setSelectedTag(null);
}
});
const { register, handleSubmit, watch, reset, control } = useForm({
defaultValues: selectedTag as TagUpdateArgs
const createForm = useZodForm({
schema: z.object({
name: z.string(),
color: z.string()
}),
defaultValues: {
color: '#A717D9'
}
});
const updateForm = useZodForm({
schema: z.object({
id: z.number(),
name: z.string().nullable(),
color: z.string().nullable()
}),
defaultValues: selectedTag ?? undefined
});
const deleteForm = useZodForm({ schema: z.object({}) });
const submitTagUpdate = updateForm.handleSubmit((data) => updateTag.mutateAsync(data));
// eslint-disable-next-line react-hooks/exhaustive-deps
const autoUpdateTag = useCallback(useDebounce(submitTagUpdate, 500), [submitTagUpdate]);
const setTag = useCallback(
(tag: Tag | null) => {
if (tag) reset(tag);
if (tag) updateForm.reset(tag);
setSelectedTag(tag);
},
[setSelectedTag, reset]
[setSelectedTag, updateForm]
);
const submitTagUpdate = handleSubmit((data) => updateTag.mutate(data));
// eslint-disable-next-line react-hooks/exhaustive-deps
const autoUpdateTag = useCallback(useDebounce(submitTagUpdate, 500), []);
useEffect(() => {
const subscription = watch(() => autoUpdateTag());
const subscription = updateForm.watch(() => autoUpdateTag());
return () => subscription.unsubscribe();
});
}, [updateForm, autoUpdateTag]);
return (
<SettingsContainer>
@@ -69,17 +75,16 @@ export default function TagsSettings() {
rightArea={
<div className="flex-row space-x-2">
<Dialog
form={createForm}
onSubmit={createForm.handleSubmit(async (data) => {
await createTag.mutateAsync(data);
setOpenCreateModal(false);
})}
open={openCreateModal}
setOpen={setOpenCreateModal}
title="Create New Tag"
description="Choose a name and color."
ctaAction={() => {
createTag({
name: newName,
color: newColor
});
}}
loading={isLoading}
loading={createTag.isLoading}
ctaLabel="Create"
trigger={
<Button variant="accent" size="sm">
@@ -90,12 +95,10 @@ export default function TagsSettings() {
<div className="relative mt-3 ">
<PopoverPicker
className="!absolute left-[9px] -top-[3px]"
value={newColor}
onChange={setNewColor}
{...createForm.register('color')}
/>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
{...createForm.register('name', { required: true })}
className="w-full pl-[40px]"
placeholder="Name"
/>
@@ -122,43 +125,36 @@ export default function TagsSettings() {
</div>
</Card>
{selectedTag ? (
<form onSubmit={submitTagUpdate}>
<Form form={updateForm} onSubmit={submitTagUpdate}>
<div className="flex flex-row mb-10 space-x-3">
<div className="flex flex-col">
<span className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">
Color
</span>
<div className="relative">
<Controller
name="color"
control={control}
render={({ field: { onChange, value } }) => (
<PopoverPicker
className="!absolute left-[9px] -top-[3px]"
value={value || ''}
onChange={onChange}
/>
)}
<PopoverPicker
className="!absolute left-[9px] -top-[3px]"
{...updateForm.register('color')}
/>
<Input className="w-28 pl-[40px]" {...register('color')} />
<Input className="w-28 pl-[40px]" {...updateForm.register('color')} />
</div>
</div>
<div className="flex flex-col">
<span className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">
Name
</span>
<Input {...register('name')} />
<Input {...updateForm.register('name')} />
</div>
<div className="flex flex-grow" />
<Dialog
form={deleteForm}
onSubmit={deleteForm.handleSubmit(async () => {
await deleteTag.mutateAsync(selectedTag.id);
})}
open={openDeleteModal}
setOpen={setOpenDeleteModal}
title="Delete Tag"
description="Are you sure you want to delete this tag? This cannot be undone and tagged files will be unlinked."
ctaAction={() => {
deleteTag.mutate(selectedTag.id);
}}
loading={deleteTag.isLoading}
ctaDanger
ctaLabel="Delete"
trigger={
@@ -175,7 +171,7 @@ export default function TagsSettings() {
>
<Switch checked />
</InputContainer>
</form>
</Form>
) : (
<div className="text-sm font-medium text-gray-400">No Tag Selected</div>
)}

View File

@@ -6,6 +6,7 @@
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./src/forms": "./src/forms/index.ts",
"./postcss": "./style/postcss.config.js",
"./tailwind": "./style/tailwind.js",
"./style": "./style/index.js",
@@ -29,7 +30,7 @@
"@radix-ui/react-tabs": "^1.0.1",
"@sd/assets": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
"class-variance-authority": "^0.2.3",
"class-variance-authority": "^0.4.0",
"clsx": "^1.2.1",
"phosphor-react": "^1.4.1",
"postcss": "^8.4.17",

View File

@@ -22,15 +22,16 @@ type Button = {
const hasHref = (props: ButtonProps | LinkButtonProps): props is LinkButtonProps => 'href' in props;
const styles = cva(
'border rounded-md items-center transition-colors duration-100 cursor-default disabled:opacity-50 outline-none ring-offset-app-box focus:ring-2 focus:ring-accent focus:ring-offset-2',
[
'border rounded-md items-center transition-colors duration-100 cursor-default outline-none',
'disabled:opacity-70 disabled:pointer-events-none disabled:cursor-not-allowed',
'ring-offset-app-box focus:ring-2 focus:ring-accent focus:ring-offset-2'
],
{
variants: {
pressEffect: {
true: 'active:translate-y-[1px]'
},
disabled: {
true: 'opacity-70 pointer-events-none cursor-not-allowed'
},
size: {
icon: '!p-1',
md: 'py-1 px-3 text-md font-medium',
@@ -70,7 +71,7 @@ export const Button = forwardRef<
return hasHref(props) ? (
<a {...props} ref={ref as any} className={cx(className, 'no-underline inline-block')} />
) : (
<button {...(props as ButtonProps)} ref={ref as any} className={className} />
<button type="button" {...(props as ButtonProps)} ref={ref as any} className={className} />
);
});

View File

@@ -0,0 +1,18 @@
import { VariantProps, cva } from 'class-variance-authority';
import { ComponentProps, forwardRef } from 'react';
const styles = cva(
[
'form-check-input appearance-none h-4 w-4 border border-gray-300 rounded-sm bg-white transition duration-200 mt-1 align-top bg-no-repeat bg-center bg-contain float-left mr-2',
'checked:bg-blue-600 checked:border-blue-600 focus:outline-none '
],
{ variants: {} }
);
export interface CheckBoxProps extends ComponentProps<'input'>, VariantProps<typeof styles> {}
export const CheckBox = forwardRef<HTMLInputElement, CheckBoxProps>(
({ className, ...props }, ref) => (
<input {...props} type="checkbox" ref={ref} className={styles({ className })} />
)
);

View File

@@ -1,17 +1,20 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import clsx from 'clsx';
import { ReactNode, useEffect, useState } from 'react';
import { ReactNode, useEffect } from 'react';
import { FieldValues } from 'react-hook-form';
import { animated, useTransition } from 'react-spring';
import { Button, Loader } from '../';
import { Form, FormProps } from './forms/Form';
export interface DialogProps extends DialogPrimitive.DialogProps {
export interface DialogProps<S extends FieldValues>
extends DialogPrimitive.DialogProps,
FormProps<S> {
open: boolean;
setOpen: (open: boolean) => void;
trigger?: ReactNode;
ctaLabel?: string;
ctaDanger?: boolean;
ctaAction?: () => void;
title?: string;
description?: string;
children?: ReactNode;
@@ -20,7 +23,13 @@ export interface DialogProps extends DialogPrimitive.DialogProps {
submitDisabled?: boolean;
}
export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) {
export function Dialog<S extends FieldValues>({
form,
onSubmit,
open,
setOpen: onOpenChange,
...props
}: DialogProps<S>) {
const transitions = useTransition(open, {
from: {
opacity: 0,
@@ -44,49 +53,7 @@ export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) {
style={{
opacity: styles.opacity
}}
>
<DialogPrimitive.Content forceMount asChild>
<animated.div
style={styles}
className="min-w-[300px] max-w-[400px] rounded-md bg-app-box border border-app-line text-ink shadow-2xl shadow-app-shade/230"
>
<form
onSubmit={(e) => {
e.preventDefault();
if (props.ctaAction) props.ctaAction();
}}
>
<div className="p-5">
<DialogPrimitive.Title className="mb-2 font-bold">
{props.title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-ink-dull">
{props.description}
</DialogPrimitive.Description>
{props.children}
</div>
<div className="flex flex-row justify-end px-3 py-3 space-x-2 border-t bg-app/20 border-app-line">
{props.loading && <Loader />}
<div className="flex-grow" />
<DialogPrimitive.Close asChild>
<Button disabled={props.loading} size="sm" variant="gray">
Close
</Button>
</DialogPrimitive.Close>
<Button
type="submit"
size="sm"
disabled={props.loading || props.submitDisabled}
variant={props.ctaDanger ? 'colored' : 'accent'}
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
>
{props.ctaLabel}
</Button>
</div>
</form>
</animated.div>
</DialogPrimitive.Content>
</animated.div>
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content asChild forceMount>
@@ -94,12 +61,10 @@ export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) {
className="z-50 fixed top-0 bottom-0 left-0 right-0 grid place-items-center !pointer-events-none"
style={styles}
>
<form
<Form
form={form}
onSubmit={onSubmit}
className="min-w-[300px] max-w-[400px] rounded-md bg-app-box border border-app-line text-ink shadow-app-shade !pointer-events-auto"
onSubmit={(e) => {
e.preventDefault();
if (props.ctaAction) props.ctaAction();
}}
>
<div className="p-5">
<DialogPrimitive.Title className="mb-2 font-bold">
@@ -111,7 +76,7 @@ export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) {
{props.children}
</div>
<div className="flex flex-row justify-end px-3 py-3 space-x-2 border-t bg-app-selected border-app-line">
{props.loading && <Loader />}
{form.formState.isSubmitting && <Loader />}
<div className="flex-grow" />
<DialogPrimitive.Close asChild>
<Button disabled={props.loading} size="sm" variant="gray">
@@ -121,14 +86,14 @@ export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) {
<Button
type="submit"
size="sm"
disabled={props.loading || props.submitDisabled}
disabled={form.formState.isSubmitting || props.submitDisabled}
variant={props.ctaDanger ? 'colored' : 'accent'}
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
>
{props.ctaLabel}
</Button>
</div>
</form>
</Form>
</animated.div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>

View File

@@ -2,13 +2,13 @@ import { VariantProps, cva } from 'class-variance-authority';
import clsx from 'clsx';
import { PropsWithChildren, forwardRef } from 'react';
export interface InputBaseProps extends VariantProps<typeof inputStyles> {}
export interface InputBaseProps extends VariantProps<typeof styles> {}
export type InputProps = InputBaseProps & React.InputHTMLAttributes<HTMLInputElement>;
export type TextareaProps = InputBaseProps & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const inputStyles = cva(
const styles = cva(
[
'px-3 py-1 text-sm rounded-md border leading-7',
'outline-none shadow-sm focus:ring-2 transition-all'
@@ -33,19 +33,13 @@ const inputStyles = cva(
);
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ size, variant, ...props }, ref) => {
return (
<input
ref={ref}
{...props}
className={clsx(inputStyles({ size, variant }), props.className)}
/>
);
}
({ variant, size, className, ...props }, ref) => (
<input {...props} ref={ref} className={styles({ variant, size, className })} />
)
);
export const TextArea = ({ size, variant, ...props }: TextareaProps) => {
return <textarea {...props} className={clsx(inputStyles({ size, variant }), props.className)} />;
return <textarea {...props} className={clsx(styles({ size, variant }), props.className)} />;
};
export function Label(props: PropsWithChildren<{ slug?: string }>) {

View File

@@ -1,10 +1,10 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { VariantProps, cva, cx } from 'class-variance-authority';
import { VariantProps, cva } from 'class-variance-authority';
import { forwardRef } from 'react';
export type SwitchProps = VariantProps<typeof switchStyles> &
React.ButtonHTMLAttributes<HTMLButtonElement> &
SwitchPrimitive.SwitchProps;
export interface SwitchProps
extends VariantProps<typeof switchStyles>,
SwitchPrimitive.SwitchProps {}
const switchStyles = cva(
[
@@ -45,13 +45,10 @@ const thumbStyles = cva(
}
);
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch(
props,
forwardedRef
) {
return (
<SwitchPrimitive.Root {...props} ref={forwardedRef} className={cx(switchStyles(props))}>
<SwitchPrimitive.Thumb className={cx(thumbStyles(props))} />
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
({ size, className, ...props }, ref) => (
<SwitchPrimitive.Root {...props} ref={ref} className={switchStyles({ size, className })}>
<SwitchPrimitive.Thumb className={thumbStyles({ size, className })} />
</SwitchPrimitive.Root>
);
});
)
);

View File

@@ -0,0 +1,16 @@
import { forwardRef } from 'react';
import { CheckBox as Root } from '../CheckBox';
import { FormField, UseFormFieldProps, useFormField } from './FormField';
export interface CheckBoxProps extends UseFormFieldProps {}
export const CheckBox = forwardRef<HTMLInputElement, CheckBoxProps>((props, ref) => {
const { formFieldProps, childProps } = useFormField(props);
return (
<FormField {...formFieldProps}>
<Root {...childProps} ref={ref} />
</FormField>
);
});

View File

@@ -0,0 +1,50 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { ComponentProps } from 'react';
import {
FieldValues,
FormProvider,
UseFormHandleSubmit,
UseFormProps,
UseFormReturn,
useForm
} from 'react-hook-form';
import { z } from 'zod';
export interface FormProps<T extends FieldValues> extends Omit<ComponentProps<'form'>, 'onSubmit'> {
form: UseFormReturn<T>;
onSubmit: ReturnType<UseFormHandleSubmit<T>>;
}
export const Form = <T extends FieldValues>({
form,
onSubmit,
children,
...props
}: FormProps<T>) => (
<FormProvider {...form}>
<form
onSubmit={(e) => {
e.stopPropagation();
return onSubmit(e);
}}
{...props}
>
{/* <fieldset> passes the form's 'disabled' state to all of its elements,
allowing us to handle disabled style variants with just css */}
<fieldset disabled={form.formState.isSubmitting}>{children}</fieldset>
</form>
</FormProvider>
);
interface UseZodFormProps<S extends z.ZodSchema>
extends Exclude<UseFormProps<z.infer<S>>, 'resolver'> {
schema: S;
}
export const useZodForm = <S extends z.ZodSchema>({ schema, ...formProps }: UseZodFormProps<S>) =>
useForm({
...formProps,
resolver: zodResolver(schema)
});
export { z } from 'zod';

View File

@@ -0,0 +1,28 @@
import { PropsWithChildren, useId } from 'react';
import { useFormContext } from 'react-hook-form';
export interface UseFormFieldProps extends PropsWithChildren {
name: string;
// label: string;
}
export const useFormField = <P extends UseFormFieldProps>(props: P) => {
const { name, ...otherProps } = props;
const id = useId();
return {
formFieldProps: { id, name },
childProps: { ...otherProps, id, name }
};
};
interface FormFieldProps {
name: string;
}
export const FormField = ({ name, children }: PropsWithChildren<FormFieldProps>) => {
const ctx = useFormContext();
const _ = ctx.getFieldState(name);
return <>{children}</>;
};

View File

@@ -0,0 +1,18 @@
import { forwardRef } from 'react';
import * as Root from '../Input';
import { FormField, UseFormFieldProps, useFormField } from './FormField';
export interface InputProps extends UseFormFieldProps, Root.InputProps {
name: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const { formFieldProps, childProps } = useFormField(props);
return (
<FormField {...formFieldProps}>
<Root.Input {...childProps} ref={ref} />
</FormField>
);
});

View File

@@ -0,0 +1,20 @@
import { forwardRef } from 'react';
import { useController } from 'react-hook-form';
import * as Root from '../Switch';
import { FormField, UseFormFieldProps, useFormField } from './FormField';
export interface SwitchProps extends UseFormFieldProps, Root.SwitchProps {
name: string;
}
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const { field } = useController(props);
const { formFieldProps, childProps } = useFormField(props);
return (
<FormField {...formFieldProps}>
<Root.Switch {...childProps} value={field.value} onCheckedChange={field.value} ref={ref} />
</FormField>
);
});

View File

@@ -0,0 +1,5 @@
export * from './Form';
export * from './FormField';
export * from './CheckBox';
export * from './Input';
export * from './Switch';

View File

@@ -11,4 +11,6 @@ export * from './Switch';
export * as Tabs from './Tabs';
export * from './Typography';
export * from './utils';
export * from './CheckBox';
export * as forms from './forms';
export { cva, cx } from 'class-variance-authority';

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.