knowii-oss / knowii

https://knowii.net
GNU Affero General Public License v3.0
5 stars 1 forks source link

Add 2FA support #690

Open dsebastien opened 1 week ago

dsebastien commented 1 week ago

Pages of Jetstream: Pages/Auth/TwoFactorChallenge.tsx

import { useForm, Head } from '@inertiajs/react';
import classNames from 'classnames';
import React, { useRef, useState } from 'react';
import useRoute from '@/Hooks/useRoute';
import AuthenticationCard from '@/Components/AuthenticationCard';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput';
import InputError from '@/Components/InputError';

export default function TwoFactorChallenge() {
  const route = useRoute();
  const [recovery, setRecovery] = useState(false);
  const form = useForm({
    code: '',
    recovery_code: '',
  });
  const recoveryCodeRef = useRef<HTMLInputElement>(null);
  const codeRef = useRef<HTMLInputElement>(null);

  function toggleRecovery(e: React.FormEvent) {
    e.preventDefault();
    const isRecovery = !recovery;
    setRecovery(isRecovery);

    setTimeout(() => {
      if (isRecovery) {
        recoveryCodeRef.current?.focus();
        form.setData('code', '');
      } else {
        codeRef.current?.focus();
        form.setData('recovery_code', '');
      }
    }, 100);
  }

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    form.post(route('two-factor.login'));
  }

  return (
    <AuthenticationCard>
      <Head title="Two-Factor Confirmation" />

      <div className="mb-4 text-sm text-gray-600">
        {recovery
          ? 'Please confirm access to your account by entering one of your emergency recovery codes.'
          : 'Please confirm access to your account by entering the authentication code provided by your authenticator application.'}
      </div>

      <form onSubmit={onSubmit}>
        {recovery ? (
          <div>
            <InputLabel htmlFor="recovery_code">Recovery Code</InputLabel>
            <TextInput
              id="recovery_code"
              type="text"
              className="mt-1 block w-full"
              value={form.data.recovery_code}
              onChange={(e) => form.setData('recovery_code', e.currentTarget.value)}
              ref={recoveryCodeRef}
              autoComplete="one-time-code"
            />
            <InputError className="mt-2" message={form.errors.recovery_code} />
          </div>
        ) : (
          <div>
            <InputLabel htmlFor="code">Code</InputLabel>
            <TextInput
              id="code"
              type="text"
              inputMode="numeric"
              className="mt-1 block w-full"
              value={form.data.code}
              onChange={(e) => form.setData('code', e.currentTarget.value)}
              autoFocus
              autoComplete="one-time-code"
              ref={codeRef}
            />
            <InputError className="mt-2" message={form.errors.code} />
          </div>
        )}

        <div className="flex items-center justify-end mt-4">
          <button type="button" className="text-sm text-gray-600 hover:text-gray-900 underline cursor-pointer" onClick={toggleRecovery}>
            {recovery ? 'Use an authentication code' : 'Use a recovery code'}
          </button>

          <PrimaryButton className={classNames('ml-4', { 'opacity-25': form.processing })} disabled={form.processing}>
            Log in
          </PrimaryButton>
        </div>
      </form>
    </AuthenticationCard>
  );
}
dsebastien commented 1 week ago

Other:

import React, { useRef, useState } from 'react';
import { Head, useForm } from '@inertiajs/react';
import AuthenticationCard from '@/Components/AuthenticationCard';
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput';
import { FormEventHandler } from 'react';

const TwoFactorConfirmation: React.FC = () => {
  const [recovery, setRecovery] = useState(false);

  const form = useForm({
    code: '',
    recovery_code: '',
  });

  const recoveryCodeInput = useRef<HTMLInputElement>(null);
  const codeInput = useRef<HTMLInputElement>(null);

  const toggleRecovery = async () => {
    setRecovery(!recovery);

    if (recovery) {
      recoveryCodeInput.current?.focus();
      form.setData('code', '');
    } else {
      codeInput.current?.focus();
      form.setData('recovery_code', '');
    }
  };

  const submit: FormEventHandler = (e) => {
    e.preventDefault();
    form.post(route('two-factor.login'));
  };

  return (
    <>
      <Head title="Two-factor Confirmation" />

      <AuthenticationCard logo={<AuthenticationCardLogo />}>
        <div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
          {recovery ? (
            <>Please confirm access to your account by entering one of your emergency recovery codes.</>
          ) : (
            <>Please confirm access to your account by entering the authentication code provided by your authenticator application.</>
          )}
        </div>

        <form onSubmit={submit}>
          {!recovery ? (
            <>
              <InputLabel htmlFor="code" value="Code" />
              <TextInput
                id="code"
                ref={codeInput}
                value={form.data.code}
                onChange={(e) => form.setData('code', e.target.value)}
                type="text"
                inputMode="numeric"
                className="block w-full mt-1"
                autoFocus
                autoComplete="one-time-code"
              />
              <InputError className="mt-2" message={form.errors.code} />
            </>
          ) : (
            <>
              <InputLabel htmlFor="recovery_code" value="Recovery Code" />
              <TextInput
                id="recovery_code"
                ref={recoveryCodeInput}
                value={form.data.recovery_code}
                onChange={(e) => form.setData('recovery_code', e.target.value)}
                type="text"
                className="block w-full mt-1"
                autoComplete="one-time-code"
              />
              <InputError className="mt-2" message={form.errors.recovery_code} />
            </>
          )}

          <div className="flex items-center justify-end mt-4">
            <button
              type="button"
              className="text-sm text-gray-600 underline cursor-pointer dark:text-gray-400 hover:text-gray-900"
              onClick={toggleRecovery}
            >
              {recovery ? <>Use an authentication code</> : <>Use a recovery code</>}
            </button>

            <PrimaryButton className={`ms-4 ${form.processing ? 'opacity-25' : ''}`} disabled={form.processing}>
              Log in
            </PrimaryButton>
          </div>
        </form>
      </AuthenticationCard>
    </>
  );
};

export default TwoFactorConfirmation;
dsebastien commented 1 week ago

Vue version:

<script lang="ts" setup>
import { nextTick, ref } from 'vue';
import { Head, useForm } from '@inertiajs/vue3';
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';

const recovery = ref(false);

const form = useForm({
  code: '',
  recovery_code: '',
});

const recoveryCodeInput = ref(null);
const codeInput = ref(null);

const toggleRecovery = async () => {
  recovery.value ^= true;

  await nextTick();

  if (recovery.value) {
    recoveryCodeInput.value.focus();
    form.code = '';
  } else {
    codeInput.value.focus();
    form.recovery_code = '';
  }
};

const submit = () => {
  form.post(route('two-factor.login'));
};
</script>

<template>
  <Head title="Two-factor Confirmation" />

  <AuthenticationCard>
    <template #logo>
      <AuthenticationCardLogo />
    </template>

    <div class="mb-4 text-sm text-gray-600">
      <template v-if="!recovery">
        Please confirm access to your account by entering the authentication code provided by your authenticator application.
      </template>

      <template v-else> Please confirm access to your account by entering one of your emergency recovery codes. </template>
    </div>

    <form @submit.prevent="submit">
      <div v-if="!recovery">
        <InputLabel for="code" value="Code" />
        <TextInput
          id="code"
          ref="codeInput"
          v-model="form.code"
          type="text"
          inputmode="numeric"
          class="mt-1 block w-full"
          autofocus
          autocomplete="one-time-code"
        />
        <InputError class="mt-2" :message="form.errors.code" />
      </div>

      <div v-else>
        <InputLabel for="recovery_code" value="Recovery Code" />
        <TextInput
          id="recovery_code"
          ref="recoveryCodeInput"
          v-model="form.recovery_code"
          type="text"
          class="mt-1 block w-full"
          autocomplete="one-time-code"
        />
        <InputError class="mt-2" :message="form.errors.recovery_code" />
      </div>

      <div class="flex items-center justify-end mt-4">
        <button type="button" class="text-sm text-gray-600 hover:text-gray-900 underline cursor-pointer" @click.prevent="toggleRecovery">
          <template v-if="!recovery"> Use a recovery code </template>

          <template v-else> Use an authentication code </template>
        </button>

        <PrimaryButton class="ms-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing"> Log in </PrimaryButton>
      </div>
    </form>
  </AuthenticationCard>
</template>
dsebastien commented 1 week ago

Profile/Partials/TwoFactoryAuthenticationForm.tsx:

import { router } from '@inertiajs/core';
import { useForm } from '@inertiajs/react';
import axios from 'axios';
import classNames from 'classnames';
import React, { useState } from 'react';
import ActionSection from '@/Components/ActionSection';
import ConfirmsPassword from '@/Components/ConfirmsPassword';
import DangerButton from '@/Components/DangerButton';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput';
import useTypedPage from '@/Hooks/useTypedPage';

interface Props {
  requiresConfirmation: boolean;
}

export default function TwoFactorAuthenticationForm({ requiresConfirmation }: Props) {
  const page = useTypedPage();
  const [enabling, setEnabling] = useState(false);
  const [disabling, setDisabling] = useState(false);
  const [qrCode, setQrCode] = useState<string | null>(null);
  const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
  const [confirming, setConfirming] = useState(false);
  const [setupKey, setSetupKey] = useState<string | null>(null);
  const confirmationForm = useForm({
    code: '',
  });
  const twoFactorEnabled = !enabling && page.props?.auth?.user?.two_factor_enabled;

  function enableTwoFactorAuthentication() {
    setEnabling(true);

    router.post(
      '/user/two-factor-authentication',
      {},
      {
        preserveScroll: true,
        onSuccess() {
          return Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]);
        },
        onFinish() {
          setEnabling(false);
          setConfirming(requiresConfirmation);
        },
      },
    );
  }

  function showSetupKey() {
    return axios.get('/user/two-factor-secret-key').then((response) => {
      setSetupKey(response.data.secretKey);
    });
  }

  function confirmTwoFactorAuthentication() {
    confirmationForm.post('/user/confirmed-two-factor-authentication', {
      preserveScroll: true,
      preserveState: true,
      errorBag: 'confirmTwoFactorAuthentication',
      onSuccess: () => {
        setConfirming(false);
        setQrCode(null);
        setSetupKey(null);
      },
    });
  }

  function showQrCode() {
    return axios.get('/user/two-factor-qr-code').then((response) => {
      setQrCode(response.data.svg);
    });
  }

  function showRecoveryCodes() {
    return axios.get('/user/two-factor-recovery-codes').then((response) => {
      setRecoveryCodes(response.data);
    });
  }

  function regenerateRecoveryCodes() {
    axios.post('/user/two-factor-recovery-codes').then(() => {
      showRecoveryCodes();
    });
  }

  function disableTwoFactorAuthentication() {
    setDisabling(true);

    router.delete('/user/two-factor-authentication', {
      preserveScroll: true,
      onSuccess() {
        setDisabling(false);
        setConfirming(false);
      },
    });
  }

  return (
    <ActionSection
      title={'Two Factor Authentication'}
      description={'Add additional security to your account using two factor authentication.'}
    >
      {(() => {
        if (twoFactorEnabled && !confirming) {
          return <h3 className="text-lg font-medium text-gray-900">You have enabled two factor authentication.</h3>;
        }
        if (confirming) {
          return <h3 className="text-lg font-medium text-gray-900">Finish enabling two factor authentication.</h3>;
        }
        return <h3 className="text-lg font-medium text-gray-900">You have not enabled two factor authentication.</h3>;
      })()}

      <div className="mt-3 max-w-xl text-sm text-gray-600">
        <p>
          When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve
          this token from your phone's Google Authenticator application.
        </p>
      </div>

      {twoFactorEnabled || confirming ? (
        <div>
          {qrCode ? (
            <div>
              <div className="mt-4 max-w-xl text-sm text-gray-600">
                {confirming ? (
                  <p className="font-semibold">
                    To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or
                    enter the setup key and provide the generated OTP code.
                  </p>
                ) : (
                  <p>
                    Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or
                    enter the setup key.
                  </p>
                )}
              </div>

              <div className="mt-4" dangerouslySetInnerHTML={{ __html: qrCode || '' }} />

              {setupKey && (
                <div className="mt-4 max-w-xl text-sm text-gray-600">
                  <p className="font-semibold">
                    Setup Key: <span dangerouslySetInnerHTML={{ __html: setupKey || '' }} />
                  </p>
                </div>
              )}

              {confirming && (
                <div className="mt-4">
                  <InputLabel htmlFor="code" value="Code" />

                  <TextInput
                    id="code"
                    type="text"
                    name="code"
                    className="block mt-1 w-1/2"
                    inputMode="numeric"
                    autoFocus={true}
                    autoComplete="one-time-code"
                    value={confirmationForm.data.code}
                    onChange={(e) => confirmationForm.setData('code', e.currentTarget.value)}
                  />

                  <InputError message={confirmationForm.errors.code} className="mt-2" />
                </div>
              )}
            </div>
          ) : null}

          {recoveryCodes.length > 0 && !confirming ? (
            <div>
              <div className="mt-4 max-w-xl text-sm text-gray-600">
                <p className="font-semibold">
                  Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two
                  factor authentication device is lost.
                </p>
              </div>

              <div className="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
                {recoveryCodes.map((code) => (
                  <div key={code}>{code}</div>
                ))}
              </div>
            </div>
          ) : null}
        </div>
      ) : null}

      <div className="mt-5">
        {twoFactorEnabled || confirming ? (
          <div>
            {confirming ? (
              <ConfirmsPassword onConfirm={confirmTwoFactorAuthentication}>
                <PrimaryButton className={classNames('mr-3', { 'opacity-25': enabling })} disabled={enabling}>
                  Confirm
                </PrimaryButton>
              </ConfirmsPassword>
            ) : null}
            {recoveryCodes.length > 0 && !confirming ? (
              <ConfirmsPassword onConfirm={regenerateRecoveryCodes}>
                <SecondaryButton className="mr-3">Regenerate Recovery Codes</SecondaryButton>
              </ConfirmsPassword>
            ) : null}
            {recoveryCodes.length === 0 && !confirming ? (
              <ConfirmsPassword onConfirm={showRecoveryCodes}>
                <SecondaryButton className="mr-3">Show Recovery Codes</SecondaryButton>
              </ConfirmsPassword>
            ) : null}

            {confirming ? (
              <ConfirmsPassword onConfirm={disableTwoFactorAuthentication}>
                <SecondaryButton className={classNames('mr-3', { 'opacity-25': disabling })} disabled={disabling}>
                  Cancel
                </SecondaryButton>
              </ConfirmsPassword>
            ) : (
              <ConfirmsPassword onConfirm={disableTwoFactorAuthentication}>
                <DangerButton className={classNames({ 'opacity-25': disabling })} disabled={disabling}>
                  Disable
                </DangerButton>
              </ConfirmsPassword>
            )}
          </div>
        ) : (
          <div>
            <ConfirmsPassword onConfirm={enableTwoFactorAuthentication}>
              <PrimaryButton type="button" className={classNames({ 'opacity-25': enabling })} disabled={enabling}>
                Enable
              </PrimaryButton>
            </ConfirmsPassword>
          </div>
        )}
      </div>
    </ActionSection>
  );
}

