fix(settings): fix toast crash, schema validation, radio group, and delete dialog
- Fix toast calls in useTwoFactorSetup.ts: use toast.success() instead of direct toast() which crashes because the Proxy target is not callable - Add playback field to settingsSchema.ts so Save Config validates correctly - Refactor RadioGroup to use React Context instead of Children.map, fixing mutual exclusion when items are wrapped in divs. Add name attr. - Fix Delete Account dialog auto-closing without validation by using custom footer with disabled confirm button when DELETE not typed Fixes: Settings bugs #3, #5, #6, #9 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d1f9a815d
commit
2309a6d7d5
4 changed files with 90 additions and 163 deletions
|
|
@ -54,7 +54,7 @@ export function useTwoFactorSetup(onBack: () => void, _onComplete: () => void) {
|
|||
const copyCodes = useCallback(() => {
|
||||
if (!setupData?.recovery_codes) return;
|
||||
navigator.clipboard.writeText(setupData.recovery_codes.join('\n'));
|
||||
toast('Backup codes copied to clipboard');
|
||||
toast.success('Backup codes copied to clipboard');
|
||||
}, [setupData]);
|
||||
|
||||
const downloadCodes = useCallback(() => {
|
||||
|
|
@ -67,7 +67,7 @@ export function useTwoFactorSetup(onBack: () => void, _onComplete: () => void) {
|
|||
element.download = 'veza-backup-codes.txt';
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
toast('Backup codes downloaded');
|
||||
toast.success('Backup codes downloaded');
|
||||
}, [setupData]);
|
||||
|
||||
const goToStep2Totp = useCallback(() => {
|
||||
|
|
@ -76,7 +76,7 @@ export function useTwoFactorSetup(onBack: () => void, _onComplete: () => void) {
|
|||
}, []);
|
||||
|
||||
const handleSmsUnavailable = useCallback(() => {
|
||||
toast('SMS method not yet available in this region', { icon: 'ℹ️' });
|
||||
toast.success('SMS method not yet available in this region');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,222 +2,132 @@ import * as React from 'react';
|
|||
import { Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* RadioGroupProps - Propriétés du composant RadioGroup
|
||||
*
|
||||
* @interface RadioGroupProps
|
||||
* @extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>
|
||||
*/
|
||||
export interface RadioGroupProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
/**
|
||||
* Valeur sélectionnée du groupe de boutons radio
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RadioGroup value={selected} onValueChange={setSelected}>
|
||||
* <RadioGroupItem value="option1" />
|
||||
* <RadioGroupItem value="option2" />
|
||||
* </RadioGroup>
|
||||
* ```
|
||||
*/
|
||||
// Context to pass RadioGroup state to deeply nested RadioGroupItems
|
||||
interface RadioGroupContextValue {
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* Fonction appelée lorsque la valeur sélectionnée change
|
||||
*
|
||||
* @param {string} value - Nouvelle valeur sélectionnée
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RadioGroup onValueChange={(value) => console.log('Selected:', value)}>
|
||||
* ...
|
||||
* </RadioGroup>
|
||||
* ```
|
||||
*/
|
||||
onValueChange?: (value: string) => void;
|
||||
|
||||
/**
|
||||
* Si `true`, désactive tous les boutons radio du groupe
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RadioGroup - Composant de groupe de boutons radio avec design system Kodo
|
||||
*
|
||||
* Composant pour gérer un groupe de boutons radio mutuellement exclusifs.
|
||||
* Utilise le design system Kodo avec des styles cohérents et support pour l'accessibilité.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Groupe de boutons radio simple
|
||||
* <RadioGroup value={selected} onValueChange={setSelected}>
|
||||
* <RadioGroupItem value="option1" />
|
||||
* <RadioGroupItem value="option2" />
|
||||
* <RadioGroupItem value="option3" />
|
||||
* </RadioGroup>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Avec labels
|
||||
* <RadioGroup value={selected} onValueChange={setSelected}>
|
||||
* <label>
|
||||
* <RadioGroupItem value="option1" />
|
||||
* Option 1
|
||||
* </label>
|
||||
* <label>
|
||||
* <RadioGroupItem value="option2" />
|
||||
* Option 2
|
||||
* </label>
|
||||
* </RadioGroup>
|
||||
* ```
|
||||
*
|
||||
* @component
|
||||
* @param {RadioGroupProps} props - Propriétés du composant
|
||||
* @returns {JSX.Element} Élément div avec role="radiogroup" contenant les boutons radio
|
||||
*/
|
||||
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);
|
||||
|
||||
function useRadioGroupContext() {
|
||||
return React.useContext(RadioGroupContext);
|
||||
}
|
||||
|
||||
export interface RadioGroupProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ className, value, onValueChange, disabled, children, ...props }, ref) => {
|
||||
// Collect RadioGroupItem values for arrow key navigation
|
||||
const itemValues: string[] = [];
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === RadioGroupItem && !child.props.disabled) {
|
||||
itemValues.push(child.props.value);
|
||||
}
|
||||
});
|
||||
({ className, value, onValueChange, disabled, name, children, ...props }, ref) => {
|
||||
const generatedId = React.useId();
|
||||
const groupName = name || `radio-group-${generatedId}`;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (itemValues.length === 0) return;
|
||||
const currentIndex = value ? itemValues.indexOf(value) : -1;
|
||||
const radios = (e.currentTarget as HTMLElement).querySelectorAll<HTMLInputElement>(
|
||||
'input[type="radio"]:not(:disabled)',
|
||||
);
|
||||
if (radios.length === 0) return;
|
||||
|
||||
const values = Array.from(radios).map((r) => r.value);
|
||||
const currentIndex = value ? values.indexOf(value) : -1;
|
||||
let nextIndex: number | undefined;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowRight':
|
||||
nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % itemValues.length;
|
||||
nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % values.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowLeft':
|
||||
nextIndex = currentIndex === -1 ? itemValues.length - 1 : (currentIndex - 1 + itemValues.length) % itemValues.length;
|
||||
nextIndex =
|
||||
currentIndex === -1
|
||||
? values.length - 1
|
||||
: (currentIndex - 1 + values.length) % values.length;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const nextValue = itemValues[nextIndex];
|
||||
const nextValue = values[nextIndex];
|
||||
if (nextValue !== undefined) onValueChange?.(nextValue);
|
||||
// Focus the newly selected radio
|
||||
const radios = (e.currentTarget as HTMLElement).querySelectorAll<HTMLInputElement>('input[type="radio"]');
|
||||
radios[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ value, onValueChange, disabled, name: groupName }),
|
||||
[value, onValueChange, disabled, groupName],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('grid gap-2', className)}
|
||||
role="radiogroup"
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === RadioGroupItem) {
|
||||
const isChecked = child.props.value === value;
|
||||
const isFirstItem = child.props.value === itemValues[0];
|
||||
return React.cloneElement(child, {
|
||||
checked: isChecked,
|
||||
onCheckedChange: () => onValueChange?.(child.props.value),
|
||||
disabled: disabled || child.props.disabled,
|
||||
// Only the checked item (or first if none checked) gets tabIndex 0
|
||||
tabIndex: isChecked ? 0 : (value === undefined && isFirstItem ? 0 : -1),
|
||||
} as any);
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
<RadioGroupContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('grid gap-2', className)}
|
||||
role="radiogroup"
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</RadioGroupContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioGroup.displayName = 'RadioGroup';
|
||||
|
||||
/**
|
||||
* RadioGroupItemProps - Propriétés du composant RadioGroupItem
|
||||
*
|
||||
* @interface RadioGroupItemProps
|
||||
* @extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'>
|
||||
*/
|
||||
export interface RadioGroupItemProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
/**
|
||||
* Valeur unique du bouton radio (doit être unique dans le groupe)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RadioGroupItem value="option1" />
|
||||
* ```
|
||||
*/
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* État checked du bouton radio (géré automatiquement par RadioGroup)
|
||||
* @internal
|
||||
*/
|
||||
checked?: boolean;
|
||||
|
||||
/**
|
||||
* Fonction appelée lors du clic (gérée automatiquement par RadioGroup)
|
||||
* @internal
|
||||
*/
|
||||
onCheckedChange?: () => void;
|
||||
|
||||
/**
|
||||
* Tab index for keyboard navigation (managed by RadioGroup)
|
||||
* @internal
|
||||
*/
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RadioGroupItem - Bouton radio individuel
|
||||
*
|
||||
* Bouton radio à utiliser à l'intérieur d'un RadioGroup.
|
||||
* L'état checked est géré automatiquement par le RadioGroup parent.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
||||
({ className, value, checked, onCheckedChange, disabled, tabIndex, ...props }, ref) => {
|
||||
({ className, value, checked: checkedProp, onCheckedChange, disabled: disabledProp, tabIndex, ...props }, ref) => {
|
||||
const ctx = useRadioGroupContext();
|
||||
|
||||
// Use context values if available, otherwise fall back to props
|
||||
const isChecked = ctx ? ctx.value === value : !!checkedProp;
|
||||
const isDisabled = ctx ? ctx.disabled || disabledProp : disabledProp;
|
||||
const handleChange = ctx
|
||||
? () => ctx.onValueChange?.(value)
|
||||
: onCheckedChange;
|
||||
const radioName = ctx?.name;
|
||||
|
||||
return (
|
||||
<label
|
||||
role="radio"
|
||||
aria-checked={!!checked}
|
||||
aria-checked={isChecked}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-border text-muted-foreground',
|
||||
'ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'cursor-pointer relative inline-flex items-center justify-center',
|
||||
checked && 'border-primary text-primary',
|
||||
isChecked && 'border-primary text-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
name={radioName}
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
tabIndex={tabIndex}
|
||||
className="sr-only"
|
||||
{...props}
|
||||
/>
|
||||
{checked && (
|
||||
{isChecked && (
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
)}
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -72,10 +72,21 @@ export function AccountSettingsDeleteCard({
|
|||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
title="Are you absolutely sure?"
|
||||
variant="alert"
|
||||
onConfirm={onDeleteAccount}
|
||||
confirmLabel={isDeletingAccount ? 'Deleting...' : 'Delete Account'}
|
||||
cancelLabel="Cancel"
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteAccount}
|
||||
disabled={isDeletingAccount || deleteConfirmText !== 'DELETE'}
|
||||
>
|
||||
{isDeletingAccount ? 'Deleting...' : 'Delete Account'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export const settingsSchema = z.object({
|
|||
}),
|
||||
}),
|
||||
}),
|
||||
playback: z.object({
|
||||
quality: z.enum(['low', 'medium', 'high', 'lossless']),
|
||||
volume: z.number().min(0).max(1),
|
||||
crossfade: z.number().min(0).max(12),
|
||||
autoplay: z.boolean(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue