341 lines
14 KiB
Vue
341 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { Head, useForm } from '@inertiajs/vue3';
|
|
import { ShieldCheck } from 'lucide-vue-next';
|
|
import { computed, onUnmounted, ref } from 'vue';
|
|
import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import PasswordInput from '@/components/PasswordInput.vue';
|
|
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
|
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
|
import { edit } from '@/routes/security/index';
|
|
import { disable, enable } from '@/routes/two-factor';
|
|
import type { BreadcrumbItem } from '@/types';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
canManageTwoFactor?: boolean;
|
|
requiresConfirmation?: boolean;
|
|
twoFactorEnabled?: boolean;
|
|
}>(),
|
|
{
|
|
canManageTwoFactor: false,
|
|
requiresConfirmation: false,
|
|
twoFactorEnabled: false,
|
|
},
|
|
);
|
|
const { t } = useLocalization();
|
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|
{ title: t('navigation.security'), href: edit() },
|
|
]);
|
|
const passwordForm = useForm({
|
|
current_password: '',
|
|
password: '',
|
|
password_confirmation: '',
|
|
}).dontRemember('current_password', 'password', 'password_confirmation');
|
|
const passwordProtection = useFormProtection(passwordForm, {
|
|
isDirty: () => false,
|
|
});
|
|
const twoFactorForm = useForm({});
|
|
const twoFactorProtection = useFormProtection(twoFactorForm);
|
|
const showSetupModal = ref(false);
|
|
const showDisableDialog = ref(false);
|
|
const setupStarted = ref(false);
|
|
|
|
function savePassword(): void {
|
|
passwordProtection.submit(update(), {
|
|
preserveScroll: true,
|
|
onFinish: () => passwordForm.reset(),
|
|
});
|
|
}
|
|
function startSetup(): void {
|
|
if (setupStarted.value) {
|
|
showSetupModal.value = true;
|
|
return;
|
|
}
|
|
twoFactorProtection.submit(enable(), {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
setupStarted.value = true;
|
|
showSetupModal.value = true;
|
|
},
|
|
});
|
|
}
|
|
function disableTwoFactor(): void {
|
|
twoFactorProtection.submit(disable(), {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
showDisableDialog.value = false;
|
|
setupStarted.value = false;
|
|
},
|
|
});
|
|
}
|
|
function updateDisableDialog(open: boolean): void {
|
|
if (!twoFactorForm.processing) {
|
|
showDisableDialog.value = open;
|
|
twoFactorProtection.clearFeedback();
|
|
}
|
|
}
|
|
onUnmounted(() => passwordForm.reset());
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('security.title')" />
|
|
<SettingsLayout>
|
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
|
<h2 class="text-title">
|
|
{{ t('security.passwordTitle') }}
|
|
</h2>
|
|
<p class="text-muted-foreground">
|
|
{{ t('security.passwordDescription') }}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent class="p-5 sm:p-6">
|
|
<form
|
|
class="gap-column grid"
|
|
:aria-busy="passwordForm.processing"
|
|
@submit.prevent="savePassword"
|
|
>
|
|
<AuthFormFeedback
|
|
:state="passwordProtection.state.value"
|
|
:response-status="
|
|
passwordProtection.responseStatus.value
|
|
"
|
|
:loading-title="t('security.savingPassword')"
|
|
:success-title="t('security.passwordSaved')"
|
|
@retry="savePassword"
|
|
/>
|
|
<fieldset
|
|
class="gap-column grid min-w-0"
|
|
:disabled="passwordForm.processing"
|
|
>
|
|
<legend class="sr-only">
|
|
{{ t('security.passwordTitle') }}
|
|
</legend>
|
|
<div class="grid gap-2">
|
|
<Label for="current_password">{{
|
|
t('security.currentPassword')
|
|
}}</Label>
|
|
<PasswordInput
|
|
id="current_password"
|
|
v-model="passwordForm.current_password"
|
|
name="current_password"
|
|
required
|
|
autocomplete="current-password"
|
|
:disabled="passwordForm.processing"
|
|
:aria-invalid="
|
|
!!passwordForm.errors.current_password
|
|
"
|
|
aria-describedby="current-password-error"
|
|
/>
|
|
<InputError
|
|
id="current-password-error"
|
|
:message="
|
|
passwordForm.errors.current_password
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label for="password">{{
|
|
t('security.newPassword')
|
|
}}</Label>
|
|
<PasswordInput
|
|
id="password"
|
|
v-model="passwordForm.password"
|
|
name="password"
|
|
required
|
|
autocomplete="new-password"
|
|
:disabled="passwordForm.processing"
|
|
:aria-invalid="
|
|
!!passwordForm.errors.password
|
|
"
|
|
aria-describedby="new-password-error"
|
|
/>
|
|
<InputError
|
|
id="new-password-error"
|
|
:message="passwordForm.errors.password"
|
|
/>
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label for="password_confirmation">{{
|
|
t('security.confirmPassword')
|
|
}}</Label>
|
|
<PasswordInput
|
|
id="password_confirmation"
|
|
v-model="passwordForm.password_confirmation"
|
|
name="password_confirmation"
|
|
required
|
|
autocomplete="new-password"
|
|
:disabled="passwordForm.processing"
|
|
:aria-invalid="
|
|
!!passwordForm.errors
|
|
.password_confirmation
|
|
"
|
|
aria-describedby="password-confirmation-error"
|
|
/>
|
|
<InputError
|
|
id="password-confirmation-error"
|
|
:message="
|
|
passwordForm.errors
|
|
.password_confirmation
|
|
"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
class="justify-self-start"
|
|
:loading="passwordForm.processing"
|
|
:loading-label="t('security.savingPassword')"
|
|
data-test="update-password-button"
|
|
>{{ t('security.savePassword') }}</Button
|
|
>
|
|
</fieldset>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card
|
|
v-if="canManageTwoFactor"
|
|
class="gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
|
>
|
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
|
<div
|
|
class="flex flex-wrap items-center justify-between gap-3"
|
|
>
|
|
<h2 class="text-title flex items-center gap-2">
|
|
<ShieldCheck class="text-primary size-5" />{{
|
|
t('security.twoFactorTitle')
|
|
}}
|
|
</h2>
|
|
<span
|
|
class="bg-muted rounded-full px-3 py-1 text-xs font-semibold"
|
|
role="status"
|
|
>{{
|
|
t(
|
|
props.twoFactorEnabled
|
|
? 'security.enabled'
|
|
: setupStarted
|
|
? 'security.pending'
|
|
: 'security.disabled',
|
|
)
|
|
}}</span
|
|
>
|
|
</div>
|
|
<p class="text-muted-foreground">
|
|
{{
|
|
t(
|
|
props.twoFactorEnabled
|
|
? 'security.enabledDescription'
|
|
: 'security.twoFactorDescription',
|
|
)
|
|
}}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
|
<AuthFormFeedback
|
|
v-if="!showDisableDialog"
|
|
:state="twoFactorProtection.state.value"
|
|
:response-status="
|
|
twoFactorProtection.responseStatus.value
|
|
"
|
|
@retry="
|
|
props.twoFactorEnabled
|
|
? (showDisableDialog = true)
|
|
: startSetup()
|
|
"
|
|
/>
|
|
<Button
|
|
v-if="!twoFactorEnabled"
|
|
class="justify-self-start"
|
|
:loading="twoFactorForm.processing"
|
|
@click="startSetup"
|
|
>{{
|
|
t(
|
|
setupStarted
|
|
? 'security.continueSetup'
|
|
: 'security.enable',
|
|
)
|
|
}}</Button
|
|
>
|
|
<template v-else>
|
|
<Button
|
|
variant="outline"
|
|
class="text-destructive justify-self-start"
|
|
@click="showDisableDialog = true"
|
|
>{{ t('security.disable') }}</Button
|
|
>
|
|
<TwoFactorRecoveryCodes />
|
|
</template>
|
|
</CardContent>
|
|
</Card>
|
|
<TwoFactorSetupModal
|
|
v-if="canManageTwoFactor"
|
|
v-model:is-open="showSetupModal"
|
|
:requires-confirmation="requiresConfirmation"
|
|
:two-factor-enabled="twoFactorEnabled"
|
|
/>
|
|
<Dialog
|
|
:open="showDisableDialog"
|
|
@update:open="updateDisableDialog"
|
|
>
|
|
<DialogContent
|
|
:show-close-button="!twoFactorForm.processing"
|
|
@escape-key-down="
|
|
twoFactorForm.processing && $event.preventDefault()
|
|
"
|
|
@interact-outside="
|
|
twoFactorForm.processing && $event.preventDefault()
|
|
"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{{
|
|
t('security.disableTitle')
|
|
}}</DialogTitle>
|
|
<DialogDescription>{{
|
|
t('security.disableDescription')
|
|
}}</DialogDescription>
|
|
</DialogHeader>
|
|
<AuthFormFeedback
|
|
:state="twoFactorProtection.state.value"
|
|
:response-status="
|
|
twoFactorProtection.responseStatus.value
|
|
"
|
|
@retry="disableTwoFactor"
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
:disabled="twoFactorForm.processing"
|
|
@click="updateDisableDialog(false)"
|
|
>{{ t('common.cancel') }}</Button
|
|
>
|
|
<Button
|
|
variant="destructive"
|
|
:loading="twoFactorForm.processing"
|
|
@click="disableTwoFactor"
|
|
>{{ t('security.disable') }}</Button
|
|
>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</SettingsLayout>
|
|
</AppLayout>
|
|
</template>
|