Other implementation:

import React, { useEffect, useRef, useState, FormEventHandler } from 'react';
import { useForm, usePage, router } from '@inertiajs/react';
import axios from 'axios';
import ActionSection from '@/Components/ActionSection';
import ConfirmsPassword from '@/Components/ConfirmsPassword';
import DangerButton from '@/Components/DangerButton';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput';

interface TwoFactorAuthenticationFormProps {
  requiresConfirmation: boolean;
}

const TwoFactorAuthenticationForm: React.FC<TwoFactorAuthenticationFormProps> = ({ requiresConfirmation }) => {
  const { props: page } = usePage<any>();

  const [enabling, setEnabling] = useState(false);
  const [confirming, setConfirming] = useState(false);
  const [disabling, setDisabling] = useState(false);
  const [qrCode, setQrCode] = useState<string | null>(null);
  const [setupKey, setSetupKey] = useState<string | null>(null);
  const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);

  const confirmationForm = useForm({
    code: '',
  });

  const twoFactorEnabled = !enabling && page.auth.user?.two_factor_enabled;

  useEffect(() => {
    if (!twoFactorEnabled) {
      confirmationForm.reset();
      confirmationForm.clearErrors();
    }
  }, [twoFactorEnabled]);

  const enableTwoFactorAuthentication = (e: any) => {
    e.preventDefault();
    setEnabling(true);

    router.post(
      route('two-factor.enable'),
      {},
      {
        preserveScroll: true,
        onSuccess: () => {
          Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]);
        },
        onFinish: () => {
          setEnabling(false);
          setConfirming(requiresConfirmation);
        },
      },
    );
  };

  const showQrCode = () => {
    return axios.get(route('two-factor.qr-code')).then((response) => {
      setQrCode(response.data.svg);
    });
  };

  const showSetupKey = () => {
    return axios.get(route('two-factor.secret-key')).then((response) => {
      setSetupKey(response.data.secretKey);
    });
  };

  const showRecoveryCodes = () => {
    return axios.get(route('two-factor.recovery-codes')).then((response) => {
      setRecoveryCodes(response.data);
    });
  };

  const confirmTwoFactorAuthentication: FormEventHandler = (e) => {
    e.preventDefault();
    confirmationForm.post(route('two-factor.confirm'), {
      errorBag: 'confirmTwoFactorAuthentication',
      preserveScroll: true,
      preserveState: true,
      onSuccess: () => {
        setConfirming(false);
        setQrCode(null);
        setSetupKey(null);
      },
    });
  };

  const regenerateRecoveryCodes: FormEventHandler = (e) => {
    e.preventDefault();
    axios.post(route('two-factor.recovery-codes')).then(() => showRecoveryCodes());
  };

  const disableTwoFactorAuthentication = () => {
    setDisabling(true);

    router.delete(route('two-factor.disable'), {
      preserveScroll: true,
      onSuccess: () => {
        setDisabling(false);
        setConfirming(false);
      },
    });
  };

  return (
    <ActionSection
      title="Two Factor Authentication"
      description="Add additional security to your account using two factor authentication."
      content={
        <>
          <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
            {twoFactorEnabled && !confirming && 'You have enabled two factor authentication.'}
            {twoFactorEnabled && confirming && 'Finish enabling two factor authentication.'}
            {!twoFactorEnabled && 'You have not enabled two factor authentication.'}
          </h3>

          <div className="max-w-xl mt-3 text-sm text-gray-600 dark:text-gray-400">
            <p>
              When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may
              retrieve this token from your phone's Google Authenticator application.
            </p>
          </div>

          {twoFactorEnabled && (
            <div>
              {qrCode && (
                <>
                  <div className="max-w-xl mt-4 text-sm text-gray-600 dark:text-gray-400">
                    <p className="font-semibold">
                      {confirming
                        ? "To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code."
                        : "Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key."}
                    </p>
                  </div>

                  <div
                    className="inline-block p-2 mt-4 bg-white"
                    dangerouslySetInnerHTML={{
                      __html: qrCode,
                    }}
                  />

                  {setupKey && (
                    <div className="max-w-xl mt-4 text-sm text-gray-600 dark:text-gray-400">
                      <p className="font-semibold">
                        Setup Key: <span>{setupKey}</span>
                      </p>
                    </div>
                  )}

                  {confirming && (
                    <div className="mt-4">
                      <InputLabel htmlFor="code" value="Code" />

                      <TextInput
                        id="code"
                        value={confirmationForm.data.code}
                        onChange={(e) => confirmationForm.setData('code', e.target.value)}
                        type="text"
                        name="code"
                        className="block w-1/2 mt-1"
                        inputMode="numeric"
                        autoFocus
                        autoComplete="one-time-code"
                        onKeyUp={(e) => e.key === 'Enter' && confirmTwoFactorAuthentication(e)}
                      />

                      <InputError message={confirmationForm.errors.code} className="mt-2" />
                    </div>
                  )}
                </>
              )}

              {recoveryCodes.length > 0 && !confirming && (
                <>
                  <div className="max-w-xl mt-4 text-sm text-gray-600 dark:text-gray-400">
                    <p className="font-semibold">
                      Store these recovery codes in a secure password manager. They can be used to recover access to your account if your
                      two factor authentication device is lost.
                    </p>
                  </div>

                  <div className="grid max-w-xl gap-1 px-4 py-4 mt-4 font-mono text-sm bg-gray-100 rounded-lg dark:bg-gray-900 dark:text-gray-100">
                    {recoveryCodes.map((code) => (
                      <div key={code}>{code}</div>
                    ))}
                  </div>
                </>
              )}
            </div>
          )}

          <div className="mt-5">
            {!twoFactorEnabled ? (
              <ConfirmsPassword onConfirmed={enableTwoFactorAuthentication}>
                <PrimaryButton type="button" className={enabling ? 'opacity-25' : ''} disabled={enabling}>
                  Enable
                </PrimaryButton>
              </ConfirmsPassword>
            ) : (
              <>
                {confirming && (
                  <ConfirmsPassword onConfirmed={confirmTwoFactorAuthentication}>
                    <PrimaryButton type="button" className={`me-3 ${enabling ? 'opacity-25' : ''}`} disabled={enabling}>
                      Confirm
                    </PrimaryButton>
                  </ConfirmsPassword>
                )}

                {recoveryCodes.length > 0 && !confirming && (
                  <ConfirmsPassword onConfirmed={regenerateRecoveryCodes}>
                    <SecondaryButton className="me-3">Regenerate Recovery Codes</SecondaryButton>
                  </ConfirmsPassword>
                )}

                {recoveryCodes.length === 0 && !confirming && (
                  <ConfirmsPassword onConfirmed={showRecoveryCodes}>
                    <SecondaryButton className="me-3">Show Recovery Codes</SecondaryButton>
                  </ConfirmsPassword>
                )}

                {confirming && (
                  <ConfirmsPassword onConfirmed={disableTwoFactorAuthentication}>
                    <SecondaryButton className={disabling ? 'opacity-25' : ''} disabled={disabling}>
                      Cancel
                    </SecondaryButton>
                  </ConfirmsPassword>
                )}

                {!confirming && (
                  <ConfirmsPassword onConfirmed={disableTwoFactorAuthentication}>
                    <DangerButton className={disabling ? 'opacity-25' : ''} disabled={disabling}>
                      Disable
                    </DangerButton>
                  </ConfirmsPassword>
                )}
              </>
            )}
          </div>
        </>
      }
    ></ActionSection>
  );
};

