[ENG 485] Improve create library dialog (#712)

* Move dialogs into router

* Prevent tab focus on show password button

* Add className props to radio group

* Add password strength meter to password input

* Add steps to dialog

* Update create library dialog

* Update select component

* Add select form field

* Add advanced options to create lib dialog

* Remove spacing

* Update checkbox styles

* Revert to single page, add checkbox & accordion, remove privacy

* Revert "Add className props to radio group"

This reverts commit e53e53cbdf.

* Move schema, switch to camel case

* Revert "Add steps to dialog"

This reverts commit e672e1c472.

* Revert "Prevent tab focus on show password button"

This reverts commit d42e4dc988.

* Add margin

* add debounce to password strength

* Update select
This commit is contained in:
nikec
2023-04-19 09:54:55 +02:00
committed by GitHub
parent f684660a90
commit a7db52478c
12 changed files with 369 additions and 237 deletions

View File

@@ -1,223 +1,222 @@
import { useQueryClient } from '@tanstack/react-query';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash, Info } from 'phosphor-react';
import { useState } from 'react';
import clsx from 'clsx';
import { CaretRight } from 'phosphor-react';
import { useEffect, useState } from 'react';
import { Controller } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import {
Algorithm,
HASHING_ALGOS,
HashingAlgoSlug,
LibraryConfigWrapped,
hashingAlgoSlugSchema,
useBridgeMutation,
usePlausibleEvent
} from '@sd/client';
import {
Button,
CheckBox,
Dialog,
Select,
RadixCheckbox,
SelectOption,
Tooltip,
UseDialogProps,
forms,
useDialog
} from '@sd/ui';
import { PasswordMeter } from '~/components/PasswordMeter';
import { generatePassword } from '~/util';
const { Input, z, useZodForm } = forms;
const { Input, z, useZodForm, PasswordInput, Select } = forms;
const schema = z.object({
name: z.string(),
password: z.string(),
password_validate: z.string(),
algorithm: z.string(),
hashing_algorithm: hashingAlgoSlugSchema,
share_telemetry: z.boolean()
});
export default (props: UseDialogProps) => {
const dialog = useDialog(props);
const submitPlausibleEvent = usePlausibleEvent();
const form = useZodForm({
schema,
defaultValues: {
password: '',
algorithm: 'XChaCha20Poly1305',
hashing_algorithm: 'Argon2id-s'
const schema = z
.object({
name: z.string().min(1),
encryptLibrary: z.boolean(),
password: z.string(),
passwordValidate: z.string(),
algorithm: z.enum(['XChaCha20Poly1305', 'Aes256Gcm']),
hashingAlgorithm: hashingAlgoSlugSchema
})
.superRefine((data, ctx) => {
if (data.encryptLibrary && !data.password) {
ctx.addIssue({
code: 'custom',
path: ['password'],
message: 'Password is required'
});
}
if (data.password && data.password !== data.passwordValidate) {
ctx.addIssue({
code: 'custom',
path: ['passwordValidate'],
message: 'Passwords do not match'
});
}
});
const [showMasterPassword1, setShowMasterPassword1] = useState(false);
const [showMasterPassword2, setShowMasterPassword2] = useState(false);
const MP1CurrentEyeIcon = showMasterPassword1 ? EyeSlash : Eye;
const MP2CurrentEyeIcon = showMasterPassword2 ? EyeSlash : Eye;
export default (props: UseDialogProps) => {
const dialog = useDialog(props);
const navigate = useNavigate();
const queryClient = useQueryClient();
const submitPlausibleEvent = usePlausibleEvent();
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const createLibrary = useBridgeMutation('library.create', {
onSuccess: (library) => {
queryClient.setQueryData(['library.list'], (libraries: any) => [
...(libraries || []),
library
]);
queryClient.setQueryData(
['library.list'],
(libraries: LibraryConfigWrapped[] | undefined) => [...(libraries || []), library]
);
submitPlausibleEvent({
event: {
type: 'libraryCreate'
}
});
navigate(`/${library.uuid}/overview`);
},
onError: (err: any) => {
console.error(err);
onError: (err) => console.log(err)
});
const form = useZodForm({
schema: schema,
defaultValues: {
encryptLibrary: false,
password: '',
passwordValidate: '',
algorithm: 'XChaCha20Poly1305',
hashingAlgorithm: 'Argon2id-s'
}
});
const onSubmit = form.handleSubmit(async (data) => {
if (data.password !== data.password_validate) {
alert('Passwords are not the same');
} else {
await createLibrary.mutateAsync({
...data,
algorithm: data.algorithm as Algorithm,
hashing_algorithm: HASHING_ALGOS[data.hashing_algorithm],
auth: {
type: 'Password',
value: data.password
}
});
}
await createLibrary.mutateAsync({
name: data.name,
algorithm: data.algorithm,
hashing_algorithm: HASHING_ALGOS[data.hashingAlgorithm],
auth: {
type: 'Password',
value: data.encryptLibrary ? data.password : ''
}
});
});
const encryptLibrary = form.watch('encryptLibrary');
useEffect(() => {
if (showAdvancedOptions) setShowAdvancedOptions(false);
}, [encryptLibrary]);
return (
<Dialog
form={form}
onSubmit={onSubmit}
dialog={dialog}
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."
submitDisabled={!form.formState.isValid}
ctaLabel="Create"
title="Create New Library"
description="Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data."
ctaLabel={form.formState.isSubmitting ? 'Creating library...' : 'Create library'}
>
<Input
label="Library name"
placeholder="My Cool Library"
className="mt-2"
{...form.register('name', { required: true })}
/>
<div className="mt-3 mb-1 flex flex-row items-center">
<div className="space-x-2">
<CheckBox
className="bg-app-selected"
defaultChecked={true}
{...form.register('share_telemetry', { required: true })}
/>
</div>
<span className="mt-1 text-xs font-medium">Share anonymous usage</span>
<Tooltip label="Share completely anonymous telemetry data to help the developers improve the app">
<Info className="ml-1.5 h-4 w-4 text-ink-faint" />
</Tooltip>
</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="mt-2 mb-1 text-center text-[0.95rem] font-bold">Key Manager</p>
<div className="my-1 h-[2px] w-full bg-gray-500" />
<div className="mt-5 space-y-4">
<Input
label="Master password"
placeholder="Password"
type={showMasterPassword1 ? 'text' : 'password'}
className="mt-2"
{...form.register('password')}
right={
<div className="flex">
<Button
onClick={() => {
const password = generatePassword(32);
form.setValue('password', password);
form.setValue('password_validate', password);
setShowMasterPassword1(true);
setShowMasterPassword2(true);
}}
size="icon"
>
<ArrowsClockwise className="h-4 w-4" />
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(form.watch('password') as string);
}}
size="icon"
>
<Clipboard className="h-4 w-4" />
</Button>
<Button
onClick={() => setShowMasterPassword1(!showMasterPassword1)}
size="icon"
>
<MP1CurrentEyeIcon className="h-4 w-4" />
</Button>
</div>
}
{...form.register('name')}
label="Library name"
placeholder={'e.g. "James\' Library"'}
size="md"
/>
<Controller
control={form.control}
name="encryptLibrary"
render={({ field }) => (
<RadixCheckbox
checked={field.value}
onCheckedChange={field.onChange}
label="Encrypt Library"
name="encryptLibrary"
/>
)}
/>
{encryptLibrary && (
<>
<div className="border-b border-app-line" />
<PasswordInput
{...form.register('password')}
label="Password"
showStrength
/>
<PasswordInput
{...form.register('passwordValidate', {
onBlur: () => form.trigger('passwordValidate')
})}
label="Confirm password"
/>
<div className="rounded-md border border-app-line bg-app-overlay">
<Button
variant="bare"
className={clsx(
'flex w-full border-none !p-3',
showAdvancedOptions && 'rounded-b-none'
)}
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
>
Advanced Settings
<CaretRight
weight="bold"
className={clsx(
'ml-1 transition',
showAdvancedOptions && 'rotate-90'
)}
/>
</Button>
{showAdvancedOptions && (
<div className="space-y-4 p-3 pt-0">
<div className="h-px bg-app-line" />
<Select
control={form.control}
name="algorithm"
label="Algorithm"
size="md"
className="!mt-3"
>
<SelectOption value="XChaCha20Poly1305">
XChaCha20-Poly1305
</SelectOption>
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
</Select>
<Select
control={form.control}
name="hashingAlgorithm"
label="Hashing Algorithm"
size="md"
>
<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>
<Input
label="Master password (again)"
placeholder="Password"
type={showMasterPassword2 ? 'text' : 'password'}
className="mt-2"
right={
<Button
onClick={() => setShowMasterPassword2(!showMasterPassword2)}
size="icon"
>
<MP2CurrentEyeIcon className="h-4 w-4" />
</Button>
}
{...form.register('password_validate')}
/>
<div className="mt-4 mb-3 grid w-full grid-cols-2 gap-4">
<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 as HashingAlgoSlug)}
>
<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')} />
</Dialog>
);
};

View File

@@ -1,5 +1,6 @@
import { Navigate, Outlet, RouteObject } from 'react-router-dom';
import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client';
import { Dialogs } from '@sd/ui';
import { useKeybindHandler } from '~/hooks/useKeyboardHandler';
import libraryRoutes from './$libraryId';
import onboardingRoutes from './onboarding';
@@ -23,7 +24,12 @@ const Wrapper = () => {
useKeybindHandler();
useInvalidateQuery();
return <Outlet />;
return (
<>
<Outlet />
<Dialogs />
</>
);
};
// NOTE: all route `Layout`s below should contain

View File

@@ -5,7 +5,7 @@ import { Form, RadioGroup, useZodForm, z } from '@sd/ui/src/forms';
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './Layout';
import { useUnlockOnboardingScreen } from './Progress';
const shareTelemetry = RadioGroup.options([
export const shareTelemetry = RadioGroup.options([
z.literal('share-telemetry'),
z.literal('no-telemetry')
]).details({

View File

@@ -9,7 +9,6 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import { ErrorBoundary } from 'react-error-boundary';
import { RouterProvider, RouterProviderProps } from 'react-router-dom';
import { useDebugState } from '@sd/client';
import { Dialogs } from '@sd/ui';
import ErrorFallback from './ErrorFallback';
export * from './util/keybind';
@@ -49,7 +48,6 @@ export const SpacedriveInterface = (props: { router: RouterProviderProps['router
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Devtools />
<RouterProvider router={props.router} />
<Dialogs />
</ErrorBoundary>
);
};

View File

@@ -41,7 +41,8 @@
"react-loading-icons": "^1.1.0",
"react-router-dom": "6.9.0",
"react-spring": "^9.5.5",
"tailwindcss-radix": "^2.6.0"
"tailwindcss-radix": "^2.6.0",
"use-debounce": "^9.0.4"
},
"devDependencies": {
"@babel/core": "^7.19.3",

View File

@@ -25,18 +25,18 @@ export interface RadixCheckboxProps extends ComponentProps<typeof Checkbox.Root>
// TODO: Replace above with this, requires refactor of usage
export const RadixCheckbox = (props: RadixCheckboxProps) => (
<div className="align-center flex">
<div className="flex items-center">
<Checkbox.Root
className="flex h-[17px] w-[17px] shrink-0 rounded-md bg-app-button"
id={props.name}
{...props}
>
<Checkbox.Indicator className="flex h-[17px] w-[17px] items-center justify-center rounded-md bg-accent">
<Check weight="bold" />
<Check weight="bold" size={14} />
</Checkbox.Indicator>
</Checkbox.Root>
{props.label && (
<label className=" ml-2 font-medium" htmlFor={props.name}>
<label className="ml-2 text-sm font-medium" htmlFor={props.name}>
{props.label}
</label>
)}

View File

@@ -1,69 +1,86 @@
import { ReactComponent as ChevronDouble } from '@sd/assets/svgs/chevron-double.svg';
import * as SelectPrimitive from '@radix-ui/react-select';
import * as RS from '@radix-ui/react-select';
import { VariantProps, cva } from 'class-variance-authority';
import clsx from 'clsx';
import { CaretDown, Check } from 'phosphor-react';
import { Check } from 'phosphor-react';
import { PropsWithChildren } from 'react';
interface SelectProps {
export const selectStyles = cva(
[
'rounded-md border text-sm flex pl-3 pr-[10px] items-center justify-between',
'shadow-sm outline-none transition-all focus:ring-2',
'radix-placeholder:text-ink-faint'
],
{
variants: {
variant: {
default: [
'bg-app-input focus:bg-app-focus',
'border-app-line focus:border-app-divider/80',
'focus:ring-app-selected/30'
]
},
size: {
sm: 'h-[30px]',
md: 'h-[34px]',
lg: 'h-[38px]'
}
},
defaultVariants: {
variant: 'default',
size: 'sm'
}
}
);
export interface SelectProps extends VariantProps<typeof selectStyles> {
value: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}
export function Select(props: PropsWithChildren<SelectProps>) {
return (
<SelectPrimitive.Root
<RS.Root
defaultValue={props.value}
value={props.value}
onValueChange={props.onChange}
disabled={props.disabled}
>
<SelectPrimitive.Trigger
className={clsx(
'inline-flex items-center border bg-app-box py-0.5 pl-2',
'rounded-md border-app-line shadow shadow-app-shade/10 outline-none',
props.className
)}
>
<span className="grow truncate text-left text-xs">
<SelectPrimitive.Value />
</span>
<RS.Trigger className={selectStyles({ size: props.size, className: props.className })}>
<RS.Value placeholder={props.placeholder} />
<RS.Icon className="ml-2">
<ChevronDouble className="text-ink-dull" />
</RS.Icon>
</RS.Trigger>
<SelectPrimitive.Icon>
<ChevronDouble className="mr-0.5 h-3 w-3 text-ink-dull" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal className="relative">
<SelectPrimitive.Content className="absolute z-50 w-full rounded-md border border-app-line bg-app-box p-1 shadow-2xl shadow-app-shade/20 ">
<SelectPrimitive.ScrollUpButton className="hidden ">
<CaretDown />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport>{props.children}</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="hidden "></SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
<RS.Portal>
<RS.Content className="z-50 rounded-md border border-app-line bg-app-box shadow-2xl shadow-app-shade/20 ">
<RS.Viewport className="p-1">{props.children}</RS.Viewport>
</RS.Content>
</RS.Portal>
</RS.Root>
);
}
export function SelectOption(props: PropsWithChildren<{ value: string; default?: boolean }>) {
return (
<SelectPrimitive.Item
className={clsx(
'relative flex items-center px-1 py-0.5 pl-6 pr-4 text-xs',
'font-sm cursor-pointer select-none rounded text-ink',
'hover:bg-accent hover:text-white focus:outline-none radix-disabled:opacity-50 '
)}
defaultChecked={props.default || false}
<RS.Item
value={props.value}
defaultChecked={props.default}
className={clsx(
'relative flex h-6 cursor-pointer select-none items-center rounded pl-6 pr-3',
'text-sm text-ink radix-highlighted:text-white',
'focus:outline-none radix-disabled:opacity-50 radix-highlighted:bg-accent '
)}
>
<SelectPrimitive.ItemText>{props.children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="absolute left-1 inline-flex items-center">
<RS.ItemText>{props.children}</RS.ItemText>
<RS.ItemIndicator className="absolute left-1 inline-flex items-center">
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
</RS.ItemIndicator>
</RS.Item>
);
}

View File

@@ -1,4 +1,4 @@
import { PropsWithChildren, useId } from 'react';
import { PropsWithChildren, ReactNode, useId } from 'react';
import { useFormContext } from 'react-hook-form';
export interface UseFormFieldProps extends PropsWithChildren {
@@ -19,9 +19,10 @@ export const useFormField = <P extends UseFormFieldProps>(props: P) => {
};
};
interface FormFieldProps extends UseFormFieldProps {
interface FormFieldProps extends Omit<UseFormFieldProps, 'label'> {
id: string;
error?: string;
label?: string | ReactNode;
}
export const FormField = (props: FormFieldProps) => {

View File

@@ -1,5 +1,10 @@
import { forwardRef } from 'react';
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import zxcvbnEnPackage from '@zxcvbn-ts/language-en';
import clsx from 'clsx';
import { forwardRef, useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useDebounce, useDebouncedCallback } from 'use-debounce';
import * as Root from '../Input';
import { FormField, UseFormFieldProps, useFormField } from './FormField';
@@ -17,16 +22,96 @@ export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
);
});
export const PasswordInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const { formFieldProps, childProps } = useFormField(props);
export interface PasswordInputProps extends UseFormFieldProps, Root.InputProps {
name: string;
showStrength?: boolean;
}
const PasswordStrengthMeter = (props: { password: string }) => {
const [strength, setStrength] = useState<{ label: string; score: number }>();
const updateStrength = useDebouncedCallback(
() => setStrength(props.password ? getPasswordStrength(props.password) : undefined),
100
);
// TODO: Remove duplicate in @sd/client
function getPasswordStrength(password: string): { label: string; score: number } {
const ratings = ['Poor', 'Weak', 'Good', 'Strong', 'Perfect'];
zxcvbnOptions.setOptions({
dictionary: {
...zxcvbnCommonPackage.dictionary,
...zxcvbnEnPackage.dictionary
},
graphs: zxcvbnCommonPackage.adjacencyGraphs,
translations: zxcvbnEnPackage.translations
});
const result = zxcvbn(password);
return { label: ratings[result.score]!, score: result.score };
}
useEffect(() => updateStrength(), [props.password]);
return (
<FormField {...formFieldProps}>
<Root.PasswordInput
{...childProps}
ref={ref}
error={formFieldProps.error !== undefined}
/>
</FormField>
<div className="flex grow items-center justify-end">
{strength && (
<span
className={clsx(
'mr-2 text-xs',
strength.score === 0 && 'text-red-500',
strength.score === 1 && 'text-red-500',
strength.score === 2 && 'text-amber-400',
strength.score === 3 && 'text-lime-500',
strength.score === 4 && 'text-accent'
)}
>
{strength.label}
</span>
)}
<div className={clsx('h-[6px] w-1/4 rounded-full bg-app-selected')}>
{strength && (
<div
style={{
width: `${strength.score !== 0 ? strength.score * 25 : 12.5}%`
}}
className={clsx(
'h-full rounded-full',
strength.score === 0 && 'bg-red-500',
strength.score === 1 && 'bg-red-500',
strength.score === 2 && 'bg-amber-400',
strength.score === 3 && 'bg-lime-500',
strength.score === 4 && 'bg-accent'
)}
/>
)}
</div>
</div>
);
});
};
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ showStrength, ...props }, ref) => {
const { formFieldProps, childProps } = useFormField(props);
const { watch } = useFormContext();
return (
<FormField
{...formFieldProps}
label={
<>
{formFieldProps.label}
{showStrength && <PasswordStrengthMeter password={watch(props.name)} />}
</>
}
>
<Root.PasswordInput
{...childProps}
ref={ref}
error={formFieldProps.error !== undefined}
/>
</FormField>
);
}
);

View File

@@ -0,0 +1,24 @@
import { FieldValues, UseControllerProps, useController } from 'react-hook-form';
import * as Root from '../Select';
import { FormField, UseFormFieldProps, useFormField } from './FormField';
export interface SelectProps<T extends FieldValues>
extends Omit<UseFormFieldProps, 'name'>,
Omit<Root.SelectProps, 'value' | 'onChange'>,
UseControllerProps<T> {}
export const Select = <T extends FieldValues>(props: SelectProps<T>) => {
const { formFieldProps, childProps } = useFormField(props);
const { field } = useController({ name: props.name });
return (
<FormField {...formFieldProps}>
<Root.Select
{...childProps}
className="w-full"
value={field.value}
onChange={field.onChange}
/>
</FormField>
);
};

View File

@@ -3,4 +3,5 @@ export * from './FormField';
export * from './CheckBox';
export * from './Input';
export * from './Switch';
export * from './Select';
export * as RadioGroup from './RadioGroup';

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.