teableio/teable

View on GitHub
apps/nextjs-app/src/features/app/components/setting/account/ChangePasswordDialog.tsx

Summary

Maintainability
A
1 hr
Test Coverage
import { useMutation } from '@tanstack/react-query';
import type { HttpError } from '@teable/core';
import { changePassword, changePasswordRoSchema } from '@teable/openapi';
import { useSession } from '@teable/sdk/hooks';
import { Spin } from '@teable/ui-lib/base';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  Input,
  Label,
  useToast,
} from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { fromZodError } from 'zod-validation-error';

interface IChangePasswordDialogProps {
  children?: React.ReactNode;
}
export const ChangePasswordDialog = (props: IChangePasswordDialogProps) => {
  const { children } = props;
  const { t } = useTranslation('common');
  const router = useRouter();
  const { user } = useSession();
  const { toast } = useToast();
  const [newPassword, setNewPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [currentPassword, setCurrentPassword] = useState('');
  const [error, setError] = useState('');

  const {
    mutate: changePasswordMutate,
    isLoading,
    isSuccess,
  } = useMutation(changePassword, {
    onSuccess: () => {
      toast({
        title: t('settings.account.changePasswordSuccess.title'),
        description: t('settings.account.changePasswordSuccess.desc'),
      });
      setTimeout(() => {
        router.reload();
      }, 2000);
    },
    onError: (err: HttpError) => {
      console.error(err.message);
      setError(t('settings.account.changePasswordError.invalid'));
    },
  });

  const checkConfirmEqual = () => {
    if (newPassword && confirmPassword && newPassword !== confirmPassword) {
      setError(t('settings.account.changePasswordError.disMatch'));
      return;
    }
    if (newPassword && confirmPassword && currentPassword === newPassword) {
      setError(t('settings.account.changePasswordError.equal'));
      return;
    }
    setError('');
  };

  const reset = () => {
    setNewPassword('');
    setConfirmPassword('');
    setCurrentPassword('');
    setError('');
  };

  const disableSubmitBtn =
    !currentPassword || !newPassword || !confirmPassword || newPassword !== confirmPassword;

  const handleSubmit = async () => {
    const valid = changePasswordRoSchema.safeParse({ password: currentPassword, newPassword });
    if (!valid.success) {
      console.error(fromZodError(valid.error).message);
      setError(t('settings.account.changePasswordError.invalidNew'));
      return;
    }
    changePasswordMutate({ password: currentPassword, newPassword });
  };

  return (
    <Dialog onOpenChange={reset}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent className="md:w-80">
        <DialogHeader>
          <DialogTitle className="text-center text-sm">
            {t('settings.account.changePassword.title')}
          </DialogTitle>
          <DialogDescription className="text-center text-xs">
            {t('settings.account.changePassword.desc')}
          </DialogDescription>
        </DialogHeader>
        <div className="space-y-2">
          <div className="space-y-1">
            <Input
              className="visible m-0 h-0 border-0 p-0 text-[0]"
              type="text"
              name="email"
              autoComplete="email"
              readOnly
              value={user.email}
            />
            <Label className="text-xs text-muted-foreground" htmlFor="currentPassword">
              {t('settings.account.changePassword.current')}
            </Label>
            <Input
              className="h-7"
              id="currentPassword"
              autoComplete="current-password"
              type="password"
              value={currentPassword}
              onChange={(e) => setCurrentPassword(e.target.value)}
              aria-autocomplete="inline"
            />
          </div>
          <div className="space-y-1">
            <Label className="text-xs text-muted-foreground" htmlFor="newPassword">
              {t('settings.account.changePassword.new')}
            </Label>
            <Input
              className="h-7"
              id="newPassword"
              autoComplete="new-password"
              type="password"
              value={newPassword}
              onChange={(e) => setNewPassword(e.target.value)}
              onBlur={checkConfirmEqual}
              aria-autocomplete="inline"
            />
          </div>
          <div className="space-y-1">
            <Label className="text-xs text-muted-foreground" htmlFor="confirmPassword">
              {t('settings.account.changePassword.confirm')}
            </Label>
            <Input
              className="h-7"
              id="confirmPassword"
              autoComplete="new-password"
              type="password"
              value={confirmPassword}
              onChange={(e) => setConfirmPassword(e.target.value)}
              onBlur={checkConfirmEqual}
              aria-autocomplete="inline"
            />
          </div>
          {error && <div className="text-center text-xs text-red-500">{error}</div>}
        </div>
        <DialogFooter className="flex-col space-y-2 sm:flex-col sm:space-x-0">
          <Button
            size={'sm'}
            className="w-full"
            type="submit"
            disabled={disableSubmitBtn || isSuccess || isLoading}
            onClick={handleSubmit}
          >
            {isLoading && <Spin className="mr-1 size-4" />}
            {t('settings.account.changePassword.title')}
          </Button>
          <DialogClose asChild>
            <Button size={'sm'} className="w-full" variant={'ghost'}>
              {t('actions.cancel')}
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};