export default TwoFactorAuthenticationForm;

Vue implementation:

<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue';
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';

const props = defineProps({
  requiresConfirmation: Boolean,
});

const page = usePage();
const enabling = ref(false);
const confirming = ref(false);
const disabling = ref(false);
const qrCode = ref(null);
const setupKey = ref(null);
const recoveryCodes = ref([]);

const confirmationForm = useForm({
  code: '',
});

const twoFactorEnabled = computed(() => !enabling.value && page.props.auth.user?.two_factor_enabled);

watch(twoFactorEnabled, () => {
  if (!twoFactorEnabled.value) {
    confirmationForm.reset();
    confirmationForm.clearErrors();
  }
});

const enableTwoFactorAuthentication = () => {
  enabling.value = true;

  router.post(
    route('two-factor.enable'),
    {},
    {
      preserveScroll: true,
      onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
      onFinish: () => {
        enabling.value = false;
        confirming.value = props.requiresConfirmation;
      },
    },
  );
};

const showQrCode = () => {
  return axios.get(route('two-factor.qr-code')).then((response) => {
    qrCode.value = response.data.svg;
  });
};

const showSetupKey = () => {
  return axios.get(route('two-factor.secret-key')).then((response) => {
    setupKey.value = response.data.secretKey;
  });
};

