- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience. - Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { Form } from '@inertiajs/react';
|
|
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
|
import { Check, Copy, ScanLine } from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import AlertError from '@/components/alert-error';
|
|
import InputError from '@/components/input-error';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
InputOTP,
|
|
InputOTPGroup,
|
|
InputOTPSlot,
|
|
} from '@/components/ui/input-otp';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import { useAppearance } from '@/hooks/use-appearance';
|
|
import { useClipboard } from '@/hooks/use-clipboard';
|
|
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
|
import { confirm } from '@/routes/two-factor';
|
|
|
|
function GridScanIcon() {
|
|
return (
|
|
<div className="mb-3 rounded-full border border-border bg-card p-0.5 shadow-sm">
|
|
<div className="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
|
|
<div className="absolute inset-0 grid grid-cols-5 opacity-50">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<div
|
|
key={`col-${i + 1}`}
|
|
className="border-r border-border last:border-r-0"
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="absolute inset-0 grid grid-rows-5 opacity-50">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<div
|
|
key={`row-${i + 1}`}
|
|
className="border-b border-border last:border-b-0"
|
|
/>
|
|
))}
|
|
</div>
|
|
<ScanLine className="relative z-20 size-6 text-foreground" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TwoFactorSetupStep({
|
|
qrCodeSvg,
|
|
manualSetupKey,
|
|
buttonText,
|
|
onNextStep,
|
|
errors,
|
|
}: {
|
|
qrCodeSvg: string | null;
|
|
manualSetupKey: string | null;
|
|
buttonText: string;
|
|
onNextStep: () => void;
|
|
errors: string[];
|
|
}) {
|
|
const { resolvedAppearance } = useAppearance();
|
|
const [copiedText, copy] = useClipboard();
|
|
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
|
|
|
return (
|
|
<>
|
|
{errors?.length ? (
|
|
<AlertError errors={errors} />
|
|
) : (
|
|
<>
|
|
<div className="mx-auto flex max-w-md overflow-hidden">
|
|
<div className="mx-auto aspect-square w-64 rounded-lg border border-border">
|
|
<div className="z-10 flex h-full w-full items-center justify-center p-5">
|
|
{qrCodeSvg ? (
|
|
<div
|
|
className="aspect-square w-full rounded-lg bg-white p-2 [&_svg]:size-full"
|
|
dangerouslySetInnerHTML={{
|
|
__html: qrCodeSvg,
|
|
}}
|
|
style={{
|
|
filter:
|
|
resolvedAppearance === 'dark'
|
|
? 'invert(1) brightness(1.5)'
|
|
: undefined,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Spinner />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex w-full space-x-5">
|
|
<Button className="w-full" onClick={onNextStep}>
|
|
{buttonText}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="relative flex w-full items-center justify-center">
|
|
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
|
<span className="relative bg-card px-2 py-1">
|
|
or, enter the code manually
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex w-full space-x-2">
|
|
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
|
|
{!manualSetupKey ? (
|
|
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
|
|
<Spinner />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={manualSetupKey}
|
|
className="h-full w-full bg-background p-3 text-foreground outline-none"
|
|
/>
|
|
<button
|
|
onClick={() => copy(manualSetupKey)}
|
|
className="border-l border-border px-3 hover:bg-muted"
|
|
>
|
|
<IconComponent className="w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TwoFactorVerificationStep({
|
|
onClose,
|
|
onBack,
|
|
}: {
|
|
onClose: () => void;
|
|
onBack: () => void;
|
|
}) {
|
|
const [code, setCode] = useState<string>('');
|
|
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
pinInputContainerRef.current?.querySelector('input')?.focus();
|
|
}, 0);
|
|
}, []);
|
|
|
|
return (
|
|
<Form
|
|
{...confirm.form()}
|
|
onSuccess={() => onClose()}
|
|
resetOnError
|
|
resetOnSuccess
|
|
onError={() => {
|
|
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
|
|
}}
|
|
>
|
|
{({
|
|
processing,
|
|
errors,
|
|
}: {
|
|
processing: boolean;
|
|
errors?: { confirmTwoFactorAuthentication?: { code?: string } };
|
|
}) => (
|
|
<>
|
|
<div
|
|
ref={pinInputContainerRef}
|
|
className="relative w-full space-y-3"
|
|
>
|
|
<div className="flex w-full flex-col items-center space-y-3 py-2">
|
|
<InputOTP
|
|
id="otp"
|
|
name="code"
|
|
maxLength={OTP_MAX_LENGTH}
|
|
onChange={setCode}
|
|
disabled={processing}
|
|
pattern={REGEXP_ONLY_DIGITS}
|
|
autoFocus
|
|
>
|
|
<InputOTPGroup>
|
|
{Array.from(
|
|
{ length: OTP_MAX_LENGTH },
|
|
(_, index) => (
|
|
<InputOTPSlot
|
|
key={index}
|
|
index={index}
|
|
/>
|
|
),
|
|
)}
|
|
</InputOTPGroup>
|
|
</InputOTP>
|
|
<InputError
|
|
message={
|
|
errors?.confirmTwoFactorAuthentication?.code
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex w-full space-x-5">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={onBack}
|
|
disabled={processing}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="flex-1"
|
|
disabled={
|
|
processing || code.length < OTP_MAX_LENGTH
|
|
}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
type Props = {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
requiresConfirmation: boolean;
|
|
twoFactorEnabled: boolean;
|
|
qrCodeSvg: string | null;
|
|
manualSetupKey: string | null;
|
|
clearSetupData: () => void;
|
|
fetchSetupData: () => Promise<void>;
|
|
errors: string[];
|
|
};
|
|
|
|
export default function TwoFactorSetupModal({
|
|
isOpen,
|
|
onClose,
|
|
requiresConfirmation,
|
|
twoFactorEnabled,
|
|
qrCodeSvg,
|
|
manualSetupKey,
|
|
clearSetupData,
|
|
fetchSetupData,
|
|
errors,
|
|
}: Props) {
|
|
const [showVerificationStep, setShowVerificationStep] =
|
|
useState<boolean>(false);
|
|
|
|
const modalConfig = useMemo<{
|
|
title: string;
|
|
description: string;
|
|
buttonText: string;
|
|
}>(() => {
|
|
if (twoFactorEnabled) {
|
|
return {
|
|
title: 'Two-factor authentication enabled',
|
|
description:
|
|
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
|
buttonText: 'Close',
|
|
};
|
|
}
|
|
|
|
if (showVerificationStep) {
|
|
return {
|
|
title: 'Verify authentication code',
|
|
description:
|
|
'Enter the 6-digit code from your authenticator app',
|
|
buttonText: 'Continue',
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: 'Enable two-factor authentication',
|
|
description:
|
|
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
|
buttonText: 'Continue',
|
|
};
|
|
}, [twoFactorEnabled, showVerificationStep]);
|
|
|
|
const resetModalState = useCallback(() => {
|
|
if (twoFactorEnabled) {
|
|
clearSetupData();
|
|
}
|
|
|
|
setShowVerificationStep(false);
|
|
}, [clearSetupData, twoFactorEnabled]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
resetModalState();
|
|
onClose();
|
|
}, [onClose, resetModalState]);
|
|
|
|
const handleModalNextStep = useCallback(() => {
|
|
if (requiresConfirmation) {
|
|
setShowVerificationStep(true);
|
|
|
|
return;
|
|
}
|
|
|
|
clearSetupData();
|
|
handleClose();
|
|
}, [requiresConfirmation, clearSetupData, handleClose]);
|
|
|
|
const fetchSetupDataRef = useRef(fetchSetupData);
|
|
|
|
useEffect(() => {
|
|
fetchSetupDataRef.current = fetchSetupData;
|
|
}, [fetchSetupData]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && !qrCodeSvg) {
|
|
fetchSetupDataRef.current();
|
|
}
|
|
}, [isOpen, qrCodeSvg]);
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader className="flex items-center justify-center">
|
|
<GridScanIcon />
|
|
<DialogTitle>{modalConfig.title}</DialogTitle>
|
|
<DialogDescription className="text-center">
|
|
{modalConfig.description}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col items-center space-y-5">
|
|
{showVerificationStep ? (
|
|
<TwoFactorVerificationStep
|
|
onClose={handleClose}
|
|
onBack={() => setShowVerificationStep(false)}
|
|
/>
|
|
) : (
|
|
<TwoFactorSetupStep
|
|
qrCodeSvg={qrCodeSvg}
|
|
manualSetupKey={manualSetupKey}
|
|
buttonText={modalConfig.buttonText}
|
|
onNextStep={handleModalNextStep}
|
|
errors={errors}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|