Compare commits
9
Commits
v0.2.3
...
3869e5b8a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 |
@@ -1,39 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const tabs = [
|
||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
||||
] as const;
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
value: 'light' as const,
|
||||
Icon: Sun,
|
||||
label: t('appearance.light'),
|
||||
description: t('appearance.lightDescription'),
|
||||
},
|
||||
{
|
||||
value: 'dark' as const,
|
||||
Icon: Moon,
|
||||
label: t('appearance.dark'),
|
||||
description: t('appearance.darkDescription'),
|
||||
},
|
||||
{
|
||||
value: 'system' as const,
|
||||
Icon: Monitor,
|
||||
label: t('appearance.system'),
|
||||
description: t('appearance.systemDescription'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
:aria-label="t('appearance.label')"
|
||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
||||
>
|
||||
<button
|
||||
v-for="{ value, Icon, label } in tabs"
|
||||
:key="value"
|
||||
type="button"
|
||||
:aria-pressed="appearance === value"
|
||||
@click="updateAppearance(value)"
|
||||
:class="[
|
||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
appearance === value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
]"
|
||||
>
|
||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t(label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<fieldset class="min-w-0">
|
||||
<legend class="sr-only">{{ t('appearance.label') }}</legend>
|
||||
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||
<label
|
||||
v-for="{ value, Icon, label, description } in tabs"
|
||||
:key="value"
|
||||
class="relative min-w-0"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="appearance"
|
||||
:value="value"
|
||||
:checked="appearance === value"
|
||||
:aria-describedby="`appearance-${value}-description`"
|
||||
class="peer sr-only"
|
||||
@change="updateAppearance(value)"
|
||||
/>
|
||||
<span
|
||||
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
|
||||
>
|
||||
<component
|
||||
:is="Icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<Check
|
||||
v-if="appearance === value"
|
||||
class="text-primary size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span class="font-semibold">{{ label }}</span>
|
||||
<span
|
||||
:id="`appearance-${value}-description`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -17,97 +17,104 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const passwordInput = useTemplateRef('passwordInput');
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Delete account"
|
||||
description="Delete your account and all of its resources"
|
||||
/>
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
|
||||
>
|
||||
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||
<p class="font-medium">Warning</p>
|
||||
<p class="text-sm">
|
||||
Please proceed with caution, this cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="destructive" data-test="delete-user-button"
|
||||
>Delete account</Button
|
||||
>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form
|
||||
v-bind="ProfileController.destroy.form()"
|
||||
reset-on-success
|
||||
@error="() => passwordInput?.focus()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing, reset, clearErrors }"
|
||||
>
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle
|
||||
>Are you sure you want to delete your
|
||||
account?</DialogTitle
|
||||
>
|
||||
<DialogDescription>
|
||||
Once your account is deleted, all of its
|
||||
resources and data will also be permanently
|
||||
deleted. Please enter your password to confirm
|
||||
you would like to permanently delete your
|
||||
account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Card
|
||||
class="border-destructive/35 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('profile.delete.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('profile.delete.description') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="p-5 sm:p-6">
|
||||
<div
|
||||
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<p class="text-destructive font-semibold">
|
||||
{{ t('profile.delete.warning') }}
|
||||
</p>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('profile.delete.warningDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="justify-self-start"
|
||||
data-test="delete-user-button"
|
||||
>{{ t('profile.delete.action') }}</Button
|
||||
>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form
|
||||
v-bind="ProfileController.destroy.form()"
|
||||
reset-on-success
|
||||
@error="() => passwordInput?.focus()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing, reset, clearErrors }"
|
||||
>
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle>{{
|
||||
t('profile.delete.confirmTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('profile.delete.confirmDescription') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password" class="sr-only"
|
||||
>Password</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
ref="passwordInput"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password" class="sr-only">{{
|
||||
t('profile.delete.password')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
ref="passwordInput"
|
||||
:placeholder="t('profile.delete.password')"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click="
|
||||
() => {
|
||||
clearErrors();
|
||||
reset();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click="
|
||||
() => {
|
||||
clearErrors();
|
||||
reset();
|
||||
}
|
||||
"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
data-test="confirm-delete-user-button"
|
||||
>
|
||||
Cancel
|
||||
{{ t('profile.delete.confirmAction') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
data-test="confirm-delete-user-button"
|
||||
>
|
||||
Delete account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,123 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
|
||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
||||
const { t } = useLocalization();
|
||||
const {
|
||||
recoveryCodesList,
|
||||
fetchRecoveryCodes,
|
||||
clearRecoveryCodes,
|
||||
errors,
|
||||
loading,
|
||||
} = useTwoFactorAuth();
|
||||
const visible = ref(false);
|
||||
const showRegenerate = ref(false);
|
||||
const form = useForm({});
|
||||
const protection = useFormProtection(form);
|
||||
|
||||
const toggleRecoveryCodesVisibility = async () => {
|
||||
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
|
||||
await fetchRecoveryCodes();
|
||||
function hide(): void {
|
||||
visible.value = false;
|
||||
clearRecoveryCodes();
|
||||
}
|
||||
function toggle(): void {
|
||||
if (visible.value) {
|
||||
hide();
|
||||
} else {
|
||||
visible.value = true;
|
||||
void fetchRecoveryCodes();
|
||||
}
|
||||
|
||||
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
|
||||
|
||||
if (isRecoveryCodesVisible.value) {
|
||||
await nextTick();
|
||||
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
function closeRegenerate(open: boolean): void {
|
||||
if (!form.processing) {
|
||||
showRegenerate.value = open;
|
||||
protection.clearFeedback();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!recoveryCodesList.value.length) {
|
||||
await fetchRecoveryCodes();
|
||||
}
|
||||
});
|
||||
}
|
||||
function regenerate(): void {
|
||||
protection.submit(regenerateRecoveryCodes(), {
|
||||
preserveScroll: true,
|
||||
onBefore: hide,
|
||||
onSuccess: () => {
|
||||
showRegenerate.value = false;
|
||||
visible.value = true;
|
||||
return fetchRecoveryCodes();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex gap-3">
|
||||
<LockKeyhole class="size-4" />2FA recovery codes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recovery codes let you regain access if you lose your 2FA
|
||||
device. Store them in a secure password manager.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
<section
|
||||
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||
aria-labelledby="recovery-title"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<h3
|
||||
id="recovery-title"
|
||||
class="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
|
||||
<component
|
||||
:is="isRecoveryCodesVisible ? EyeOff : Eye"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
|
||||
codes
|
||||
</Button>
|
||||
|
||||
<Form
|
||||
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
|
||||
v-bind="regenerateRecoveryCodes.form()"
|
||||
method="post"
|
||||
:options="{ preserveScroll: true }"
|
||||
@success="fetchRecoveryCodes"
|
||||
#default="{ processing }"
|
||||
<LockKeyhole class="text-primary size-4" />{{
|
||||
t('security.recoveryTitle')
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('security.recoveryDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
:aria-expanded="visible"
|
||||
aria-controls="recovery-codes"
|
||||
:disabled="form.processing"
|
||||
@click="toggle"
|
||||
><component :is="visible ? EyeOff : Eye" />{{
|
||||
t(
|
||||
visible
|
||||
? 'security.hideRecovery'
|
||||
: 'security.viewRecovery',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
<Button
|
||||
v-if="visible && recoveryCodesList.length"
|
||||
variant="secondary"
|
||||
@click="showRegenerate = true"
|
||||
><RefreshCw />{{ t('security.regenerate') }}</Button
|
||||
>
|
||||
</div>
|
||||
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
|
||||
<p
|
||||
v-if="loading"
|
||||
role="status"
|
||||
class="bg-muted animate-pulse rounded-lg p-4"
|
||||
>
|
||||
{{ t('security.loadingRecovery') }}
|
||||
</p>
|
||||
<template v-else-if="errors.length">
|
||||
<AlertError :errors="errors" />
|
||||
<Button
|
||||
class="justify-self-start"
|
||||
variant="outline"
|
||||
@click="fetchRecoveryCodes"
|
||||
>{{ t('common.retry') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
>
|
||||
<RefreshCw /> Regenerate codes
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'relative overflow-hidden transition-all duration-300',
|
||||
isRecoveryCodesVisible
|
||||
? 'h-auto opacity-100'
|
||||
: 'h-0 opacity-0',
|
||||
]"
|
||||
</template>
|
||||
<template v-else>
|
||||
<ul
|
||||
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
|
||||
:aria-label="t('security.recoveryTitle')"
|
||||
>
|
||||
<li v-for="code in recoveryCodesList" :key="code">
|
||||
{{ code }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('security.recoveryOnce') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<div v-if="errors?.length" class="mt-6">
|
||||
<AlertError :errors="errors" />
|
||||
</div>
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div
|
||||
ref="recoveryCodeSectionRef"
|
||||
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
t('security.regenerateTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('security.regenerateDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
@retry="regenerate"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="closeRegenerate(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<div v-if="!recoveryCodesList.length" class="space-y-2">
|
||||
<div
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(code, index) in recoveryCodesList"
|
||||
:key="index"
|
||||
>
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground select-none text-xs">
|
||||
Each recovery code can be used once to access your
|
||||
account and will be removed after use. If you need more,
|
||||
click
|
||||
<span class="font-bold">Regenerate codes</span> above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button :loading="form.processing" @click="regenerate">{{
|
||||
t('security.regenerate')
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,297 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
type Props = {
|
||||
const props = defineProps<{
|
||||
requiresConfirmation: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
const { resolvedAppearance } = useAppearance();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
}>();
|
||||
const isOpen = defineModel<boolean>('isOpen');
|
||||
const { t } = useLocalization();
|
||||
const {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
fetchSetupData,
|
||||
errors,
|
||||
loading,
|
||||
} = useTwoFactorAuth();
|
||||
const form = useForm({ code: '' }).dontRemember('code');
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const verification = ref(false);
|
||||
const copied = ref(false);
|
||||
const copyError = ref(false);
|
||||
const codeInput = useTemplateRef('codeInput');
|
||||
const title = computed(() =>
|
||||
t(
|
||||
props.twoFactorEnabled
|
||||
? 'security.setupEnabled'
|
||||
: verification.value
|
||||
? 'security.verifyTitle'
|
||||
: 'security.setupTitle',
|
||||
),
|
||||
);
|
||||
const description = computed(() =>
|
||||
t(
|
||||
props.twoFactorEnabled
|
||||
? 'security.setupEnabledDescription'
|
||||
: verification.value
|
||||
? 'security.verifyDescription'
|
||||
: 'security.setupDescription',
|
||||
),
|
||||
);
|
||||
|
||||
const { copy, copied } = useClipboard();
|
||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
||||
useTwoFactorAuth();
|
||||
|
||||
const showVerificationStep = ref(false);
|
||||
const code = ref<string>('');
|
||||
|
||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
||||
|
||||
const modalConfig = computed<TwoFactorConfigContent>(() => {
|
||||
if (props.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',
|
||||
};
|
||||
function close(open: boolean): void {
|
||||
if (!form.processing) {
|
||||
isOpen.value = open;
|
||||
}
|
||||
|
||||
if (showVerificationStep.value) {
|
||||
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',
|
||||
};
|
||||
});
|
||||
|
||||
const handleModalNextStep = () => {
|
||||
if (props.requiresConfirmation) {
|
||||
showVerificationStep.value = true;
|
||||
|
||||
nextTick(() => {
|
||||
pinInputContainerRef.value?.querySelector('input')?.focus();
|
||||
});
|
||||
|
||||
}
|
||||
async function next(): Promise<void> {
|
||||
if (props.twoFactorEnabled || !props.requiresConfirmation) {
|
||||
close(false);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSetupData();
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
if (props.twoFactorEnabled) {
|
||||
clearSetupData();
|
||||
verification.value = true;
|
||||
await nextTick();
|
||||
codeInput.value?.$el?.focus();
|
||||
}
|
||||
function submit(): void {
|
||||
protection.submit(confirm(), {
|
||||
preserveScroll: true,
|
||||
errorBag: 'confirmTwoFactorAuthentication',
|
||||
onSuccess: () => {
|
||||
isOpen.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
}
|
||||
async function copyKey(): Promise<void> {
|
||||
const key = manualSetupKey.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
showVerificationStep.value = false;
|
||||
code.value = '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => isOpen.value,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetModalState();
|
||||
|
||||
return;
|
||||
copyError.value = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
if (isOpen.value && manualSetupKey.value === key) {
|
||||
copied.value = true;
|
||||
}
|
||||
|
||||
if (!qrCodeSvg.value) {
|
||||
await fetchSetupData();
|
||||
} catch {
|
||||
if (isOpen.value) {
|
||||
copyError.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
watch(isOpen, (open) => {
|
||||
verification.value = false;
|
||||
copied.value = false;
|
||||
copyError.value = false;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
clearSetupData();
|
||||
if (open) {
|
||||
void fetchSetupData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="isOpen = $event">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader class="flex items-center justify-center">
|
||||
<div
|
||||
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
|
||||
<Dialog :open="isOpen" @update:open="close">
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<ScanLine class="text-primary size-8" />
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<template v-if="!verification">
|
||||
<p
|
||||
v-if="loading"
|
||||
role="status"
|
||||
class="bg-muted animate-pulse rounded-lg p-6 text-center"
|
||||
>
|
||||
{{ t('security.loadingSetup') }}
|
||||
</p>
|
||||
<div v-else-if="errors.length" class="gap-stack grid">
|
||||
<AlertError :errors="errors" />
|
||||
<Button variant="outline" @click="fetchSetupData">{{
|
||||
t('common.retry')
|
||||
}}</Button>
|
||||
</div>
|
||||
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
|
||||
<div
|
||||
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 grid grid-cols-5 opacity-50"
|
||||
>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="`col-${i}`"
|
||||
class="border-border border-r last:border-r-0"
|
||||
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
|
||||
v-html="qrCodeSvg"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="setup-key">{{
|
||||
t('security.manualKey')
|
||||
}}</Label>
|
||||
<div class="flex min-w-0 gap-2">
|
||||
<Input
|
||||
id="setup-key"
|
||||
:model-value="manualSetupKey"
|
||||
readonly
|
||||
class="min-w-0 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t(
|
||||
copied
|
||||
? 'security.copied'
|
||||
: 'security.copyKey',
|
||||
)
|
||||
"
|
||||
@click="copyKey"
|
||||
><Check v-if="copied" /><Copy v-else
|
||||
/></Button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 grid grid-rows-5 opacity-50"
|
||||
<p
|
||||
v-if="copied"
|
||||
role="status"
|
||||
class="text-caption text-success-foreground"
|
||||
>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="`row-${i}`"
|
||||
class="border-border border-b last:border-b-0"
|
||||
/>
|
||||
</div>
|
||||
<ScanLine
|
||||
class="text-foreground relative z-20 size-6"
|
||||
{{ t('security.copied') }}
|
||||
</p>
|
||||
<InputError
|
||||
v-if="copyError"
|
||||
:message="t('security.copyError')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
|
||||
<DialogDescription class="text-center">
|
||||
{{ modalConfig.description }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
||||
>
|
||||
<template v-if="!showVerificationStep">
|
||||
<AlertError v-if="errors?.length" :errors="errors" />
|
||||
<template v-else>
|
||||
<div
|
||||
class="relative mx-auto flex max-w-md items-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<div
|
||||
v-if="!qrCodeSvg"
|
||||
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
|
||||
>
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative z-10 overflow-hidden border p-5"
|
||||
>
|
||||
<div
|
||||
v-html="qrCodeSvg"
|
||||
class="flex aspect-square size-full items-center justify-center"
|
||||
:style="{
|
||||
filter:
|
||||
resolvedAppearance === 'dark'
|
||||
? 'invert(1) brightness(1.5)'
|
||||
: undefined,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button class="w-full" @click="handleModalNextStep">
|
||||
{{ modalConfig.buttonText }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="bg-border absolute inset-0 top-1/2 h-px w-full"
|
||||
/>
|
||||
<span class="bg-card relative px-2 py-1"
|
||||
>or, enter the code manually</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-center justify-center space-x-2"
|
||||
>
|
||||
<div
|
||||
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div
|
||||
v-if="!manualSetupKey"
|
||||
class="bg-muted flex h-full w-full items-center justify-center p-3"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<template v-else>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="manualSetupKey"
|
||||
class="bg-background text-foreground h-full w-full p-3"
|
||||
/>
|
||||
<button
|
||||
@click="copy(manualSetupKey || '')"
|
||||
class="border-border hover:bg-muted relative block h-auto border-l px-3"
|
||||
>
|
||||
<Check
|
||||
v-if="copied"
|
||||
class="w-4 text-green-500"
|
||||
/>
|
||||
<Copy v-else class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Form
|
||||
v-bind="confirm.form()"
|
||||
error-bag="confirmTwoFactorAuthentication"
|
||||
reset-on-error
|
||||
@finish="code = ''"
|
||||
@success="isOpen = false"
|
||||
v-slot="{ errors, processing }"
|
||||
<p
|
||||
v-if="requiresConfirmation && !twoFactorEnabled"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('security.setupPendingDescription') }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="close(false)">{{
|
||||
t('common.close')
|
||||
}}</Button>
|
||||
<Button v-if="hasSetupData" @click="next">{{
|
||||
t(
|
||||
twoFactorEnabled || !requiresConfirmation
|
||||
? 'common.done'
|
||||
: 'security.continue',
|
||||
)
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
<form v-else class="gap-column grid" @submit.prevent="submit">
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="authentication-code">{{
|
||||
t('security.authenticationCode')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="authentication-code"
|
||||
ref="codeInput"
|
||||
v-model="form.code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="!!form.errors.code"
|
||||
aria-describedby="authentication-code-error"
|
||||
class="font-mono text-lg tracking-widest"
|
||||
/>
|
||||
<InputError
|
||||
id="authentication-code-error"
|
||||
:message="form.errors.code"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="
|
||||
verification = false;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
"
|
||||
>{{ t('common.back') }}</Button
|
||||
>
|
||||
<input type="hidden" name="code" :value="code" />
|
||||
<div
|
||||
ref="pinInputContainerRef"
|
||||
class="relative w-full space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
|
||||
>
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<InputError :message="errors?.code" />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-auto flex-1"
|
||||
@click="showVerificationStep = false"
|
||||
:disabled="processing"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-auto flex-1"
|
||||
:disabled="processing || code.length < 6"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.code.length !== 6"
|
||||
:loading="form.processing"
|
||||
>{{ t('security.confirm') }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
GitBranchPlus,
|
||||
LoaderCircle,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
store,
|
||||
validate as validateBranch,
|
||||
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||
import type {
|
||||
TicketBranchCreateDialogProps,
|
||||
TicketBranchFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketBranchCreateDialogProps>();
|
||||
const { t } = useLocalization();
|
||||
const appName = String(usePage().props.name);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
|
||||
const isOpen = ref(false);
|
||||
const isCheckingBranchName = ref(false);
|
||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||
|
||||
const defaultRepositoryConnectionId = computed(() =>
|
||||
String(
|
||||
props.repositoryConnections.find(
|
||||
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||
)?.id ??
|
||||
props.repositoryConnections[0]?.id ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
|
||||
const form = useForm<TicketBranchFormData>(draftKey, {
|
||||
branch_name: props.suggestedBranchName,
|
||||
repository_connection_id: defaultRepositoryConnectionId.value,
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => isOpen.value && form.isDirty,
|
||||
});
|
||||
|
||||
const selectedRepositoryConnection = computed(
|
||||
() =>
|
||||
props.repositoryConnections.find(
|
||||
(repositoryConnection) =>
|
||||
String(repositoryConnection.id) ===
|
||||
form.repository_connection_id,
|
||||
) ?? null,
|
||||
);
|
||||
const selectedRepositoryHasPersonalAccess = computed(
|
||||
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
|
||||
);
|
||||
const validationStateClasses = computed(() => {
|
||||
if (isCheckingBranchName.value) {
|
||||
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||
}
|
||||
|
||||
if (branchNameValidation.value?.is_valid) {
|
||||
return 'border-success/40 bg-success/10 text-success-foreground';
|
||||
}
|
||||
|
||||
if (branchNameValidation.value !== null) {
|
||||
return 'border-warning/40 bg-warning/10 text-warning-foreground';
|
||||
}
|
||||
|
||||
return 'border-border bg-muted/30 text-muted-foreground';
|
||||
});
|
||||
|
||||
function updateOpen(open: boolean): void {
|
||||
if (!open && !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = open;
|
||||
}
|
||||
|
||||
function initializeForm(): void {
|
||||
if (form.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
form.branch_name = props.suggestedBranchName;
|
||||
form.repository_connection_id = defaultRepositoryConnectionId.value;
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
initializeForm();
|
||||
});
|
||||
|
||||
watch(
|
||||
[isOpen, () => form.repository_connection_id, () => form.branch_name],
|
||||
(
|
||||
[open, repositoryConnectionId, branchName],
|
||||
_previousValues,
|
||||
onCleanup,
|
||||
) => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repositoryConnection = selectedRepositoryConnection.value;
|
||||
|
||||
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repositoryConnection.has_personal_access) {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.connectGitFirst'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (branchName.trim() === '') {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchRequired'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = true;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
validateBranch.url(
|
||||
{
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
branch_name: branchName,
|
||||
repository_connection_id:
|
||||
repositoryConnectionId,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
method: 'GET',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Branch validation failed.');
|
||||
}
|
||||
|
||||
branchNameValidation.value =
|
||||
(await response.json()) as BranchNameValidationModel;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchValidationFailed', {
|
||||
app: appName,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
isCheckingBranchName.value = false;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
onCleanup(() => {
|
||||
abortController.abort();
|
||||
window.clearTimeout(timeoutId);
|
||||
isCheckingBranchName.value = false;
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function submit(): void {
|
||||
if (
|
||||
!selectedRepositoryHasPersonalAccess.value ||
|
||||
branchNameValidation.value?.is_valid !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
protection.submit(
|
||||
store({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
isOpen.value = false;
|
||||
},
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="updateOpen">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="isOpen = true"
|
||||
>
|
||||
<GitBranchPlus class="size-4" aria-hidden="true" />
|
||||
{{ t('ticketDetail.createBranch') }}
|
||||
</Button>
|
||||
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle>{{
|
||||
t('ticketDetail.createBranchTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('ticketDetail.createBranchDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form class="grid gap-4" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
protection.state.value === 'loading'
|
||||
? t('ticketDetail.creatingBranch')
|
||||
: undefined
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="repository_connection_id">{{
|
||||
t('ticketDetail.repository')
|
||||
}}</Label>
|
||||
<select
|
||||
id="repository_connection_id"
|
||||
v-model="form.repository_connection_id"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="repositoryConnection in repositoryConnections"
|
||||
:key="repositoryConnection.id"
|
||||
:value="String(repositoryConnection.id)"
|
||||
>
|
||||
{{ repositoryConnection.repository_full_name
|
||||
}}{{
|
||||
repositoryConnection.has_personal_access
|
||||
? ''
|
||||
: ` — ${t('ticketDetail.personalAccessRequired')}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.repository_connection_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="branch_name">{{
|
||||
t('ticketDetail.branchName')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="branch_name"
|
||||
v-model="form.branch_name"
|
||||
:disabled="!selectedRepositoryHasPersonalAccess"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<InputError :message="form.errors.branch_name" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
:aria-busy="isCheckingBranchName || undefined"
|
||||
class="rounded-lg border px-4 py-3 transition-colors"
|
||||
:class="validationStateClasses"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<LoaderCircle
|
||||
v-if="isCheckingBranchName"
|
||||
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircle2
|
||||
v-else-if="branchNameValidation?.is_valid"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CircleAlert
|
||||
v-else-if="branchNameValidation !== null"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{{
|
||||
isCheckingBranchName
|
||||
? t('ticketDetail.checkingBranch')
|
||||
: (branchNameValidation?.message ??
|
||||
t('ticketDetail.selectRepository'))
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!selectedRepositoryHasPersonalAccess &&
|
||||
selectedRepositoryConnection !== null
|
||||
"
|
||||
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
|
||||
>
|
||||
<div class="grid gap-1">
|
||||
<p class="font-semibold">
|
||||
{{ t('ticketDetail.gitAccessRequired') }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.branchAccess') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="form.processing"
|
||||
@click="updateOpen(false)"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
isCheckingBranchName ||
|
||||
branchNameValidation?.is_valid !== true
|
||||
"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('ticketDetail.creatingBranch')"
|
||||
>
|
||||
{{ t('ticketDetail.createNow') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketDetailBlockerProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const savedFormData = () => ({
|
||||
acceptance_criteria: props.ticket.acceptance_criteria,
|
||||
assignee_user_id: props.ticket.assignee_user_id,
|
||||
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||
description: props.ticket.description,
|
||||
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
||||
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||
value_number: fieldValue.value_number,
|
||||
value_user_id: fieldValue.value_user_id,
|
||||
})),
|
||||
is_blocked: props.ticket.is_blocked,
|
||||
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||
story_points: props.ticket.story_points,
|
||||
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||
ticket_type_id: props.ticket.ticket_type_id,
|
||||
title: props.ticket.title,
|
||||
});
|
||||
|
||||
const form = useForm(
|
||||
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
|
||||
savedFormData(),
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
const isBlocked = computed(() => Boolean(form.is_blocked));
|
||||
|
||||
const reset = (): void => {
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!props.canEditTicket || form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
protection.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
Object.assign(form, savedFormData());
|
||||
form.defaults();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-blocker"
|
||||
aria-labelledby="ticket-blocker-title"
|
||||
class="min-w-0 scroll-mt-6"
|
||||
>
|
||||
<Card :class="{ 'border-warning/40': isBlocked }">
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.blocker') }}
|
||||
</h2>
|
||||
<Badge :variant="isBlocked ? 'warning' : 'outline'">
|
||||
{{
|
||||
isBlocked
|
||||
? t('ticketDetail.blockerActive')
|
||||
: t('ticketDetail.blockerInactive')
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{{ t('ticketDetail.blockerDescription') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent v-if="canEditTicket">
|
||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<input
|
||||
id="is_blocked"
|
||||
v-model="form.is_blocked"
|
||||
type="checkbox"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||
aria-describedby="ticket-blocker-state-description is_blocked-error"
|
||||
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<Label for="is_blocked">
|
||||
{{ t('ticketDetail.blocked') }}
|
||||
</Label>
|
||||
<p
|
||||
id="ticket-blocker-state-description"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{
|
||||
isBlocked
|
||||
? t(
|
||||
'ticketDetail.blockerActiveDescription',
|
||||
)
|
||||
: t(
|
||||
'ticketDetail.blockerInactiveDescription',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<InputError
|
||||
id="is_blocked-error"
|
||||
:message="form.errors.is_blocked"
|
||||
/>
|
||||
|
||||
<TicketFormField
|
||||
v-if="isBlocked"
|
||||
id="blocked_comment"
|
||||
v-model="form.blocked_comment"
|
||||
kind="textarea"
|
||||
:rows="3"
|
||||
:label="t('ticketDetail.blockerComment')"
|
||||
:hint="t('ticketDetail.blockerCommentHelp')"
|
||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||
:error="form.errors.blocked_comment"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!form.isDirty || form.processing"
|
||||
@click="reset"
|
||||
>
|
||||
{{ t('ticketDetail.resetBlocker') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('ticketDetail.savingBlocker')"
|
||||
:disabled="!form.isDirty"
|
||||
>
|
||||
{{
|
||||
isBlocked
|
||||
? t('ticketDetail.saveBlocker')
|
||||
: t('ticketDetail.unblockTicket')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardContent v-else>
|
||||
<p
|
||||
class="whitespace-pre-wrap text-sm leading-6"
|
||||
:class="{ 'text-muted-foreground': !isBlocked }"
|
||||
>
|
||||
{{
|
||||
isBlocked
|
||||
? ticket.blocked_comment
|
||||
: t('ticketDetail.blockerInactiveDescription')
|
||||
}}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -3,7 +3,15 @@ import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||
import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
toRef,
|
||||
unref,
|
||||
useTemplateRef,
|
||||
nextTick,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||
@@ -11,7 +19,6 @@ import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import {
|
||||
normalizeAcceptanceCriteria,
|
||||
@@ -81,6 +88,16 @@ const savedFormData = () => ({
|
||||
|
||||
const form = useForm(draftKey, savedFormData());
|
||||
|
||||
watch(
|
||||
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
|
||||
([isBlocked, blockedComment]) => {
|
||||
form.is_blocked = isBlocked;
|
||||
form.blocked_comment = blockedComment ?? '';
|
||||
form.defaults('is_blocked', isBlocked);
|
||||
form.defaults('blocked_comment', blockedComment ?? '');
|
||||
},
|
||||
);
|
||||
|
||||
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||
() =>
|
||||
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||
@@ -364,44 +381,6 @@ const submit = (): void => {
|
||||
/>
|
||||
</template>
|
||||
</TicketFormFields>
|
||||
<fieldset
|
||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>
|
||||
<legend class="px-2 text-sm font-semibold">
|
||||
{{ t('ticketForm.blocker') }}
|
||||
</legend>
|
||||
<div class="min-h-control flex items-center gap-3">
|
||||
<input
|
||||
id="is_blocked"
|
||||
v-model="form.is_blocked"
|
||||
type="checkbox"
|
||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||
aria-describedby="is_blocked-error"
|
||||
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<Label for="is_blocked">{{
|
||||
t('ticketDetail.blocked')
|
||||
}}</Label>
|
||||
</div>
|
||||
<InputError
|
||||
id="is_blocked-error"
|
||||
:message="form.errors.is_blocked"
|
||||
/>
|
||||
<TicketFormField
|
||||
id="blocked_comment"
|
||||
v-model="form.blocked_comment"
|
||||
kind="textarea"
|
||||
:rows="3"
|
||||
:label="t('ticketDetail.blockerComment')"
|
||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||
:error="form.errors.blocked_comment"
|
||||
:required="form.is_blocked"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||
:disabled="
|
||||
|
||||
@@ -1,273 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
|
||||
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type { TicketDetailGitProps } from '@/types/ticket-detail';
|
||||
|
||||
defineProps<TicketDetailGitProps>();
|
||||
|
||||
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type {
|
||||
TicketDetailGitProps,
|
||||
TicketBranchFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailGitProps>();
|
||||
const { t } = useLocalization();
|
||||
const appName = String(usePage().props.name);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||
const visibleRepositoryBranches = computed(() =>
|
||||
props.repositoryBranches.filter(
|
||||
(repositoryBranch) =>
|
||||
repositoryBranch.branches.length > 0 ||
|
||||
repositoryBranch.error !== null ||
|
||||
!repositoryBranch.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
|
||||
const visibleRepositoryPullRequests = computed(() =>
|
||||
props.repositoryPullRequests.filter(
|
||||
(repositoryPullRequest) =>
|
||||
repositoryPullRequest.pull_requests.length > 0 ||
|
||||
repositoryPullRequest.error !== null ||
|
||||
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||
props.repositoryBranches.map(
|
||||
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||
),
|
||||
);
|
||||
|
||||
const canCreateBranches = computed(
|
||||
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
||||
);
|
||||
const defaultBranchRepositoryConnectionId = computed(() =>
|
||||
String(
|
||||
linkedRepositoryConnections.value.find(
|
||||
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||
)?.id ??
|
||||
linkedRepositoryConnections.value[0]?.id ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
|
||||
const isCreateBranchDialogOpen = ref(false);
|
||||
const isCheckingBranchName = ref(false);
|
||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||
|
||||
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
|
||||
branch_name: props.suggestedBranchName,
|
||||
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
||||
});
|
||||
const branchProtection = useFormProtection(branchCreationForm, {
|
||||
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
|
||||
});
|
||||
|
||||
const updateBranchDialogOpen = (open: boolean): void => {
|
||||
if (!open && !branchProtection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
isCreateBranchDialogOpen.value = open;
|
||||
};
|
||||
|
||||
const selectedBranchRepositoryConnection = computed(
|
||||
(): RepositoryConnectionModel | null =>
|
||||
linkedRepositoryConnections.value.find(
|
||||
(repositoryConnection) =>
|
||||
String(repositoryConnection.id) ===
|
||||
branchCreationForm.repository_connection_id,
|
||||
) ?? null,
|
||||
);
|
||||
|
||||
const selectedBranchRepositoryHasPersonalAccess = computed(
|
||||
() =>
|
||||
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
||||
);
|
||||
|
||||
const initializeBranchCreationForm = (): void => {
|
||||
if (branchCreationForm.isDirty) {
|
||||
return;
|
||||
}
|
||||
branchCreationForm.clearErrors();
|
||||
branchCreationForm.branch_name = props.suggestedBranchName;
|
||||
branchCreationForm.repository_connection_id =
|
||||
defaultBranchRepositoryConnectionId.value;
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => isCreateBranchDialogOpen.value,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
branchCreationForm.clearErrors();
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
initializeBranchCreationForm();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[
|
||||
() => isCreateBranchDialogOpen.value,
|
||||
() => branchCreationForm.repository_connection_id,
|
||||
() => branchCreationForm.branch_name,
|
||||
],
|
||||
(
|
||||
[isOpen, repositoryConnectionId, branchName],
|
||||
_previousValues,
|
||||
onCleanup,
|
||||
) => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repositoryConnection = selectedBranchRepositoryConnection.value;
|
||||
|
||||
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repositoryConnection.has_personal_access) {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.connectGitFirst'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (branchName.trim() === '') {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchRequired'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
isCheckingBranchName.value = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
ProjectTicketBranchController.validate.url(
|
||||
{
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
branch_name: branchName,
|
||||
repository_connection_id:
|
||||
repositoryConnectionId,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
method: 'GET',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Branch validation failed.');
|
||||
}
|
||||
|
||||
branchNameValidation.value =
|
||||
(await response.json()) as BranchNameValidationModel;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchValidationFailed', {
|
||||
app: appName,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
isCheckingBranchName.value = false;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
onCleanup(() => {
|
||||
abortController.abort();
|
||||
window.clearTimeout(timeoutId);
|
||||
isCheckingBranchName.value = false;
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const submitBranchCreation = (): void => {
|
||||
if (
|
||||
!canCreateBranches.value ||
|
||||
!selectedBranchRepositoryHasPersonalAccess.value ||
|
||||
branchNameValidation.value?.is_valid !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
branchProtection.submit(
|
||||
ProjectTicketBranchController.store({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
isCreateBranchDialogOpen.value = false;
|
||||
},
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
|
||||
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.git') }}
|
||||
</h2>
|
||||
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<Card class="h-fit">
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.branchesDescription')
|
||||
}}</CardDescription>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
:open="isCreateBranchDialogOpen"
|
||||
@update:open="updateBranchDialogOpen"
|
||||
>
|
||||
<Button
|
||||
v-if="canCreateBranches"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
@click="isCreateBranchDialogOpen = true"
|
||||
>
|
||||
<GitBranchPlus class="size-4" />{{
|
||||
t('ticketDetail.createBranch')
|
||||
}}</Button
|
||||
>
|
||||
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle>{{
|
||||
t('ticketDetail.createBranchTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('ticketDetail.createBranchDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
class="grid gap-4"
|
||||
@submit.prevent="submitBranchCreation"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="branchProtection.state.value"
|
||||
@retry="submitBranchCreation"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="repository_connection_id">{{
|
||||
t('ticketDetail.repository')
|
||||
}}</Label>
|
||||
<select
|
||||
id="repository_connection_id"
|
||||
v-model="
|
||||
branchCreationForm.repository_connection_id
|
||||
"
|
||||
:disabled="
|
||||
branchCreationForm.processing
|
||||
"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="repositoryConnection in linkedRepositoryConnections"
|
||||
:key="repositoryConnection.id"
|
||||
:value="
|
||||
String(repositoryConnection.id)
|
||||
"
|
||||
>
|
||||
{{
|
||||
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
branchCreationForm.errors
|
||||
.repository_connection_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="branch_name">{{
|
||||
t('ticketDetail.branchName')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="branch_name"
|
||||
v-model="branchCreationForm.branch_name"
|
||||
:disabled="
|
||||
branchCreationForm.processing ||
|
||||
!selectedBranchRepositoryHasPersonalAccess
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="
|
||||
branchCreationForm.errors
|
||||
.branch_name
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border px-4 py-3"
|
||||
:class="
|
||||
branchNameValidation?.is_valid
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
||||
"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<CheckCircle2
|
||||
v-if="
|
||||
branchNameValidation?.is_valid
|
||||
"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<XCircle
|
||||
v-else-if="
|
||||
branchNameValidation !== null
|
||||
"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{{
|
||||
isCheckingBranchName
|
||||
? t(
|
||||
'ticketDetail.checkingBranch',
|
||||
)
|
||||
: (branchNameValidation?.message ??
|
||||
t(
|
||||
'ticketDetail.selectRepository',
|
||||
))
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!selectedBranchRepositoryHasPersonalAccess &&
|
||||
selectedBranchRepositoryConnection !==
|
||||
null
|
||||
"
|
||||
class="border-border rounded-xl border border-dashed px-4 py-4"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.branchAccess') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="updateBranchDialogOpen(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
branchCreationForm.processing ||
|
||||
isCheckingBranchName ||
|
||||
branchNameValidation?.is_valid !==
|
||||
true
|
||||
"
|
||||
>{{
|
||||
t('ticketDetail.createNow')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryBranches.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="t('tickets.noBranchesDescription')"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="repositoryBranch in visibleRepositoryBranches"
|
||||
:key="repositoryBranch.repository_connection.id"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.base_url
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!repositoryBranch.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.inspectBranches') }}
|
||||
</p>
|
||||
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="repositoryBranch.error"
|
||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{ repositoryBranch.error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-3 flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="branch in repositoryBranch.branches"
|
||||
:key="branch.name"
|
||||
variant="outline"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ branch.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.pullRequestsDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryPullRequests.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="t('tickets.noPullRequestsDescription')"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
||||
:key="
|
||||
repositoryPullRequest.repository_connection.id
|
||||
"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.repository_full_name
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.base_url
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!repositoryPullRequest.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.inspectPullRequests') }}
|
||||
</p>
|
||||
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="repositoryPullRequest.error"
|
||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{ repositoryPullRequest.error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||
:key="pullRequest.html_url"
|
||||
class="border-border/70 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
#{{ pullRequest.number }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ pullRequest.status }}
|
||||
</Badge>
|
||||
<a
|
||||
:href="pullRequest.html_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
{{ pullRequest.title }}
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
{{
|
||||
t('ticketDetail.sourceBranch', {
|
||||
name: pullRequest.source_branch,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TicketGitBranches
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:can-edit-ticket="canEditTicket"
|
||||
:repository-branches="repositoryBranches"
|
||||
:suggested-branch-name="suggestedBranchName"
|
||||
/>
|
||||
<TicketGitPullRequests
|
||||
:repository-pull-requests="repositoryPullRequests"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@ const sections = [
|
||||
'description',
|
||||
'checklist',
|
||||
'metadata',
|
||||
'blocker',
|
||||
'relationships',
|
||||
'history',
|
||||
'git',
|
||||
|
||||
@@ -8,11 +8,21 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { UserAvatar } from '@/components/ui/avatar';
|
||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
|
||||
defineProps<TicketDetailHistoryProps>();
|
||||
const { t, date } = useLocalization();
|
||||
const formatDateTime = (value: string): string =>
|
||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
||||
created: 'ticketDetail.historyType.created',
|
||||
link_removed: 'ticketDetail.historyType.linkRemoved',
|
||||
link_updated: 'ticketDetail.historyType.linkUpdated',
|
||||
status_changed: 'ticketDetail.historyType.statusChanged',
|
||||
updated: 'ticketDetail.historyType.updated',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
|
||||
{{ t('ticketDetail.noHistory') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div
|
||||
<ol v-else class="relative grid gap-0" role="list">
|
||||
<li
|
||||
v-for="worklogEntry in ticket.worklog_entries"
|
||||
:key="worklogEntry.id"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ worklogEntry.entry_type }}
|
||||
</Badge>
|
||||
<span class="text-foreground text-sm font-medium">
|
||||
{{
|
||||
worklogEntry.actor?.name ??
|
||||
t('ticketDetail.system')
|
||||
}}
|
||||
<div
|
||||
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
||||
>
|
||||
<UserAvatar
|
||||
v-if="worklogEntry.actor"
|
||||
:user="worklogEntry.actor"
|
||||
size="lg"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-foreground mt-3 text-sm">
|
||||
{{ worklogEntry.summary }}
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-2 text-xs">
|
||||
{{ formatDateTime(worklogEntry.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<article
|
||||
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="text-foreground text-sm font-semibold"
|
||||
>
|
||||
{{
|
||||
worklogEntry.actor?.name ??
|
||||
t('ticketDetail.system')
|
||||
}}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
t(
|
||||
entryTypeLabels[
|
||||
worklogEntry.entry_type
|
||||
],
|
||||
)
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<time
|
||||
:datetime="worklogEntry.created_at"
|
||||
class="text-muted-foreground shrink-0 text-xs"
|
||||
>
|
||||
{{
|
||||
formatDateTime(worklogEntry.created_at)
|
||||
}}
|
||||
</time>
|
||||
</div>
|
||||
<p class="text-foreground mt-3 text-sm leading-6">
|
||||
{{ worklogEntry.summary }}
|
||||
</p>
|
||||
</article>
|
||||
</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ExternalLink, GitBranch } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketGitBranchesProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const visibleRepositories = computed(() =>
|
||||
props.repositoryBranches.filter(
|
||||
(repositoryBranch) =>
|
||||
repositoryBranch.branches.length > 0 ||
|
||||
repositoryBranch.error !== null ||
|
||||
!repositoryBranch.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||
props.repositoryBranches.map(
|
||||
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||
),
|
||||
);
|
||||
const canCreateBranch = computed(
|
||||
() => props.canEditTicket && repositoryConnections.value.length > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-1.5">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<GitBranch
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('ticketDetail.branches') }}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>{ t('ticketDetail.branchesDescription') }}</CardDescription
|
||||
>
|
||||
</div>
|
||||
|
||||
<TicketBranchCreateDialog
|
||||
v-if="canCreateBranch"
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:repository-connections="repositoryConnections"
|
||||
:suggested-branch-name="suggestedBranchName"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 pt-6">
|
||||
<FeedbackState
|
||||
v-if="repositoryBranches.length === 0"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.noLinkedRepositories')"
|
||||
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="visibleRepositories.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="t('tickets.noBranchesDescription')"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="repositoryBranch in visibleRepositories"
|
||||
v-else
|
||||
:key="repositoryBranch.repository_connection.id"
|
||||
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<header class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<a
|
||||
:href="
|
||||
repositoryBranch.repository_connection
|
||||
.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openRepositoryExternal', {
|
||||
repository:
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name,
|
||||
provider:
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ repositoryBranch.repository_connection.base_url }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="
|
||||
!repositoryBranch.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.gitAccessRequired')"
|
||||
:description="t('ticketDetail.inspectBranches')"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="repositoryBranch.error"
|
||||
state="error"
|
||||
:title="t('ticketDetail.branchesUnavailable')"
|
||||
:description="repositoryBranch.error"
|
||||
/>
|
||||
|
||||
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<li
|
||||
v-for="branch in repositoryBranch.branches"
|
||||
:key="branch.name"
|
||||
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<GitBranch
|
||||
class="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="wrap-anywhere font-mono text-xs">{{
|
||||
branch.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketGitPullRequestsProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const visibleRepositories = computed(() =>
|
||||
props.repositoryPullRequests.filter(
|
||||
(repositoryPullRequest) =>
|
||||
repositoryPullRequest.pull_requests.length > 0 ||
|
||||
repositoryPullRequest.error !== null ||
|
||||
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<GitPullRequest
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('ticketDetail.pullRequests') }}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 pt-6">
|
||||
<FeedbackState
|
||||
v-if="repositoryPullRequests.length === 0"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.noLinkedRepositories')"
|
||||
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="visibleRepositories.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="t('tickets.noPullRequestsDescription')"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="repositoryPullRequest in visibleRepositories"
|
||||
v-else
|
||||
:key="repositoryPullRequest.repository_connection.id"
|
||||
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<header class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<a
|
||||
:href="
|
||||
repositoryPullRequest.repository_connection
|
||||
.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openRepositoryExternal', {
|
||||
repository:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.repository_full_name,
|
||||
provider:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.repository_full_name
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection.base_url
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="
|
||||
!repositoryPullRequest.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.gitAccessRequired')"
|
||||
:description="t('ticketDetail.inspectPullRequests')"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="repositoryPullRequest.error"
|
||||
state="error"
|
||||
:title="t('ticketDetail.pullRequestsUnavailable')"
|
||||
:description="repositoryPullRequest.error"
|
||||
/>
|
||||
|
||||
<ul v-else class="grid min-w-0 gap-3">
|
||||
<li
|
||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||
:key="pullRequest.html_url"
|
||||
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
#{{ pullRequest.number }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ pullRequest.status }}
|
||||
</Badge>
|
||||
<a
|
||||
:href="pullRequest.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openPullRequestExternal', {
|
||||
number: pullRequest.number,
|
||||
provider:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
pullRequest.title
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
t('ticketDetail.sourceBranch', {
|
||||
name: pullRequest.source_branch,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,120 +1,107 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onScopeDispose, ref } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
|
||||
export type UseTwoFactorAuthReturn = {
|
||||
qrCodeSvg: Ref<string | null>;
|
||||
manualSetupKey: Ref<string | null>;
|
||||
recoveryCodesList: Ref<string[]>;
|
||||
errors: Ref<string[]>;
|
||||
hasSetupData: ComputedRef<boolean>;
|
||||
clearSetupData: () => void;
|
||||
clearErrors: () => void;
|
||||
clearTwoFactorAuthData: () => void;
|
||||
fetchQrCode: () => Promise<void>;
|
||||
fetchSetupKey: () => Promise<void>;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
};
|
||||
/** Keep secrets within the consuming component and discard late responses after hiding them. */
|
||||
export function useTwoFactorAuth() {
|
||||
const { t } = useLocalization();
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
let pending: AbortController | null = null;
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || response.redirected) {
|
||||
throw new Error(String(response.status));
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
function clearErrors(): void {
|
||||
errors.value = [];
|
||||
}
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
|
||||
const hasSetupData = computed<boolean>(
|
||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
||||
);
|
||||
|
||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
||||
const fetchQrCode = async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
||||
qrCode.url(),
|
||||
);
|
||||
|
||||
qrCodeSvg.value = svg;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch QR code');
|
||||
qrCodeSvg.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupKey = async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
);
|
||||
|
||||
manualSetupKey.value = key;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch a setup key');
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSetupData = (): void => {
|
||||
function clearTwoFactorAuthData(): void {
|
||||
pending?.abort();
|
||||
pending = null;
|
||||
loading.value = false;
|
||||
manualSetupKey.value = null;
|
||||
qrCodeSvg.value = null;
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const clearErrors = (): void => {
|
||||
errors.value = [];
|
||||
};
|
||||
|
||||
const clearTwoFactorAuthData = (): void => {
|
||||
clearSetupData();
|
||||
clearErrors();
|
||||
recoveryCodesList.value = [];
|
||||
};
|
||||
clearErrors();
|
||||
}
|
||||
|
||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
||||
async function load(kind: 'setup' | 'recovery'): Promise<void> {
|
||||
clearTwoFactorAuthData();
|
||||
const request = new AbortController();
|
||||
pending = request;
|
||||
loading.value = true;
|
||||
try {
|
||||
clearErrors();
|
||||
recoveryCodesList.value = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
);
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch recovery codes');
|
||||
recoveryCodesList.value = [];
|
||||
if (kind === 'setup') {
|
||||
const [qr, key] = await Promise.all([
|
||||
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
|
||||
fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
request.signal,
|
||||
),
|
||||
]);
|
||||
if (pending !== request) {
|
||||
return;
|
||||
}
|
||||
qrCodeSvg.value = qr.svg;
|
||||
manualSetupKey.value = key.secretKey;
|
||||
} else {
|
||||
const codes = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
request.signal,
|
||||
);
|
||||
if (pending === request) {
|
||||
recoveryCodesList.value = codes;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (pending === request && !request.signal.aborted) {
|
||||
const status = error instanceof Error ? error.message : '';
|
||||
errors.value = [
|
||||
t(
|
||||
status === '423' || status === '401' || status === '419'
|
||||
? 'security.confirmAgain'
|
||||
: kind === 'setup'
|
||||
? 'security.setupError'
|
||||
: 'security.recoveryError',
|
||||
),
|
||||
];
|
||||
}
|
||||
} finally {
|
||||
if (pending === request) {
|
||||
pending = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupData = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
qrCodeSvg.value = null;
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onScopeDispose(clearTwoFactorAuthData);
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
errors,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
clearErrors,
|
||||
loading,
|
||||
hasSetupData: computed(() =>
|
||||
Boolean(qrCodeSvg.value && manualSetupKey.value),
|
||||
),
|
||||
clearSetupData: clearTwoFactorAuthData,
|
||||
clearRecoveryCodes: clearTwoFactorAuthData,
|
||||
clearTwoFactorAuthData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
clearErrors,
|
||||
fetchSetupData: () => load('setup'),
|
||||
fetchRecoveryCodes: () => load('recovery'),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: t('navigation.profile'),
|
||||
href: editProfile(),
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
title: t('navigation.security'),
|
||||
href: editSecurity(),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
title: t('navigation.gitConnections'),
|
||||
href: editGitConnections(),
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
title: t('navigation.applications'),
|
||||
href: editApplications(),
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: t('navigation.appearance'),
|
||||
href: editAppearance(),
|
||||
icon: Palette,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-6">
|
||||
<Heading
|
||||
:title="t('navigation.settings')"
|
||||
:description="t('navigation.settingsDescription')"
|
||||
/>
|
||||
<div
|
||||
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||
>
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<div class="text-primary flex items-center gap-2">
|
||||
<Settings2 class="size-4" aria-hidden="true" />
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('settings.eyebrow') }}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{ t('navigation.settings') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('navigation.settingsDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
||||
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
||||
<div
|
||||
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||
>
|
||||
<aside
|
||||
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||
>
|
||||
<nav
|
||||
class="flex gap-1 overflow-x-auto lg:flex-col"
|
||||
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||
:aria-label="t('navigation.settings')"
|
||||
>
|
||||
<Button
|
||||
<Link
|
||||
v-for="item in sidebarNavItems"
|
||||
:key="toUrl(item.href)"
|
||||
variant="ghost"
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
isCurrentOrParentUrl(item.href) ? 'page' : undefined
|
||||
"
|
||||
:class="[
|
||||
'shrink-0 justify-start lg:w-full',
|
||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
||||
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||
isCurrentOrParentUrl(item.href)
|
||||
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||
]"
|
||||
as-child
|
||||
>
|
||||
<Link
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
isCurrentOrParentUrl(item.href)
|
||||
? 'page'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
</Button>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator class="lg:hidden" />
|
||||
|
||||
<div class="min-w-0 flex-1 md:max-w-2xl">
|
||||
<section class="max-w-xl space-y-12">
|
||||
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,94 @@
|
||||
export const en = {
|
||||
'security.title': 'Security settings',
|
||||
'security.passwordTitle': 'Update password',
|
||||
'security.passwordDescription':
|
||||
'Use a long, unique password to keep your account secure.',
|
||||
'security.currentPassword': 'Current password',
|
||||
'security.newPassword': 'New password',
|
||||
'security.confirmPassword': 'Confirm password',
|
||||
'security.savePassword': 'Save password',
|
||||
'security.savingPassword': 'Saving password…',
|
||||
'security.passwordSaved': 'Password saved.',
|
||||
'security.twoFactorTitle': 'Two-factor authentication',
|
||||
'security.twoFactorDescription':
|
||||
'Add a second layer of protection with a code from your authenticator app.',
|
||||
'security.enabledDescription':
|
||||
'Your authenticator app provides a six-digit code when you sign in. Keep your recovery codes somewhere safe.',
|
||||
'security.enabled': 'Enabled',
|
||||
'security.disabled': 'Disabled',
|
||||
'security.pending': 'Awaiting confirmation',
|
||||
'security.enable': 'Enable 2FA',
|
||||
'security.disable': 'Disable 2FA',
|
||||
'security.continueSetup': 'Continue setup',
|
||||
'security.disableTitle': 'Disable two-factor authentication?',
|
||||
'security.disableDescription':
|
||||
'You will no longer need an authenticator code to sign in. Your existing recovery codes will be removed.',
|
||||
'security.setupTitle': 'Set up your authenticator',
|
||||
'security.setupDescription':
|
||||
'Scan this QR code with a TOTP authenticator app, or enter the setup key manually.',
|
||||
'security.setupEnabled': 'Two-factor authentication enabled',
|
||||
'security.setupEnabledDescription':
|
||||
'Finish adding this account to your authenticator app and save your recovery codes.',
|
||||
'security.verifyTitle': 'Verify authentication code',
|
||||
'security.verifyDescription':
|
||||
'Enter the six-digit code from your authenticator app to finish enabling two-factor authentication.',
|
||||
'security.authenticationCode': 'Authentication code',
|
||||
'security.manualKey': 'Manual setup key',
|
||||
'security.copyKey': 'Copy key',
|
||||
'security.copied': 'Copied',
|
||||
'security.copyError':
|
||||
'Copying failed. Select the key and copy it manually.',
|
||||
'security.confirm': 'Confirm',
|
||||
'security.continue': 'Continue',
|
||||
'security.loadingSetup': 'Loading authenticator setup…',
|
||||
'security.setupPendingDescription':
|
||||
'Protection starts after you confirm a code. Closing this dialog keeps setup unfinished; you can continue from this page.',
|
||||
'security.confirmAgain':
|
||||
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
|
||||
'security.setupError':
|
||||
'Authenticator setup could not be loaded. Please try again.',
|
||||
'security.recoveryError':
|
||||
'Recovery codes could not be loaded. Please try again.',
|
||||
'security.recoveryTitle': '2FA recovery codes',
|
||||
'security.recoveryDescription':
|
||||
'Recovery codes let you regain access if you lose your authenticator. Store them in a secure password manager.',
|
||||
'security.hideRecovery': 'Hide recovery codes',
|
||||
'security.viewRecovery': 'View recovery codes',
|
||||
'security.loadingRecovery': 'Loading recovery codes…',
|
||||
'security.regenerate': 'Regenerate codes',
|
||||
'security.recoveryOnce':
|
||||
'Each recovery code can be used once. Hiding this section removes the codes from this page.',
|
||||
'security.regenerateTitle': 'Regenerate recovery codes?',
|
||||
'security.regenerateDescription':
|
||||
'All previous recovery codes will stop working. Save the new codes in a secure place.',
|
||||
'applications.title': 'Applications',
|
||||
'applications.description':
|
||||
'Create API keys for tools that use KanbanGit with your account permissions. Delete an application to revoke its access.',
|
||||
'applications.saveKey': 'Save this API key now.',
|
||||
'applications.keyOnce':
|
||||
'This key is shown only after creation. It will not be shown again after you leave this page.',
|
||||
'applications.apiKey': 'API key',
|
||||
'applications.create': 'Create application',
|
||||
'applications.nameDescription':
|
||||
'Name the tool that will receive an API key.',
|
||||
'applications.name': 'Application name',
|
||||
'applications.namePlaceholder': 'For example, Claude Desktop',
|
||||
'applications.creating': 'Creating application…',
|
||||
'applications.saved': 'Saved applications',
|
||||
'applications.hiddenKeys':
|
||||
'Existing keys stay hidden. Create a new application when another key is needed.',
|
||||
'applications.empty': 'No applications have been created yet.',
|
||||
'applications.created': 'Created:',
|
||||
'applications.lastUsed': 'Last used:',
|
||||
'applications.never': 'Never',
|
||||
'applications.delete': 'Delete',
|
||||
'applications.deleteNamed': 'Delete {name}',
|
||||
'applications.deleteTitle': 'Delete application?',
|
||||
'applications.deleteDescription':
|
||||
'{name} will lose access immediately. This cannot be undone.',
|
||||
'common.back': 'Back',
|
||||
'common.done': 'Done',
|
||||
|
||||
'criteria.title': 'Acceptance criteria',
|
||||
'criteria.progress': '{completed} of {total} complete',
|
||||
'criteria.empty': 'No acceptance criteria yet.',
|
||||
@@ -359,6 +449,7 @@ export const en = {
|
||||
'navigation.appearance': 'Appearance',
|
||||
'navigation.settingsDescription':
|
||||
'Manage your profile, security, and Git integration settings.',
|
||||
'settings.eyebrow': 'Account settings',
|
||||
'navigation.createTicket': 'New ticket',
|
||||
'navigation.createProject': 'New project',
|
||||
'navigation.open': 'Open navigation',
|
||||
@@ -373,8 +464,58 @@ export const en = {
|
||||
'navigation.breadcrumbs': 'Breadcrumbs',
|
||||
'appearance.label': 'Color theme',
|
||||
'appearance.light': 'Light',
|
||||
'appearance.lightDescription':
|
||||
'Use a bright workspace with dark text and clear borders.',
|
||||
'appearance.dark': 'Dark',
|
||||
'appearance.darkDescription':
|
||||
'Reduce glare with a darker workspace and balanced contrast.',
|
||||
'appearance.system': 'System',
|
||||
'appearance.systemDescription':
|
||||
'Follow the light or dark preference set on this device.',
|
||||
'appearance.title': 'Appearance',
|
||||
'appearance.description': 'Choose how KanbanGit looks on this device.',
|
||||
'appearance.persistenceHint':
|
||||
'Your choice is saved on this device and restored when you open KanbanGit again.',
|
||||
'profile.title': 'Profile',
|
||||
'profile.description':
|
||||
'Keep your personal details and profile picture up to date.',
|
||||
'profile.information': 'Profile information',
|
||||
'profile.picture': 'Profile picture',
|
||||
'profile.pictureDescription':
|
||||
'Help teammates recognize you throughout the workspace.',
|
||||
'profile.picturePreview': 'Profile picture preview',
|
||||
'profile.choosePicture': 'Choose picture',
|
||||
'profile.changePicture': 'Choose another',
|
||||
'profile.discardPicture': 'Discard selection',
|
||||
'profile.removePicture': 'Remove picture',
|
||||
'profile.removingPicture': 'Removing picture…',
|
||||
'profile.selectedPicture': 'Selected: {name}',
|
||||
'profile.pictureHelp':
|
||||
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
|
||||
'profile.name': 'Name',
|
||||
'profile.namePlaceholder': 'Full name',
|
||||
'profile.email': 'Email address',
|
||||
'profile.emailPlaceholder': 'Email address',
|
||||
'profile.emailUnverified': 'Your email address is unverified.',
|
||||
'profile.resendVerification': 'Resend the verification email',
|
||||
'profile.verificationSent':
|
||||
'A new verification link has been sent to your email address.',
|
||||
'profile.save': 'Save profile',
|
||||
'profile.saving': 'Saving profile…',
|
||||
'profile.unsaved': 'You have unsaved profile changes.',
|
||||
'profile.delete.title': 'Delete account',
|
||||
'profile.delete.description':
|
||||
'Permanently delete your account and all of its resources.',
|
||||
'profile.delete.warning': 'This action cannot be undone',
|
||||
'profile.delete.warningDescription':
|
||||
'Your account and associated data will be permanently removed.',
|
||||
'profile.delete.action': 'Delete account',
|
||||
'profile.delete.confirmTitle':
|
||||
'Are you sure you want to delete your account?',
|
||||
'profile.delete.confirmDescription':
|
||||
'Enter your password to confirm that you want to permanently delete your account and its data.',
|
||||
'profile.delete.password': 'Password',
|
||||
'profile.delete.confirmAction': 'Permanently delete account',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.retry': 'Try again',
|
||||
@@ -456,9 +597,24 @@ export const en = {
|
||||
'ticketDetail.checklistPlaceholder':
|
||||
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||
'ticketDetail.blocker': 'Blocker',
|
||||
'ticketDetail.blockerDescription':
|
||||
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
|
||||
'ticketDetail.blockerActive': 'Blocked',
|
||||
'ticketDetail.blockerInactive': 'Not blocked',
|
||||
'ticketDetail.blockerActiveDescription':
|
||||
'A blocker reason is required while this ticket is blocked.',
|
||||
'ticketDetail.blockerInactiveDescription':
|
||||
'This ticket currently has no active blocker.',
|
||||
'ticketDetail.blockerComment': 'Blocker comment',
|
||||
'ticketDetail.blockerCommentHelp':
|
||||
'Explain the concrete dependency or decision preventing progress.',
|
||||
'ticketDetail.blockerPlaceholder':
|
||||
'Describe what currently blocks progress on this ticket.',
|
||||
'ticketDetail.saveBlocker': 'Save blocker',
|
||||
'ticketDetail.unblockTicket': 'Unblock ticket',
|
||||
'ticketDetail.resetBlocker': 'Discard blocker changes',
|
||||
'ticketDetail.savingBlocker': 'Saving blocker…',
|
||||
'ticketDetail.customFields': 'Custom fields',
|
||||
'ticketDetail.customFieldsDescription':
|
||||
'Project-configured fields can be tracked directly on the ticket.',
|
||||
@@ -476,8 +632,20 @@ export const en = {
|
||||
'ticketDetail.branchName': 'Branch name',
|
||||
'ticketDetail.branchAccess':
|
||||
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||
'ticketDetail.gitAccessRequired': 'Personal Git access required',
|
||||
'ticketDetail.personalAccessRequired': 'personal Git access required',
|
||||
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||
'ticketDetail.createNow': 'Create now',
|
||||
'ticketDetail.creatingBranch': 'Creating branch…',
|
||||
'ticketDetail.noLinkedRepositories': 'No linked repositories',
|
||||
'ticketDetail.noLinkedRepositoriesDescription':
|
||||
'A project administrator needs to link a repository before Git activity can appear here.',
|
||||
'ticketDetail.branchesUnavailable': 'Branches unavailable',
|
||||
'ticketDetail.pullRequestsUnavailable': 'Pull requests unavailable',
|
||||
'ticketDetail.openRepositoryExternal':
|
||||
'Open {repository} on {provider} (opens in a new tab)',
|
||||
'ticketDetail.openPullRequestExternal':
|
||||
'Open pull request #{number} on {provider} (opens in a new tab)',
|
||||
'ticketDetail.inspectBranches':
|
||||
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||
'ticketDetail.pullRequests': 'Pull requests',
|
||||
@@ -533,6 +701,11 @@ export const en = {
|
||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||
'ticketDetail.system': 'System',
|
||||
'ticketDetail.historyType.created': 'Ticket created',
|
||||
'ticketDetail.historyType.updated': 'Ticket updated',
|
||||
'ticketDetail.historyType.statusChanged': 'Status changed',
|
||||
'ticketDetail.historyType.linkUpdated': 'Link added',
|
||||
'ticketDetail.historyType.linkRemoved': 'Link removed',
|
||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||
'ticketForm.create': 'Create ticket',
|
||||
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
|
||||
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
||||
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
||||
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
||||
@@ -64,7 +65,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
/>
|
||||
<aside
|
||||
:aria-label="t('ticketDetail.metadata')"
|
||||
class="min-w-0 xl:col-start-2 xl:row-span-4 xl:row-start-1"
|
||||
class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
|
||||
>
|
||||
<TicketDetailMetadata
|
||||
:ticket="ticket"
|
||||
@@ -73,6 +74,12 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
:ticket-field-definitions="ticketFieldDefinitions"
|
||||
/>
|
||||
</aside>
|
||||
<TicketDetailBlocker
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:can-edit-ticket="canEditTicket"
|
||||
class="xl:col-start-1"
|
||||
/>
|
||||
<TicketDetailRelationships
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { edit } from '@/routes/appearance/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{
|
||||
title: 'Appearance settings',
|
||||
title: t('navigation.appearance'),
|
||||
href: edit(),
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||
<Head title="Appearance settings" />
|
||||
|
||||
<h1 class="sr-only">Appearance settings</h1>
|
||||
<Head :title="t('appearance.title')" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Appearance settings"
|
||||
description="Update your account's appearance settings"
|
||||
/>
|
||||
<AppearanceTabs />
|
||||
</div>
|
||||
<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('appearance.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('appearance.description') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||
<AppearanceTabs />
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('appearance.persistenceHint') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,198 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
destroy,
|
||||
store,
|
||||
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.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 { Input } from '@/components/ui/input';
|
||||
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 as editApplications } from '@/routes/applications/index';
|
||||
import { edit } from '@/routes/applications/index';
|
||||
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||
|
||||
defineProps<SettingsApplicationsPageProps>();
|
||||
const props = defineProps<SettingsApplicationsPageProps>();
|
||||
const { t, locale } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.applications'), href: edit() },
|
||||
]);
|
||||
const form = useForm({ name: '' });
|
||||
const protection = useFormProtection(form);
|
||||
const deleteForm = useForm({});
|
||||
const deleteProtection = useFormProtection(deleteForm);
|
||||
const selected = ref<
|
||||
SettingsApplicationsPageProps['applications'][number] | null
|
||||
>(null);
|
||||
const copied = ref(false);
|
||||
const copyError = ref(false);
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Applications',
|
||||
href: editApplications(),
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (value: string | null): string => {
|
||||
if (value === null) {
|
||||
return 'Never';
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
return t('applications.never');
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
};
|
||||
}
|
||||
function createApplication(): void {
|
||||
protection.submit(store(), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
}
|
||||
function closeDelete(open: boolean): void {
|
||||
if (!open && !deleteForm.processing) {
|
||||
selected.value = null;
|
||||
deleteProtection.clearFeedback();
|
||||
}
|
||||
}
|
||||
function deleteApplication(): void {
|
||||
if (!selected.value) {
|
||||
return;
|
||||
}
|
||||
deleteProtection.submit(destroy(selected.value.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
selected.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
async function copyKey(): Promise<void> {
|
||||
const key = props.applicationKey;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
copyError.value = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
if (props.applicationKey === key) {
|
||||
copied.value = true;
|
||||
}
|
||||
} catch {
|
||||
if (props.applicationKey === key) {
|
||||
copyError.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.applicationKey,
|
||||
() => {
|
||||
copied.value = false;
|
||||
copyError.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||
<Head title="Applications" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('applications.title')" />
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="space-y-2">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Applications"
|
||||
description="Create API keys for tools that should use KanbanGit with your account permissions."
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Keys are shown once. Delete an application when a tool
|
||||
should lose access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="applicationKey"
|
||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
|
||||
>
|
||||
<p class="font-medium">Save this API key now.</p>
|
||||
<p class="mt-1">
|
||||
It will not be shown again after you leave this page.
|
||||
</p>
|
||||
<div class="gap-stack grid">
|
||||
<h2 class="text-title">{{ t('applications.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('applications.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="status"
|
||||
role="status"
|
||||
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
|
||||
>
|
||||
{{ status }}
|
||||
</p>
|
||||
<section
|
||||
v-if="applicationKey"
|
||||
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
|
||||
aria-labelledby="application-key-title"
|
||||
>
|
||||
<h3 id="application-key-title" class="font-semibold">
|
||||
{{ t('applications.saveKey') }}
|
||||
</h3>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('applications.keyOnce') }}
|
||||
</p>
|
||||
<Label for="application-key" class="sr-only">{{
|
||||
t('applications.apiKey')
|
||||
}}</Label>
|
||||
<div class="flex min-w-0 flex-wrap gap-2">
|
||||
<Input
|
||||
class="mt-3 font-mono text-xs"
|
||||
id="application-key"
|
||||
:model-value="applicationKey"
|
||||
readonly
|
||||
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
|
||||
/>
|
||||
<Button variant="outline" @click="copyKey"
|
||||
><Check v-if="copied" /><Copy v-else />{{
|
||||
t(copied ? 'security.copied' : 'security.copyKey')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create application</CardTitle>
|
||||
<CardDescription>
|
||||
Name the tool that will receive an API key.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="UserApplicationController.store.form()"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
<p
|
||||
v-if="copied"
|
||||
role="status"
|
||||
class="text-caption text-success-foreground"
|
||||
>
|
||||
{{ t('security.copied') }}
|
||||
</p>
|
||||
<InputError
|
||||
v-if="copyError"
|
||||
:message="t('security.copyError')"
|
||||
/>
|
||||
</section>
|
||||
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<KeyRound class="text-primary size-5" />{{
|
||||
t('applications.create')
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('applications.nameDescription') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="p-5 sm:p-6">
|
||||
<form
|
||||
class="gap-column grid"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="createApplication"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('applications.creating')"
|
||||
@retry="createApplication"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="application-name">{{
|
||||
t('applications.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="application-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
autocomplete="off"
|
||||
:placeholder="t('applications.namePlaceholder')"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="application-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="application-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="justify-self-start"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('applications.creating')"
|
||||
>{{ t('applications.create') }}</Button
|
||||
>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('applications.hiddenKeys') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="gap-stack grid p-5 sm:p-6">
|
||||
<p
|
||||
v-if="!applications.length"
|
||||
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
|
||||
>
|
||||
{{ t('applications.empty') }}
|
||||
</p>
|
||||
<article
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div class="gap-stack grid min-w-0 flex-1 basis-40">
|
||||
<h4 class="break-words font-semibold">
|
||||
{{ application.name }}
|
||||
</h4>
|
||||
<dl
|
||||
class="gap-stack text-caption text-muted-foreground grid"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Application name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Claude Desktop"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
<div>
|
||||
<dt class="inline font-medium">
|
||||
{{ t('applications.created') }}
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
{{ formatDate(application.created_at) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Create application
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Saved applications</CardTitle>
|
||||
<CardDescription>
|
||||
Existing keys stay hidden. Create a new
|
||||
application when another key is needed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-if="applications.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
No applications have been created yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
class="border-border rounded-xl border p-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge>API key</Badge>
|
||||
<span class="text-sm font-medium">
|
||||
{{ application.name }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Created:
|
||||
{{
|
||||
formatDate(
|
||||
application.created_at,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Last used:
|
||||
{{
|
||||
formatDate(
|
||||
application.last_used_at,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
UserApplicationController.destroy.form(
|
||||
application.id,
|
||||
)
|
||||
"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
<div>
|
||||
<dt class="inline font-medium">
|
||||
{{ t('applications.lastUsed') }}
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
{{
|
||||
formatDate(application.last_used_at)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-destructive"
|
||||
:aria-label="
|
||||
t('applications.deleteNamed', {
|
||||
name: application.name,
|
||||
})
|
||||
"
|
||||
@click="selected = application"
|
||||
><Trash2 />{{ t('applications.delete') }}</Button
|
||||
>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog :open="!!selected" @update:open="closeDelete">
|
||||
<DialogContent
|
||||
:show-close-button="!deleteForm.processing"
|
||||
@escape-key-down="
|
||||
deleteForm.processing && $event.preventDefault()
|
||||
"
|
||||
@interact-outside="
|
||||
deleteForm.processing && $event.preventDefault()
|
||||
"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
t('applications.deleteTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('applications.deleteDescription', {
|
||||
name: selected?.name ?? '',
|
||||
})
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AuthFormFeedback
|
||||
:state="deleteProtection.state.value"
|
||||
:response-status="deleteProtection.responseStatus.value"
|
||||
@retry="deleteApplication"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="deleteForm.processing"
|
||||
@click="closeDelete(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:loading="deleteForm.processing"
|
||||
@click="deleteApplication"
|
||||
>{{ t('applications.delete') }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,209 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { Trash2 } from 'lucide-vue-next';
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
|
||||
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import DeleteUser from '@/components/DeleteUser.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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/profile/index';
|
||||
import { send } from '@/routes/verification';
|
||||
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
defineProps<SettingsProfilePageProps>();
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Profile settings',
|
||||
href: edit(),
|
||||
},
|
||||
];
|
||||
|
||||
const { t } = useLocalization();
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const avatarPreview = ref<string | null>(null);
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{
|
||||
title: t('navigation.profile'),
|
||||
href: edit(),
|
||||
},
|
||||
]);
|
||||
const profileForm = useForm('SettingsProfile', {
|
||||
name: user.value.name,
|
||||
email: user.value.email,
|
||||
avatar: null as File | null,
|
||||
});
|
||||
const profileProtection = useFormProtection(profileForm);
|
||||
const avatarDeleteForm = useForm({});
|
||||
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
|
||||
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
|
||||
const avatarPreview = ref<string | null>(null);
|
||||
const displayedAvatar = computed(
|
||||
() => avatarPreview.value ?? user.value.avatar,
|
||||
);
|
||||
const displayedUser = computed<UserModel>(() => ({
|
||||
...user.value,
|
||||
avatar: displayedAvatar.value,
|
||||
}));
|
||||
const canRemoveAvatar = computed(
|
||||
() => user.value.avatar !== null && !user.value.avatar_is_generated,
|
||||
() =>
|
||||
avatarPreview.value === null &&
|
||||
user.value.avatar !== null &&
|
||||
!user.value.avatar_is_generated,
|
||||
);
|
||||
const profileUpdateForm = ProfileController.update.form();
|
||||
const profileUpdateTarget = {
|
||||
url: profileUpdateForm.action,
|
||||
method: profileUpdateForm.method,
|
||||
};
|
||||
|
||||
const clearAvatarPreview = (): void => {
|
||||
function clearAvatarPreview(): void {
|
||||
if (avatarPreview.value) {
|
||||
URL.revokeObjectURL(avatarPreview.value);
|
||||
avatarPreview.value = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleAvatarChange = (event: Event) => {
|
||||
function clearAvatarSelection(): void {
|
||||
clearAvatarPreview();
|
||||
profileForm.avatar = null;
|
||||
profileForm.clearErrors('avatar');
|
||||
profileProtection.clearFeedback();
|
||||
if (avatarInput.value) {
|
||||
avatarInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleAvatarChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const file = input.files?.[0] ?? null;
|
||||
|
||||
clearAvatarPreview();
|
||||
profileForm.avatar = file;
|
||||
profileForm.clearErrors('avatar');
|
||||
profileProtection.clearFeedback();
|
||||
|
||||
if (file) {
|
||||
avatarPreview.value = URL.createObjectURL(file);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const removeAvatar = (): void => {
|
||||
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
|
||||
function submitProfile(): void {
|
||||
profileProtection.submit(profileUpdateTarget, {
|
||||
forceFormData: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: clearAvatarPreview,
|
||||
onSuccess: () => {
|
||||
profileForm.defaults({
|
||||
name: profileForm.name,
|
||||
email: profileForm.email,
|
||||
avatar: null,
|
||||
});
|
||||
clearAvatarSelection();
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAvatarPreview();
|
||||
});
|
||||
function removeAvatar(): void {
|
||||
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
|
||||
preserveScroll: true,
|
||||
onSuccess: clearAvatarSelection,
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearAvatarPreview);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||
<Head title="Profile settings" />
|
||||
|
||||
<h1 class="sr-only">Profile settings</h1>
|
||||
<Head :title="t('profile.title')" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Profile information"
|
||||
description="Update your name, email address, and profile picture"
|
||||
/>
|
||||
<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('profile.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('profile.description') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<Form
|
||||
v-bind="ProfileController.update.form()"
|
||||
class="space-y-6"
|
||||
v-slot="{
|
||||
errors,
|
||||
processing,
|
||||
progress,
|
||||
recentlySuccessful,
|
||||
}"
|
||||
>
|
||||
<div class="grid gap-3">
|
||||
<Label for="avatar">Profile picture</Label>
|
||||
<div class="flex items-center gap-4">
|
||||
<UserAvatar class="size-16" />
|
||||
<div class="grid flex-1 gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@change="handleAvatarChange"
|
||||
/>
|
||||
<Button
|
||||
v-if="canRemoveAvatar"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="avatarDeleteForm.processing"
|
||||
data-test="delete-profile-picture-button"
|
||||
@click="removeAvatar"
|
||||
<CardContent class="p-5 sm:p-6">
|
||||
<form
|
||||
class="gap-column grid min-w-0"
|
||||
:aria-busy="profileForm.processing"
|
||||
@submit.prevent="submitProfile"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="profileProtection.state.value"
|
||||
:title="
|
||||
profileForm.processing
|
||||
? t('profile.saving')
|
||||
: undefined
|
||||
"
|
||||
@retry="submitProfile"
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
class="gap-column grid min-w-0"
|
||||
:disabled="profileForm.processing"
|
||||
>
|
||||
<legend class="sr-only">
|
||||
{{ t('profile.information') }}
|
||||
</legend>
|
||||
|
||||
<section
|
||||
aria-labelledby="profile-picture-title"
|
||||
class="gap-column grid min-w-0 border-b pb-6"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<h3
|
||||
id="profile-picture-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
Remove
|
||||
</Button>
|
||||
{{ t('profile.picture') }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('profile.pictureDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
PNG, JPG, or WebP up to 2 MB.
|
||||
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="displayedUser"
|
||||
size="xl"
|
||||
class="ring-border shrink-0 ring-1"
|
||||
:label="t('profile.picturePreview')"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0 flex-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button as-child variant="outline">
|
||||
<label
|
||||
for="avatar"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Camera
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{
|
||||
avatarPreview
|
||||
? t(
|
||||
'profile.changePicture',
|
||||
)
|
||||
: t(
|
||||
'profile.choosePicture',
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="avatarPreview"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="clearAvatarSelection"
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
{{
|
||||
t('profile.discardPicture')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="canRemoveAvatar"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
:disabled="
|
||||
avatarDeleteForm.processing
|
||||
"
|
||||
data-test="delete-profile-picture-button"
|
||||
@click="removeAvatar"
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
{{ t('profile.removePicture') }}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
id="avatar"
|
||||
ref="avatarInput"
|
||||
name="avatar"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
class="sr-only"
|
||||
aria-describedby="avatar-help avatar-error"
|
||||
:aria-invalid="
|
||||
!!profileForm.errors.avatar
|
||||
"
|
||||
@change="handleAvatarChange"
|
||||
/>
|
||||
<p
|
||||
v-if="profileForm.avatar"
|
||||
class="text-caption min-w-0 break-all font-medium"
|
||||
>
|
||||
{{
|
||||
t('profile.selectedPicture', {
|
||||
name: profileForm.avatar
|
||||
.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
id="avatar-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('profile.pictureHelp') }}
|
||||
</p>
|
||||
<progress
|
||||
v-if="profileForm.progress"
|
||||
class="h-1 w-full overflow-hidden rounded-full"
|
||||
:value="
|
||||
profileForm.progress.percentage
|
||||
"
|
||||
max="100"
|
||||
>
|
||||
{{
|
||||
profileForm.progress.percentage
|
||||
}}%
|
||||
</progress>
|
||||
<InputError
|
||||
id="avatar-error"
|
||||
:message="profileForm.errors.avatar"
|
||||
/>
|
||||
<FormFeedback
|
||||
:state="
|
||||
avatarDeleteProtection.state
|
||||
.value
|
||||
"
|
||||
:title="
|
||||
avatarDeleteForm.processing
|
||||
? t(
|
||||
'profile.removingPicture',
|
||||
)
|
||||
: undefined
|
||||
"
|
||||
@retry="removeAvatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">{{
|
||||
t('profile.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="profileForm.name"
|
||||
name="name"
|
||||
required
|
||||
autocomplete="name"
|
||||
:placeholder="t('profile.namePlaceholder')"
|
||||
:aria-invalid="!!profileForm.errors.name"
|
||||
aria-describedby="name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="name-error"
|
||||
:message="profileForm.errors.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">{{
|
||||
t('profile.email')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="profileForm.email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
:placeholder="t('profile.emailPlaceholder')"
|
||||
:aria-invalid="!!profileForm.errors.email"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
<InputError
|
||||
id="email-error"
|
||||
:message="profileForm.errors.email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
mustVerifyEmail && !user.email_verified_at
|
||||
"
|
||||
class="bg-muted/55 rounded-lg border p-4"
|
||||
>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('profile.emailUnverified') }}
|
||||
<Link
|
||||
:href="send()"
|
||||
as="button"
|
||||
class="text-primary font-medium underline underline-offset-4"
|
||||
>
|
||||
{{ t('profile.resendVerification') }}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="status === 'verification-link-sent'"
|
||||
role="status"
|
||||
class="text-success-foreground mt-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('profile.verificationSent') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<progress
|
||||
v-if="progress"
|
||||
class="h-1 w-full overflow-hidden rounded-full"
|
||||
:value="progress.percentage"
|
||||
max="100"
|
||||
>
|
||||
{{ progress.percentage }}%
|
||||
</progress>
|
||||
<InputError class="mt-1" :message="errors.avatar" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
class="mt-1 block w-full"
|
||||
name="name"
|
||||
:default-value="user.name"
|
||||
required
|
||||
autocomplete="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<InputError class="mt-2" :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
name="email"
|
||||
:default-value="user.email"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
<InputError class="mt-2" :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div v-if="mustVerifyEmail && !user.email_verified_at">
|
||||
<p class="text-muted-foreground -mt-4 text-sm">
|
||||
Your email address is unverified.
|
||||
<Link
|
||||
:href="send()"
|
||||
as="button"
|
||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to resend the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="status === 'verification-link-sent'"
|
||||
class="mt-2 text-sm font-medium text-green-600"
|
||||
>
|
||||
A new verification link has been sent to your email
|
||||
address.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
:disabled="processing"
|
||||
data-test="update-profile-button"
|
||||
>Save</Button
|
||||
>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-in-out"
|
||||
enter-from-class="opacity-0"
|
||||
leave-active-class="transition ease-in-out"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<p
|
||||
v-show="recentlySuccessful"
|
||||
class="text-sm text-neutral-600"
|
||||
>
|
||||
Saved.
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="profileForm.processing"
|
||||
data-test="update-profile-button"
|
||||
>
|
||||
{{
|
||||
profileForm.processing
|
||||
? t('profile.saving')
|
||||
: t('profile.save')
|
||||
}}
|
||||
</Button>
|
||||
<p
|
||||
v-if="profileForm.isDirty"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('profile.unsaved') }}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeleteUser />
|
||||
</SettingsLayout>
|
||||
|
||||
@@ -1,222 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
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 { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
canManageTwoFactor?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
status?: string | null;
|
||||
twoFactorEnabled?: boolean;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
canManageTwoFactor: false,
|
||||
mustChangePassword: false,
|
||||
requiresConfirmation: false,
|
||||
status: null,
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canManageTwoFactor?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: 'Security settings',
|
||||
href: edit(),
|
||||
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);
|
||||
|
||||
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
|
||||
const showSetupModal = ref<boolean>(false);
|
||||
|
||||
onUnmounted(() => clearTwoFactorAuthData());
|
||||
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="Security settings" />
|
||||
|
||||
<h1 class="sr-only">Security settings</h1>
|
||||
|
||||
<Head :title="t('security.title')" />
|
||||
<SettingsLayout>
|
||||
<div
|
||||
v-if="mustChangePassword || status"
|
||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{
|
||||
mustChangePassword
|
||||
? 'You must change your password before continuing anywhere else in the app.'
|
||||
: status
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Update password"
|
||||
description="Ensure your account is using a long, random password to stay secure"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="SecurityController.update.form()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
reset-on-success
|
||||
:reset-on-error="[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing, recentlySuccessful }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="current_password">Current password</Label>
|
||||
<PasswordInput
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="current-password"
|
||||
placeholder="Current password"
|
||||
/>
|
||||
<InputError :message="errors.current_password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">New password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation"
|
||||
>Confirm password</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
:disabled="processing"
|
||||
data-test="update-password-button"
|
||||
>
|
||||
Save password
|
||||
</Button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-in-out"
|
||||
enter-from-class="opacity-0"
|
||||
leave-active-class="transition ease-in-out"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<p
|
||||
v-show="recentlySuccessful"
|
||||
class="text-sm text-neutral-600"
|
||||
>
|
||||
Saved.
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div v-if="canManageTwoFactor" class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Two-factor authentication"
|
||||
description="Manage your two-factor authentication settings"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!twoFactorEnabled"
|
||||
class="flex flex-col items-start justify-start space-y-4"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
When you enable two-factor authentication, you will be
|
||||
prompted for a secure pin during login. This pin can be
|
||||
retrieved from a TOTP-supported application on your
|
||||
phone.
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
v-if="hasSetupData"
|
||||
@click="showSetupModal = true"
|
||||
</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"
|
||||
>
|
||||
<ShieldCheck />Continue setup
|
||||
</Button>
|
||||
<Form
|
||||
v-else
|
||||
v-bind="enable.form()"
|
||||
@success="showSetupModal = true"
|
||||
#default="{ processing }"
|
||||
>
|
||||
<Button type="submit" :disabled="processing">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-start justify-start space-y-4"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
You will be prompted for a secure, random pin during
|
||||
login, which you can retrieve from the TOTP-supported
|
||||
application on your phone.
|
||||
</p>
|
||||
|
||||
<div class="relative inline">
|
||||
<Form v-bind="disable.form()" #default="{ 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
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="justify-self-start"
|
||||
:loading="passwordForm.processing"
|
||||
:loading-label="t('security.savingPassword')"
|
||||
data-test="update-password-button"
|
||||
>{{ t('security.savePassword') }}</Button
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</Form>
|
||||
</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>
|
||||
|
||||
<TwoFactorRecoveryCodes />
|
||||
</div>
|
||||
|
||||
<TwoFactorSetupModal
|
||||
v-model:isOpen="showSetupModal"
|
||||
:requiresConfirmation="requiresConfirmation"
|
||||
:twoFactorEnabled="twoFactorEnabled"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
export type TicketDetailHeaderProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canEditTicket'
|
||||
@@ -15,6 +16,10 @@ export type TicketDetailContentProps = Pick<
|
||||
| 'ticketTypes'
|
||||
| 'users'
|
||||
>;
|
||||
export type TicketDetailBlockerProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canEditTicket'
|
||||
>;
|
||||
export type TicketDetailMetadataProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||
@@ -28,6 +33,24 @@ export type TicketDetailGitProps = Pick<
|
||||
| 'repositoryPullRequests'
|
||||
| 'suggestedBranchName'
|
||||
>;
|
||||
export type TicketGitBranchesProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'repositoryBranches'
|
||||
| 'suggestedBranchName'
|
||||
>;
|
||||
export type TicketGitPullRequestsProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'repositoryPullRequests'
|
||||
>;
|
||||
export type TicketBranchCreateDialogProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'suggestedBranchName'
|
||||
> & {
|
||||
repositoryConnections: RepositoryConnectionModel[];
|
||||
};
|
||||
export type TicketDetailRelationshipsProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileUpdateTest extends TestCase
|
||||
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
|
||||
$response->assertSessionHasErrors('avatar');
|
||||
}
|
||||
|
||||
public function test_profile_and_appearance_settings_render_the_shared_localized_interface(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/settings.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Settings rendering and localization checks passed.', $process->getOutput());
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account()
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
|
||||
|
||||
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
||||
}
|
||||
|
||||
public function test_two_factor_setup_requires_a_valid_code_before_protection_is_enabled(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()])
|
||||
->from(route('security.edit'))
|
||||
->post(route('two-factor.enable'))->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertNotNull($user->refresh()->two_factor_secret);
|
||||
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||
$this->getJson(route('two-factor.secret-key'))->assertOk()->assertJsonStructure(['secretKey']);
|
||||
$this->getJson(route('two-factor.qr-code'))->assertOk()->assertJsonStructure(['svg']);
|
||||
|
||||
$this->mock(TwoFactorAuthenticationProvider::class, function ($mock): void {
|
||||
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '000000')->once()->andReturn(false);
|
||||
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '123456')->once()->andReturn(true);
|
||||
});
|
||||
$this->post(route('two-factor.confirm'), ['code' => '000000'])
|
||||
->assertSessionHasErrors('code', errorBag: 'confirmTwoFactorAuthentication');
|
||||
$this->assertFalse($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||
$this->post(route('two-factor.confirm'), ['code' => '123456'])->assertSessionHasNoErrors();
|
||||
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||
}
|
||||
|
||||
public function test_recovery_codes_can_be_retrieved_regenerated_and_removed_with_two_factor(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
||||
$oldCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||
$this->assertSame($user->recoveryCodes(), $oldCodes);
|
||||
$this->post(route('two-factor.regenerate-recovery-codes'))->assertSessionHasNoErrors();
|
||||
$newCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||
$this->assertCount(8, $newCodes);
|
||||
$this->assertEmpty(array_intersect($oldCodes, $newCodes));
|
||||
$this->delete(route('two-factor.disable'))->assertSessionHasNoErrors();
|
||||
$this->assertNull($user->refresh()->two_factor_secret);
|
||||
$this->assertNull($user->two_factor_recovery_codes);
|
||||
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||
}
|
||||
|
||||
public function test_sensitive_two_factor_endpoints_reject_expired_password_confirmation(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
$this->actingAs($user);
|
||||
foreach (['two-factor.qr-code', 'two-factor.secret-key', 'two-factor.recovery-codes'] as $route) {
|
||||
$this->getJson(route($route))->assertStatus(423);
|
||||
}
|
||||
$this->deleteJson(route('two-factor.disable'))->assertStatus(423);
|
||||
$this->postJson(route('two-factor.regenerate-recovery-codes'))->assertStatus(423);
|
||||
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SecurityUiTest extends TestCase
|
||||
{
|
||||
public function test_security_and_application_key_rendering(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/security.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Security UI rendering checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ class UserApplicationTest extends TestCase
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('settings/Applications')
|
||||
->where('applications.0.name', 'Claude Desktop')
|
||||
->where('applicationKey', null),
|
||||
->where('applicationKey', null)
|
||||
->missing('applications.0.key_hash')
|
||||
->missing('applications.0.plain_text_key'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,4 +102,26 @@ class UserApplicationTest extends TestCase
|
||||
'id' => $application->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_application_list_only_contains_the_current_users_keys(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
UserApplication::factory()->for($user)->create(['name' => 'Own tool']);
|
||||
UserApplication::factory()->create(['name' => 'Foreign tool']);
|
||||
$this->actingAs($user)->get(route('applications.edit'))
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->has('applications', 1)
|
||||
->where('applications.0.name', 'Own tool')
|
||||
->where('applicationKey', null));
|
||||
}
|
||||
|
||||
public function test_invalid_application_name_does_not_issue_a_key(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->from(route('applications.edit'))
|
||||
->post(route('applications.store'), ['name' => ''])
|
||||
->assertSessionHasErrors('name')
|
||||
->assertSessionMissing('applicationKey');
|
||||
$this->assertDatabaseCount('user_applications', 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,15 +84,11 @@ try {
|
||||
renderTheme('light'),
|
||||
renderTheme('dark'),
|
||||
]);
|
||||
const selectedButton = (html) =>
|
||||
html.match(
|
||||
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
|
||||
)[1];
|
||||
assert.match(selectedButton(light), />Light</);
|
||||
assert.match(selectedButton(dark), />Dark</);
|
||||
assert.match(light, /role="group" aria-label="Color theme"/);
|
||||
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
|
||||
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
|
||||
assert.match(light, /value="light"[^>]*checked/);
|
||||
assert.match(dark, /value="dark"[^>]*checked/);
|
||||
assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
|
||||
assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
|
||||
assert.equal((dark.match(/ checked/g) ?? []).length, 1);
|
||||
assert.equal(normalizeAppearance('unexpected'), 'system');
|
||||
|
||||
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4185';
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'security_test_result', value, url: base }]);
|
||||
const enabled = (value) =>
|
||||
context.addCookies([
|
||||
{ name: 'security_enabled', value: String(value), url: base },
|
||||
]);
|
||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||
const dialog = () => page.getByRole('dialog');
|
||||
let recoveryFetches = 0;
|
||||
page.on('request', (request) => {
|
||||
if (
|
||||
request.url().endsWith('/user/two-factor-recovery-codes') &&
|
||||
request.method() === 'GET'
|
||||
) {
|
||||
recoveryFetches++;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
for (const path of ['security', 'applications']) {
|
||||
await page.goto(`${base}/settings/${path}`);
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name:
|
||||
path === 'security'
|
||||
? 'Update password'
|
||||
: 'Applications',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`${path} overflow at ${width}/${theme}`,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.ok(
|
||||
(await page.locator('section').first().boundingBox())
|
||||
.width <= width,
|
||||
`${path} content is clipped at ${width}/${theme}`,
|
||||
);
|
||||
}
|
||||
await page.goto(`${base}/settings/security`);
|
||||
await button('Enable 2FA').click();
|
||||
await page.getByLabel('Manual setup key').waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Manual setup key').inputValue(),
|
||||
'MANUALSETUPTESTKEY',
|
||||
);
|
||||
assert.equal(
|
||||
await dialog().evaluate(
|
||||
(el) => el.scrollWidth <= el.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await button('Continue').click();
|
||||
const code = page.getByLabel('Authentication code', {
|
||||
exact: true,
|
||||
});
|
||||
await code.waitFor();
|
||||
assert.equal(await code.getAttribute('inputmode'), 'numeric');
|
||||
assert.equal(
|
||||
await code.getAttribute('autocomplete'),
|
||||
'one-time-code',
|
||||
);
|
||||
await code.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await button('Back').evaluate(
|
||||
(el) => el === document.activeElement,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
assert.equal(await page.getByLabel('Manual setup key').count(), 0);
|
||||
assert.match(
|
||||
await page.locator('body').innerText(),
|
||||
/Awaiting confirmation/,
|
||||
);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await button('Continue setup').click();
|
||||
await button('Continue').click();
|
||||
await result('validation');
|
||||
await page
|
||||
.getByLabel('Authentication code', { exact: true })
|
||||
.fill('000000');
|
||||
await button('Confirm').click();
|
||||
await page
|
||||
.getByText('The provided two factor authentication code was invalid.', {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Authentication code', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Authentication code', { exact: true })
|
||||
.inputValue(),
|
||||
'',
|
||||
);
|
||||
await result('success');
|
||||
await page
|
||||
.getByLabel('Authentication code', { exact: true })
|
||||
.fill('123456');
|
||||
await button('Confirm').click();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await button('Disable 2FA').waitFor();
|
||||
assert.equal(
|
||||
recoveryFetches,
|
||||
0,
|
||||
'Recovery codes are fetched only on request',
|
||||
);
|
||||
await button('View recovery codes').click();
|
||||
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||
await button('Hide recovery codes').click();
|
||||
assert.equal(
|
||||
await page.getByText('old-code-0001', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await button('View recovery codes').click();
|
||||
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||
assert.equal(recoveryFetches, 2);
|
||||
await button('Regenerate codes').click();
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(
|
||||
await page.getByText('old-code-0001', { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
await button('Regenerate codes').click();
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Regenerate codes', exact: true })
|
||||
.click();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||
assert.equal(recoveryFetches, 3);
|
||||
await button('Hide recovery codes').click();
|
||||
await page.route('**/user/two-factor-recovery-codes', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await route.fulfill({ json: ['late-secret-code'] });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
await button('View recovery codes').click();
|
||||
await button('Hide recovery codes').click();
|
||||
await page.waitForTimeout(450);
|
||||
assert.equal(
|
||||
await page.getByText('late-secret-code', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await page.unroute('**/user/two-factor-recovery-codes');
|
||||
await page.route('**/user/two-factor-recovery-codes', (route) =>
|
||||
route.fulfill({ status: 423, json: {} }),
|
||||
);
|
||||
await button('View recovery codes').click();
|
||||
await page
|
||||
.getByText(
|
||||
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
|
||||
{ exact: true },
|
||||
)
|
||||
.waitFor();
|
||||
await page.unroute('**/user/two-factor-recovery-codes');
|
||||
await button('Try again').click();
|
||||
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||
await button('Disable 2FA').click();
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await button('Disable 2FA').click();
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Disable 2FA', exact: true })
|
||||
.click();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await button('Enable 2FA').waitFor();
|
||||
assert.equal(
|
||||
await page.getByText('old-code-0001', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await page.route('**/user/two-factor-secret-key', (route) => route.abort());
|
||||
await button('Enable 2FA').click();
|
||||
await page
|
||||
.getByText(
|
||||
'Authenticator setup could not be loaded. Please try again.',
|
||||
{ exact: true },
|
||||
)
|
||||
.waitFor();
|
||||
assert.equal(await button('Continue').count(), 0);
|
||||
await page.unroute('**/user/two-factor-secret-key');
|
||||
await button('Try again').click();
|
||||
await page.getByLabel('Manual setup key').waitFor();
|
||||
await page.locator('[data-slot="dialog-close"]').click();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await page.getByLabel('Current password', { exact: true }).fill('wrong');
|
||||
await page.getByLabel('New password', { exact: true }).fill('new-password');
|
||||
await page
|
||||
.getByLabel('Confirm password', { exact: true })
|
||||
.fill('new-password');
|
||||
await result('validation');
|
||||
await button('Save password').click();
|
||||
await page
|
||||
.getByText('The password is incorrect.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Current password', { exact: true }).inputValue(),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('New password', { exact: true }).inputValue(),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Current password', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
await page.evaluate(() => JSON.stringify(history.state)),
|
||||
/MANUALSETUPTESTKEY|123456|new-password/,
|
||||
);
|
||||
|
||||
await result('success');
|
||||
await page.goto(`${base}/settings/applications`);
|
||||
assert.equal(await button('Copy key').count(), 0);
|
||||
await page.getByLabel('Application name').fill('My tool');
|
||||
await page.route('**/settings/applications', (route) =>
|
||||
route.request().method() === 'POST' ? route.abort() : route.continue(),
|
||||
);
|
||||
await button('Create application').click();
|
||||
await page.locator('[data-form-state="network"]').waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Application name').inputValue(),
|
||||
'My tool',
|
||||
);
|
||||
await page.unroute('**/settings/applications');
|
||||
await button('Try again').click();
|
||||
await page.getByLabel('API key', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
(await page.locator('section').first().boundingBox()).width <= 390,
|
||||
'One-time key content is clipped on mobile',
|
||||
);
|
||||
await button('Copy key').click();
|
||||
assert.equal(
|
||||
await page.evaluate(() => navigator.clipboard.readText()),
|
||||
'kg_app_one_time_test_key',
|
||||
);
|
||||
await page.reload();
|
||||
assert.equal(await button('Copy key').count(), 0);
|
||||
assert.equal(await page.getByLabel('API key', { exact: true }).count(), 0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
|
||||
.click();
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
|
||||
.click();
|
||||
await result('expired');
|
||||
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await dialog()
|
||||
.getByText('Your session has expired', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.locator('button[aria-label="Delete Desktop tool"]').count(),
|
||||
1,
|
||||
);
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await result('success');
|
||||
await page
|
||||
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
|
||||
.click();
|
||||
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await page
|
||||
.getByText('No applications have been created yet.', { exact: true })
|
||||
.waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Security browser behavior checks passed: 2FA setup, confirmation, cancellation, recovery hiding and regeneration, late responses, errors, password reset, one-time key copy, deletion, keyboard, mobile and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Security from '../../resources/js/pages/settings/Security.vue';
|
||||
import Applications from '../../resources/js/pages/settings/Applications.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => (name === 'Security' ? Security : Applications),
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/security-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const application = {
|
||||
id: 1,
|
||||
name: 'Desktop tool',
|
||||
created_at: '2026-09-14T00:00:00Z',
|
||||
last_used_at: null,
|
||||
};
|
||||
const key = 'kg_app_one_time_test_key';
|
||||
function makePage(url, extra = {}) {
|
||||
return {
|
||||
component: url.startsWith('/settings/security')
|
||||
? 'Security'
|
||||
: 'Applications',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
canManageTwoFactor: true,
|
||||
requiresConfirmation: true,
|
||||
twoFactorEnabled: false,
|
||||
applications: [application],
|
||||
applicationKey: null,
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
avatar_color: '#10B981',
|
||||
avatar_is_generated: true,
|
||||
},
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
const path = new URL(request.url, 'http://localhost').pathname;
|
||||
if (!path.startsWith('/settings') && !path.startsWith('/user/')) {
|
||||
return next();
|
||||
}
|
||||
const result =
|
||||
request.headers.cookie?.match(/security_test_result=(\w+)/)?.[1] ??
|
||||
'success';
|
||||
const mutation = request.method !== 'GET';
|
||||
if (mutation && ['server', 'expired'].includes(result)) {
|
||||
response.statusCode = result === 'expired' ? 419 : 503;
|
||||
response.end('Simulated failure');
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
if (mutation) {
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
}
|
||||
const submitted = body ? JSON.parse(body) : {};
|
||||
const cookies = request.headers.cookie ?? '';
|
||||
let enabled = cookies.includes('security_enabled=true');
|
||||
let extra = { twoFactorEnabled: enabled };
|
||||
let url = path;
|
||||
if (path.startsWith('/user/two-factor-') && !mutation) {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
path.endsWith('qr-code')
|
||||
? {
|
||||
svg: '<svg viewBox="0 0 100 100"><rect width="100" height="100" fill="black"/></svg>',
|
||||
}
|
||||
: path.endsWith('secret-key')
|
||||
? { secretKey: 'MANUALSETUPTESTKEY' }
|
||||
: ['old-code-0001', 'old-code-0002'],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mutation) {
|
||||
url = path.startsWith('/settings/applications')
|
||||
? '/settings/applications'
|
||||
: '/settings/security';
|
||||
if (result === 'validation') {
|
||||
extra.errors = path.includes('confirmed-two-factor')
|
||||
? {
|
||||
confirmTwoFactorAuthentication: {
|
||||
code: 'The provided two factor authentication code was invalid.',
|
||||
},
|
||||
}
|
||||
: path.includes('applications')
|
||||
? { name: 'The name field is required.' }
|
||||
: { current_password: 'The password is incorrect.' };
|
||||
} else if (path.includes('confirmed-two-factor')) {
|
||||
enabled = true;
|
||||
response.setHeader(
|
||||
'Set-Cookie',
|
||||
'security_enabled=true; Path=/',
|
||||
);
|
||||
} else if (
|
||||
path === '/user/two-factor-authentication' &&
|
||||
request.method === 'DELETE'
|
||||
) {
|
||||
enabled = false;
|
||||
response.setHeader(
|
||||
'Set-Cookie',
|
||||
'security_enabled=false; Path=/',
|
||||
);
|
||||
} else if (
|
||||
path === '/settings/applications' &&
|
||||
request.method === 'POST'
|
||||
) {
|
||||
extra.applicationKey = key;
|
||||
extra.applications = [{ ...application, name: submitted.name }];
|
||||
} else if (
|
||||
path.startsWith('/settings/applications/') &&
|
||||
request.method === 'DELETE'
|
||||
) {
|
||||
extra.applications = [];
|
||||
}
|
||||
extra.twoFactorEnabled = enabled;
|
||||
}
|
||||
const page = makePage(url, extra);
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/security-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Security fixture ready.');
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const Security = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/settings/Security.vue',
|
||||
)
|
||||
).default;
|
||||
const Applications = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/settings/Applications.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (url, component, extra = {}) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(url, extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const security = await render('/settings/security', Security);
|
||||
assert.match(security, /autocomplete="current-password"/);
|
||||
assert.match(security, /Enable 2FA/);
|
||||
assert.doesNotMatch(
|
||||
await render('/settings/security', Security, {
|
||||
canManageTwoFactor: false,
|
||||
}),
|
||||
/Enable 2FA/,
|
||||
);
|
||||
const enabled = await render('/settings/security', Security, {
|
||||
twoFactorEnabled: true,
|
||||
});
|
||||
assert.match(enabled, /View recovery codes/);
|
||||
assert.doesNotMatch(enabled, /old-code/);
|
||||
const hidden = await render('/settings/applications', Applications);
|
||||
assert.doesNotMatch(hidden, /id="application-key"/);
|
||||
assert.doesNotMatch(hidden, /Copy key/);
|
||||
const shown = await render('/settings/applications', Applications, {
|
||||
applicationKey: key,
|
||||
});
|
||||
assert.match(shown, /id="application-key"/);
|
||||
assert.match(shown, /Copy key/);
|
||||
assert.match(shown, /kg_app_one_time_test_key/);
|
||||
console.log('Security UI rendering checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4184';
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => {
|
||||
errors.push(error.message);
|
||||
console.error(error.message);
|
||||
});
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
const setResult = (value) =>
|
||||
context.addCookies([{ name: 'settings_test_result', value, url: base }]);
|
||||
const state = (value) =>
|
||||
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
||||
const themeRadio = (value) =>
|
||||
page.locator(`input[name="appearance"][value="${value}"]`);
|
||||
const chooseTheme = (value) =>
|
||||
page
|
||||
.locator(`label:has(input[name="appearance"][value="${value}"])`)
|
||||
.click();
|
||||
const image = {
|
||||
name: 'profile.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.goto(`${base}/settings/appearance`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Settings', exact: true })
|
||||
.waitFor();
|
||||
await chooseTheme(theme);
|
||||
assert.equal(await themeRadio(theme).isChecked(), true);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((element) => element.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`Appearance overflows at ${width}px in ${theme}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await page.goto(`${base}/settings/appearance`);
|
||||
const light = themeRadio('light');
|
||||
await light.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await themeRadio('dark').isChecked(), true);
|
||||
await page.reload();
|
||||
assert.equal(await themeRadio('dark').isChecked(), true);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await page.goto(`${base}/settings/profile`);
|
||||
const avatarInput = page.locator('#avatar');
|
||||
assert.equal(
|
||||
await avatarInput.getAttribute('accept'),
|
||||
'image/png,image/jpeg,image/webp',
|
||||
);
|
||||
await avatarInput.setInputFiles(image);
|
||||
await page.getByText('Selected: profile.png', { exact: true }).waitFor();
|
||||
assert.match(
|
||||
await page
|
||||
.locator('img[alt="Profile picture preview"]')
|
||||
.getAttribute('src'),
|
||||
/^blob:/,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
await setResult('validation');
|
||||
await page.getByLabel('Name', { exact: true }).fill('Updated User');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save profile', exact: true })
|
||||
.click();
|
||||
await state('validation');
|
||||
await page
|
||||
.getByText('The avatar must be a file of type: jpg, jpeg, png, webp.', {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(await avatarInput.getAttribute('aria-invalid'), 'true');
|
||||
assert.equal(
|
||||
await page.getByLabel('Name', { exact: true }).inputValue(),
|
||||
'Updated User',
|
||||
);
|
||||
assert.match(
|
||||
await page
|
||||
.locator('img[alt="Profile picture preview"]')
|
||||
.getAttribute('src'),
|
||||
/^blob:/,
|
||||
);
|
||||
|
||||
await page.route('**/settings/profile?*', (route) => route.abort('failed'));
|
||||
await page
|
||||
.getByRole('button', { name: 'Save profile', exact: true })
|
||||
.click();
|
||||
await state('network');
|
||||
await page.unroute('**/settings/profile?*');
|
||||
await setResult('success');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await state('success');
|
||||
|
||||
await page.goto(`${base}/settings/profile`);
|
||||
await page
|
||||
.getByRole('button', { name: 'Remove picture', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Remove picture', exact: true })
|
||||
.waitFor({ state: 'detached' });
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Settings browser checks passed: avatar preview, upload errors and retry, removal, keyboard, 320/390/1440px and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Appearance from '../../resources/js/pages/settings/Appearance.vue';
|
||||
import Profile from '../../resources/js/pages/settings/Profile.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => (name === 'Appearance' ? Appearance : Profile),
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/settings-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4184, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
function makeUser(generated = false) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: generated ? null : '/avatar.png',
|
||||
avatar_color: '#10B981',
|
||||
avatar_is_generated: generated,
|
||||
email_verified_at: null,
|
||||
must_change_password_on_next_login: false,
|
||||
created_at: '2026-09-14T00:00:00Z',
|
||||
updated_at: '2026-09-14T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makePage(url, errors = {}, generated = false) {
|
||||
return {
|
||||
component: url.startsWith('/settings/appearance')
|
||||
? 'Appearance'
|
||||
: 'Profile',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors,
|
||||
sidebarOpen: true,
|
||||
mustVerifyEmail: true,
|
||||
status: null,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: makeUser(generated),
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/settings')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
let url = request.url;
|
||||
let errors = {};
|
||||
let generated = false;
|
||||
if (request.method !== 'GET') {
|
||||
const result =
|
||||
request.headers.cookie?.match(
|
||||
/settings_test_result=(\w+)/,
|
||||
)?.[1] ?? 'success';
|
||||
if (result === 'server') {
|
||||
response.statusCode = 503;
|
||||
response.end('Simulated server failure');
|
||||
return;
|
||||
}
|
||||
if (result === 'validation') {
|
||||
errors = {
|
||||
avatar: 'The avatar must be a file of type: jpg, jpeg, png, webp.',
|
||||
};
|
||||
}
|
||||
generated = request.url.startsWith('/settings/profile/avatar');
|
||||
url = '/settings/profile';
|
||||
}
|
||||
|
||||
const page = makePage(url, errors, generated);
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en" data-appearance="system"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><script>try{const value=localStorage.getItem("appearance");if(["light","dark","system"].includes(value)){document.documentElement.dataset.appearance=value}}catch{}</script></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/settings-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log(
|
||||
'Settings fixture ready at http://127.0.0.1:4184/settings/profile',
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { en } = await server.ssrLoadModule(
|
||||
'/resources/js/locales/en.ts',
|
||||
);
|
||||
const Profile = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/settings/Profile.vue',
|
||||
)
|
||||
).default;
|
||||
const Appearance = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/settings/Appearance.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (
|
||||
url,
|
||||
component,
|
||||
locale = createLocalization(),
|
||||
) => {
|
||||
const response = await createInertiaApp({
|
||||
page: makePage(url),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance('system')),
|
||||
});
|
||||
return response.body;
|
||||
};
|
||||
|
||||
const profile = await render('/settings/profile', Profile);
|
||||
assert.match(profile, /Account settings/);
|
||||
assert.match(profile, /Profile picture/);
|
||||
assert.match(profile, /Choose picture/);
|
||||
assert.match(profile, /accept="image\/png,image\/jpeg,image\/webp"/);
|
||||
assert.match(
|
||||
profile,
|
||||
/On mobile, you can select from your photo library or camera/,
|
||||
);
|
||||
assert.match(profile, /aria-current="page"[^>]*>[\s\S]*Profile/);
|
||||
assert.match(profile, /Delete account/);
|
||||
|
||||
const appearance = await render('/settings/appearance', Appearance);
|
||||
assert.equal((appearance.match(/name="appearance"/g) ?? []).length, 3);
|
||||
assert.match(appearance, /value="light"/);
|
||||
assert.match(appearance, /value="dark"/);
|
||||
assert.match(appearance, /value="system"[^>]*checked/);
|
||||
assert.match(appearance, /Follow the light or dark preference/);
|
||||
assert.match(appearance, /aria-current="page"[^>]*>[\s\S]*Appearance/);
|
||||
|
||||
const localized = await render(
|
||||
'/settings/profile',
|
||||
Profile,
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{
|
||||
de: {
|
||||
...en,
|
||||
'settings.eyebrow': 'Kontoeinstellungen',
|
||||
'profile.title': 'Profil',
|
||||
'profile.choosePicture': 'Bild auswählen',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.match(localized, /Kontoeinstellungen/);
|
||||
assert.match(localized, /Profil/);
|
||||
assert.match(localized, /Bild auswählen/);
|
||||
console.log('Settings rendering and localization checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,102 @@ try {
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await visit();
|
||||
const blocker = content().locator('#ticket-blocker');
|
||||
const blockerToggle = blocker.getByRole('checkbox', {
|
||||
name: 'Mark ticket as blocked',
|
||||
exact: true,
|
||||
});
|
||||
const blockerComment = blocker.getByLabel('Blocker comment', {
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(await blockerToggle.isChecked(), true);
|
||||
assert.equal(
|
||||
await blocker.getByText('Alex Morgan', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await content()
|
||||
.locator('#ticket-history')
|
||||
.getByText('Alex Morgan', { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await content()
|
||||
.locator('#ticket-history')
|
||||
.getByText('Status changed', { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
await blockerComment.fill('');
|
||||
assert.equal(
|
||||
await blockerComment.evaluate(
|
||||
(element) => element.validity.valueMissing,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await blockerComment.fill('Waiting for a production decision.');
|
||||
await blocker.getByRole('button', { name: 'Save blocker' }).click();
|
||||
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await blockerComment.inputValue(),
|
||||
'Waiting for a production decision.',
|
||||
);
|
||||
for (const label of ['Assignee', 'Reviewer']) {
|
||||
const field = content()
|
||||
.locator('#ticket-metadata dl > div')
|
||||
.filter({ has: page.getByText(label, { exact: true }) });
|
||||
assert.match(await field.locator('dd').innerText(), /Alex Morgan/);
|
||||
}
|
||||
await blockerToggle.uncheck();
|
||||
await blocker.getByRole('button', { name: 'Unblock ticket' }).click();
|
||||
await blocker
|
||||
.getByText('This ticket currently has no active blocker.', {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(await blocker.getByLabel('Blocker comment').count(), 0);
|
||||
await content().getByRole('button', { name: 'Change parent' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.activeElement?.id === 'parent_ticket_id',
|
||||
);
|
||||
const saveTicket = content().getByRole('button', {
|
||||
name: 'Save changes',
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(
|
||||
await content()
|
||||
.getByRole('button', { name: 'Discard changes', exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
'Saving the blocker must also update the ticket form defaults.',
|
||||
);
|
||||
await page
|
||||
.getByLabel('Title', { exact: true })
|
||||
.fill('Updated title after unblocking');
|
||||
const ticketUpdate = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === 'PATCH' &&
|
||||
new URL(request.url()).pathname === '/projects/7/tickets/42',
|
||||
);
|
||||
await saveTicket.click();
|
||||
const savedTicket = (await ticketUpdate).postDataJSON();
|
||||
assert.equal(savedTicket.is_blocked, false);
|
||||
assert.equal(savedTicket.blocked_comment, '');
|
||||
assert.equal(Number(savedTicket.parent_ticket_id), 40);
|
||||
assert.deepEqual(savedTicket.acceptance_criteria, [
|
||||
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
|
||||
{
|
||||
id: 11,
|
||||
text: 'Invalidate when records change',
|
||||
checked: false,
|
||||
indent: 1,
|
||||
},
|
||||
]);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Updated title after unblocking' })
|
||||
.waitFor();
|
||||
await visit();
|
||||
await tabTo(edit());
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
|
||||
@@ -246,6 +342,49 @@ try {
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await visit('?slowBranch&serverError');
|
||||
await page
|
||||
.getByRole('button', { name: 'Create branch', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByLabel('Branch name', { exact: true })
|
||||
.fill('feature/ENG-42-preserve-this-name');
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
const failedBranchCreation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/branches') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
||||
await page.getByText('Creating branch…', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('button[type="submit"]')
|
||||
.last()
|
||||
.getAttribute('aria-busy'),
|
||||
'true',
|
||||
);
|
||||
assert.equal((await failedBranchCreation).status(), 503);
|
||||
await page
|
||||
.getByText('The request could not be completed', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Branch name', { exact: true }).inputValue(),
|
||||
'feature/ENG-42-preserve-this-name',
|
||||
);
|
||||
assert.equal(await page.getByRole('dialog').isVisible(), true);
|
||||
await page.getByLabel('Branch name', { exact: true }).fill('eng-42-cache');
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await visit();
|
||||
const linkSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/links') &&
|
||||
|
||||
@@ -42,6 +42,7 @@ const repository = {
|
||||
provider_label: 'Gitea',
|
||||
base_url: 'https://git.example.test',
|
||||
repository_full_name: 'Engineering/platform',
|
||||
repository_url: 'https://git.example.test/Engineering/platform',
|
||||
has_personal_access: true,
|
||||
};
|
||||
const parent = {
|
||||
@@ -108,9 +109,18 @@ const ticket = {
|
||||
},
|
||||
],
|
||||
worklog_entries: [
|
||||
{
|
||||
id: 2,
|
||||
actor: null,
|
||||
actor_id: null,
|
||||
entry_type: 'status_changed',
|
||||
summary: 'Moved from Backlog to Delivery / In progress.',
|
||||
created_at: '2026-09-11T13:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
actor: user,
|
||||
actor_id: user.id,
|
||||
entry_type: 'created',
|
||||
summary: 'Ticket created in the project backlog.',
|
||||
created_at: '2026-09-11T12:00:00Z',
|
||||
@@ -345,6 +355,9 @@ if (serve) {
|
||||
const data = body ? JSON.parse(body) : {};
|
||||
const previous = new URL(request.headers.referer);
|
||||
const params = previous.searchParams;
|
||||
if (request.url.includes('/branches') && params.has('slowBranch')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
if (params.has('serverError')) {
|
||||
response.writeHead(503);
|
||||
response.end('Simulated failure');
|
||||
@@ -395,7 +408,19 @@ if (serve) {
|
||||
if (params.has('reject')) {
|
||||
params.set('rejected', '1');
|
||||
} else {
|
||||
savedForm = data;
|
||||
savedForm = {
|
||||
...data,
|
||||
blocked_comment: data.is_blocked
|
||||
? data.blocked_comment
|
||||
: null,
|
||||
field_values: data.field_values.map((fieldValue) => ({
|
||||
...fieldValue,
|
||||
user_value:
|
||||
Number(fieldValue.value_user_id) === user.id
|
||||
? user
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
params.set('saved', data.title);
|
||||
}
|
||||
}
|
||||
@@ -476,6 +501,9 @@ if (serve) {
|
||||
'feature/ENG-42-cache',
|
||||
'Cache implementation',
|
||||
'Ticket created in the project backlog.',
|
||||
'Ticket created',
|
||||
'Status changed',
|
||||
'System',
|
||||
]) {
|
||||
assert.ok(
|
||||
html.includes(text),
|
||||
@@ -486,6 +514,7 @@ if (serve) {
|
||||
'description',
|
||||
'checklist',
|
||||
'metadata',
|
||||
'blocker',
|
||||
'relationships',
|
||||
'history',
|
||||
'git',
|
||||
@@ -525,6 +554,14 @@ if (serve) {
|
||||
assert.match(html, /aria-label="Criterion 1"/);
|
||||
assert.match(html, /Add criterion/);
|
||||
assert.match(html, /Create branch/);
|
||||
assert.match(
|
||||
html,
|
||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||
);
|
||||
assert.match(html, /Save link/);
|
||||
assert.match(html, /Outgoing/);
|
||||
const incoming = await render('?incoming');
|
||||
@@ -554,8 +591,7 @@ if (serve) {
|
||||
'Unassigned',
|
||||
'No author',
|
||||
'Not set',
|
||||
'No matching branches',
|
||||
'No matching pull requests',
|
||||
'No linked repositories',
|
||||
'No worklog entries exist',
|
||||
]) {
|
||||
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
||||
@@ -565,11 +601,13 @@ if (serve) {
|
||||
assert.match(hierarchy, /Platform delivery/);
|
||||
assert.match(hierarchy, /Nested child/);
|
||||
assert.match(hierarchy, /Change parent/);
|
||||
assert.match(await render('?noAccess'), /Open Git connections/);
|
||||
assert.match(
|
||||
await render('?gitError'),
|
||||
/Repository temporarily unavailable/,
|
||||
);
|
||||
const noAccess = await render('?noAccess');
|
||||
assert.match(noAccess, /Personal Git access required/);
|
||||
assert.match(noAccess, /Open Git connections/);
|
||||
const gitError = await render('?gitError');
|
||||
assert.match(gitError, /Branches unavailable/);
|
||||
assert.match(gitError, /Pull requests unavailable/);
|
||||
assert.match(gitError, /Repository temporarily unavailable/);
|
||||
const create = await render('?source=board', true);
|
||||
assert.match(create, /Create ticket/);
|
||||
assert.match(create, /Delivery \/ In progress/);
|
||||
|
||||
Reference in New Issue
Block a user