const showRecoveryCodes = () => {
  return axios.get(route('two-factor.recovery-codes')).then((response) => {
    recoveryCodes.value = response.data;
  });
};

const confirmTwoFactorAuthentication = () => {
  confirmationForm.post(route('two-factor.confirm'), {
    errorBag: 'confirmTwoFactorAuthentication',
    preserveScroll: true,
    preserveState: true,
    onSuccess: () => {
      confirming.value = false;
      qrCode.value = null;
      setupKey.value = null;
    },
  });
};

const regenerateRecoveryCodes = () => {
  axios.post(route('two-factor.recovery-codes')).then(() => showRecoveryCodes());
};

const disableTwoFactorAuthentication = () => {
  disabling.value = true;

  router.delete(route('two-factor.disable'), {
    preserveScroll: true,
    onSuccess: () => {
      disabling.value = false;
      confirming.value = false;
    },
  });
};
</script>

<template>
  <ActionSection>
    <template #title> Two Factor Authentication </template>

    <template #description> Add additional security to your account using two factor authentication. </template>

    <template #content>
      <h3 v-if="twoFactorEnabled && !confirming" class="text-lg font-medium text-gray-900">You have enabled two factor authentication.</h3>

      <h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-medium text-gray-900">
        Finish enabling two factor authentication.
      </h3>

      <h3 v-else class="text-lg font-medium text-gray-900">You have not enabled two factor authentication.</h3>

      <div class="mt-3 max-w-xl text-sm text-gray-600">
        <p>
          When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve
          this token from your phone's Google Authenticator application.
        </p>
      </div>

      <div v-if="twoFactorEnabled">
        <div v-if="qrCode">
          <div class="mt-4 max-w-xl text-sm text-gray-600">
            <p v-if="confirming" class="font-semibold">
              To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter
              the setup key and provide the generated OTP code.
            </p>

            <p v-else>
              Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the
              setup key.
            </p>
          </div>

          <div class="mt-4 p-2 inline-block bg-white" v-html="qrCode" />

          <div v-if="setupKey" class="mt-4 max-w-xl text-sm text-gray-600">
            <p class="font-semibold">Setup Key: <span v-html="setupKey"></span></p>
          </div>

          <div v-if="confirming" class="mt-4">
            <InputLabel for="code" value="Code" />

            <TextInput
              id="code"
              v-model="confirmationForm.code"
              type="text"
              name="code"
              class="block mt-1 w-1/2"
              inputmode="numeric"
              autofocus
              autocomplete="one-time-code"
              @keyup.enter="confirmTwoFactorAuthentication"
            />

            <InputError :message="confirmationForm.errors.code" class="mt-2" />
          </div>
        </div>

        <div v-if="recoveryCodes.length > 0 && !confirming">
          <div class="mt-4 max-w-xl text-sm text-gray-600">
            <p class="font-semibold">
              Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor
              authentication device is lost.
            </p>
          </div>

          <div class="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
            <div v-for="code in recoveryCodes" :key="code">
              {{ code }}
            </div>
          </div>
        </div>
      </div>

      <div class="mt-5">
        <div v-if="!twoFactorEnabled">
          <ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
            <PrimaryButton type="button" :class="{ 'opacity-25': enabling }" :disabled="enabling"> Enable </PrimaryButton>
          </ConfirmsPassword>
        </div>

        <div v-else>
          <ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
            <PrimaryButton v-if="confirming" type="button" class="me-3" :class="{ 'opacity-25': enabling }" :disabled="enabling">
              Confirm
            </PrimaryButton>
          </ConfirmsPassword>

          <ConfirmsPassword @confirmed="regenerateRecoveryCodes">
            <SecondaryButton v-if="recoveryCodes.length > 0 && !confirming" class="me-3"> Regenerate Recovery Codes </SecondaryButton>
          </ConfirmsPassword>

          <ConfirmsPassword @confirmed="showRecoveryCodes">
            <SecondaryButton v-if="recoveryCodes.length === 0 && !confirming" class="me-3"> Show Recovery Codes </SecondaryButton>
          </ConfirmsPassword>

          <ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
            <SecondaryButton v-if="confirming" :class="{ 'opacity-25': disabling }" :disabled="disabling"> Cancel </SecondaryButton>
          </ConfirmsPassword>

          <ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
            <DangerButton v-if="!confirming" :class="{ 'opacity-25': disabling }" :disabled="disabling"> Disable </DangerButton>
          </ConfirmsPassword>
        </div>
      </div>
    </template>
  </ActionSection>
</template>
dsebastien commented 1 week ago

Also, add this in Profile/Show.tsx, before logout other browser sessions form

{jetstream.canManageTwoFactorAuthentication && (
            <>
              <div className="mt-10 sm:mt-0">
                <TwoFactorAuthenticationForm requiresConfirmation={confirmsTwoFactorAuthentication} />
              </div>
              <SectionBorder />
            </>
          )}