Compare commits

...
19 Commits
Author SHA1 Message Date
Twyco b226ee8d48 Merge pull request 'KAN-42: Rebuild ticket types, priorities and display settings' (#137) from feature/KAN-42-ticket-types-priorities-display into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 22m13s
CI/CD / Publish immutable image (push) Successful in 43s
Reviewed-on: #137
2026-09-15 14:05:51 +00:00
Twyco ac553f0087 feat(KAN-42): rebuild ticket types priorities and display settings
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 16:05:30 +02:00
Twyco 28d20882ac Merge pull request 'KAN-41: Erlaubte Statusübergänge bearbeiten' (#136) from feature/KAN-41-allowed-status-transitions into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #136
2026-09-15 14:05:14 +00:00
Twyco 208989d5fc feat(KAN-41): edit allowed status transitions
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 16:05:06 +02:00
Twyco d2f659de8f Merge pull request 'KAN-40: Workflow-Einstellungen für Spalten und Status' (#135) from feature/KAN-40-workflow-columns-and-statuses into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #135
2026-09-15 14:04:52 +00:00
Twyco f461439ce6 feat(KAN-40): rebuild workflow column and status settings
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 36s
2026-09-15 16:04:05 +02:00
Twyco 3eea473336 Merge pull request 'KAN-39: Allgemeine Projekteinstellungen und Vererbung' (#134) from feature/KAN-39-project-settings-inheritance into main
CI/CD / preconditions (push) Successful in 1m4s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #134
2026-09-15 14:03:25 +00:00
Twyco bfec4f464e feat(KAN-39): rebuild general project settings and inheritance
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
2026-09-15 16:02:41 +02:00
Twyco 2edee702d4 Merge pull request 'KAN-38: Persönliche Git-Verbindungen' (#133) from feature/KAN-38-personal-git-connections into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #133
2026-09-15 14:01:31 +00:00
Twyco 9b90c24224 feat(KAN-38): rebuild personal Git connection settings 2026-09-15 14:01:31 +00:00
Twyco 3869e5b8a6 Merge pull request 'KAN-37: Sicherheit und Anwendungsschlüssel' (#132) from feature/KAN-37-security-application-keys into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #132
2026-09-15 14:01:23 +00:00
Twyco 196623bb67 feat(KAN-37): rebuild security and application key settings 2026-09-15 14:01:23 +00:00
Twyco bc6a113acf Merge pull request 'KAN-36: Profil und Erscheinungsbild' (#131) from feature/KAN-36-profile-and-appearance into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 38s
Reviewed-on: #131
2026-09-15 14:01:17 +00:00
Twyco 1377ee8510 feat(KAN-36): rebuild profile and appearance settings
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Failing after 3m17s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 17:36:41 +02:00
Twyco 2409c3b18f Merge pull request 'KAN-35 ticket changes' (#129) from feature/KAN-35-ticket-git-area-and-branch-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1m1s
Reviewed-on: #129
2026-09-14 14:52:29 +00:00
Twyco f61298fda4 test(KAN-35): isolate branch error browser scenario
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5s
2026-09-14 14:52:19 +00:00
Eric 7a4c6f3865 KAN-35 ticket changes 2026-09-14 14:52:19 +00:00
Twyco 79c78b3434 Merge pull request 'Feature/kan 34 blocker ticket history' (#130) from feature/KAN-34-blocker-ticket-history into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Canceled after 5s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #130
2026-09-14 14:52:12 +00:00
Eric 38438b4700 feat(KAN-34): rework blocker and ticket history
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Failing after 3m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 16:20:09 +02:00
68 changed files with 9829 additions and 3853 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// $exceptions->dontFlash(['api_token']);
})->create(); })->create();
+69 -28
View File
@@ -1,39 +1,80 @@
<script setup lang="ts"> <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 { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization(); const { t } = useLocalization();
const tabs = [ const tabs = computed(() => [
{ value: 'light', Icon: Sun, label: 'appearance.light' }, {
{ value: 'dark', Icon: Moon, label: 'appearance.dark' }, value: 'light' as const,
{ value: 'system', Icon: Monitor, label: 'appearance.system' }, Icon: Sun,
] as const; 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> </script>
<template> <template>
<div <fieldset class="min-w-0">
role="group" <legend class="sr-only">{{ t('appearance.label') }}</legend>
:aria-label="t('appearance.label')" <div class="grid min-w-0 gap-3 sm:grid-cols-3">
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1" <label
> v-for="{ value, Icon, label, description } in tabs"
<button :key="value"
v-for="{ value, Icon, label } in tabs" class="relative min-w-0"
:key="value" >
type="button" <input
:aria-pressed="appearance === value" type="radio"
@click="updateAppearance(value)" name="appearance"
:class="[ :value="value"
'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', :checked="appearance === value"
appearance === value :aria-describedby="`appearance-${value}-description`"
? 'bg-primary text-primary-foreground' class="peer sr-only"
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground', @change="updateAppearance(value)"
]" />
> <span
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" /> 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>{{ t(label) }}</span> >
</button> <span class="flex items-center justify-between gap-3">
</div> <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> </template>
+91 -84
View File
@@ -2,10 +2,10 @@
import { Form } from '@inertiajs/vue3'; import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue'; import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -17,97 +17,104 @@ import {
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput'); const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script> </script>
<template> <template>
<div class="space-y-6"> <Card
<Heading class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
variant="small" >
title="Delete account" <CardHeader class="gap-stack border-b p-5 sm:p-6">
description="Delete your account and all of its resources" <h2 class="text-title">{{ t('profile.delete.title') }}</h2>
/> <p class="text-muted-foreground">
<div {{ t('profile.delete.description') }}
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" </p>
> </CardHeader>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100"> <CardContent class="p-5 sm:p-6">
<p class="font-medium">Warning</p> <div
<p class="text-sm"> class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
Please proceed with caution, this cannot be undone. >
</p> <div class="gap-stack grid">
</div> <p class="text-destructive font-semibold">
<Dialog> {{ t('profile.delete.warning') }}
<DialogTrigger as-child> </p>
<Button variant="destructive" data-test="delete-user-button" <p class="text-body text-muted-foreground">
>Delete account</Button {{ t('profile.delete.warningDescription') }}
> </p>
</DialogTrigger> </div>
<DialogContent> <Dialog>
<Form <DialogTrigger as-child>
v-bind="ProfileController.destroy.form()" <Button
reset-on-success variant="destructive"
@error="() => passwordInput?.focus()" class="justify-self-start"
:options="{ data-test="delete-user-button"
preserveScroll: true, >{{ t('profile.delete.action') }}</Button
}" >
class="space-y-6" </DialogTrigger>
v-slot="{ errors, processing, reset, clearErrors }" <DialogContent>
> <Form
<DialogHeader class="space-y-3"> v-bind="ProfileController.destroy.form()"
<DialogTitle reset-on-success
>Are you sure you want to delete your @error="() => passwordInput?.focus()"
account?</DialogTitle :options="{
> preserveScroll: true,
<DialogDescription> }"
Once your account is deleted, all of its class="space-y-6"
resources and data will also be permanently v-slot="{ errors, processing, reset, clearErrors }"
deleted. Please enter your password to confirm >
you would like to permanently delete your <DialogHeader class="space-y-3">
account. <DialogTitle>{{
</DialogDescription> t('profile.delete.confirmTitle')
</DialogHeader> }}</DialogTitle>
<DialogDescription>
{{ t('profile.delete.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password" class="sr-only" <Label for="password" class="sr-only">{{
>Password</Label t('profile.delete.password')
> }}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
name="password" name="password"
ref="passwordInput" ref="passwordInput"
placeholder="Password" :placeholder="t('profile.delete.password')"
/> />
<InputError :message="errors.password" /> <InputError :message="errors.password" />
</div> </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 <Button
variant="secondary" type="submit"
@click=" variant="destructive"
() => { :disabled="processing"
clearErrors(); data-test="confirm-delete-user-button"
reset();
}
"
> >
Cancel {{ t('profile.delete.confirmAction') }}
</Button> </Button>
</DialogClose> </DialogFooter>
</Form>
<Button </DialogContent>
type="submit" </Dialog>
variant="destructive" </div>
:disabled="processing" </CardContent>
data-test="confirm-delete-user-button" </Card>
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</template> </template>
+150 -104
View File
@@ -1,123 +1,169 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next'; 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 AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth(); const { t } = useLocalization();
const isRecoveryCodesVisible = ref<boolean>(false); const {
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef'); recoveryCodesList,
fetchRecoveryCodes,
clearRecoveryCodes,
errors,
loading,
} = useTwoFactorAuth();
const visible = ref(false);
const showRegenerate = ref(false);
const form = useForm({});
const protection = useFormProtection(form);
const toggleRecoveryCodesVisibility = async () => { function hide(): void {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) { visible.value = false;
await fetchRecoveryCodes(); clearRecoveryCodes();
}
function toggle(): void {
if (visible.value) {
hide();
} else {
visible.value = true;
void fetchRecoveryCodes();
} }
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value; function closeRegenerate(open: boolean): void {
if (!form.processing) {
if (isRecoveryCodesVisible.value) { showRegenerate.value = open;
await nextTick(); protection.clearFeedback();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
} }
}; }
function regenerate(): void {
onMounted(async () => { protection.submit(regenerateRecoveryCodes(), {
if (!recoveryCodesList.value.length) { preserveScroll: true,
await fetchRecoveryCodes(); onBefore: hide,
} onSuccess: () => {
}); showRegenerate.value = false;
visible.value = true;
return fetchRecoveryCodes();
},
});
}
</script> </script>
<template> <template>
<Card class="w-full"> <section
<CardHeader> class="gap-column grid min-w-0 rounded-lg border p-4"
<CardTitle class="flex gap-3"> aria-labelledby="recovery-title"
<LockKeyhole class="size-4" />2FA recovery codes >
</CardTitle> <div class="gap-stack grid">
<CardDescription> <h3
Recovery codes let you regain access if you lose your 2FA id="recovery-title"
device. Store them in a secure password manager. class="flex items-center gap-2 font-semibold"
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
> >
<Button @click="toggleRecoveryCodesVisibility" class="w-fit"> <LockKeyhole class="text-primary size-4" />{{
<component t('security.recoveryTitle')
:is="isRecoveryCodesVisible ? EyeOff : Eye" }}
class="size-4" </h3>
/> <p class="text-body text-muted-foreground">
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery {{ t('security.recoveryDescription') }}
codes </p>
</Button> </div>
<div class="flex flex-wrap gap-3">
<Form <Button
v-if="isRecoveryCodesVisible && recoveryCodesList.length" variant="outline"
v-bind="regenerateRecoveryCodes.form()" :aria-expanded="visible"
method="post" aria-controls="recovery-codes"
:options="{ preserveScroll: true }" :disabled="form.processing"
@success="fetchRecoveryCodes" @click="toggle"
#default="{ processing }" ><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 </template>
variant="secondary" <template v-else>
type="submit" <ul
:disabled="processing" class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
> :aria-label="t('security.recoveryTitle')"
<RefreshCw /> Regenerate codes >
</Button> <li v-for="code in recoveryCodesList" :key="code">
</Form> {{ code }}
</div> </li>
<div </ul>
:class="[ <p class="text-caption text-muted-foreground">
'relative overflow-hidden transition-all duration-300', {{ t('security.recoveryOnce') }}
isRecoveryCodesVisible </p>
? 'h-auto opacity-100' </template>
: 'h-0 opacity-0', </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"> <DialogHeader>
<AlertError :errors="errors" /> <DialogTitle>{{
</div> t('security.regenerateTitle')
<div v-else class="mt-3 space-y-3"> }}</DialogTitle>
<div <DialogDescription>{{
ref="recoveryCodeSectionRef" t('security.regenerateDescription')
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm" }}</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"> <Button :loading="form.processing" @click="regenerate">{{
<div t('security.regenerate')
v-for="n in 8" }}</Button>
:key="n" </DialogFooter>
class="bg-muted-foreground/20 h-4 animate-pulse rounded" </DialogContent>
></div> </Dialog>
</div> </section>
<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>
</template> </template>
+220 -256
View File
@@ -1,297 +1,261 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { Check, Copy, ScanLine } from 'lucide-vue-next'; import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'; import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue'; import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import { Input } from '@/components/ui/input';
InputOTP, import { Label } from '@/components/ui/label';
InputOTPGroup, import { useFormProtection } from '@/composables/useFormProtection';
InputOTPSlot, import { useLocalization } from '@/composables/useLocalization';
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor'; import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = { const props = defineProps<{
requiresConfirmation: boolean; requiresConfirmation: boolean;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
}; }>();
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
const isOpen = defineModel<boolean>('isOpen'); 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(); function close(open: boolean): void {
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } = if (!form.processing) {
useTwoFactorAuth(); isOpen.value = open;
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',
};
} }
}
if (showVerificationStep.value) { async function next(): Promise<void> {
return { if (props.twoFactorEnabled || !props.requiresConfirmation) {
title: 'Verify authentication code', close(false);
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();
});
return; return;
} }
verification.value = true;
clearSetupData(); await nextTick();
isOpen.value = false; codeInput.value?.$el?.focus();
}; }
function submit(): void {
const resetModalState = () => { protection.submit(confirm(), {
if (props.twoFactorEnabled) { preserveScroll: true,
clearSetupData(); errorBag: 'confirmTwoFactorAuthentication',
onSuccess: () => {
isOpen.value = false;
},
onFinish: () => {
form.reset();
},
});
}
async function copyKey(): Promise<void> {
const key = manualSetupKey.value;
if (!key) {
return;
} }
copyError.value = false;
showVerificationStep.value = false; try {
code.value = ''; await navigator.clipboard.writeText(key);
}; if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
} }
} catch {
if (!qrCodeSvg.value) { if (isOpen.value) {
await fetchSetupData(); 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> </script>
<template> <template>
<Dialog :open="isOpen" @update:open="isOpen = $event"> <Dialog :open="isOpen" @update:open="close">
<DialogContent class="sm:max-w-md"> <DialogContent
<DialogHeader class="flex items-center justify-center"> class="sm:max-w-md"
<div :show-close-button="!form.processing"
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm" @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 <div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5" class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
> v-html="qrCodeSvg"
<div />
class="absolute inset-0 grid grid-cols-5 opacity-50" <div class="grid min-w-0 gap-2">
> <Label for="setup-key">{{
<div t('security.manualKey')
v-for="i in 5" }}</Label>
:key="`col-${i}`" <div class="flex min-w-0 gap-2">
class="border-border border-r last:border-r-0" <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>
<div <p
class="absolute inset-0 grid grid-rows-5 opacity-50" v-if="copied"
role="status"
class="text-caption text-success-foreground"
> >
<div {{ t('security.copied') }}
v-for="i in 5" </p>
:key="`row-${i}`" <InputError
class="border-border border-b last:border-b-0" v-if="copyError"
/> :message="t('security.copyError')"
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
/> />
</div> </div>
</div> </div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle> <p
<DialogDescription class="text-center"> v-if="requiresConfirmation && !twoFactorEnabled"
{{ modalConfig.description }} class="text-caption text-muted-foreground"
</DialogDescription> >
</DialogHeader> {{ t('security.setupPendingDescription') }}
</p>
<div <DialogFooter>
class="relative flex w-auto flex-col items-center justify-center space-y-5" <Button variant="outline" @click="close(false)">{{
> t('common.close')
<template v-if="!showVerificationStep"> }}</Button>
<AlertError v-if="errors?.length" :errors="errors" /> <Button v-if="hasSetupData" @click="next">{{
<template v-else> t(
<div twoFactorEnabled || !requiresConfirmation
class="relative mx-auto flex max-w-md items-center overflow-hidden" ? 'common.done'
> : 'security.continue',
<div )
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border" }}</Button>
> </DialogFooter>
<div </template>
v-if="!qrCodeSvg" <form v-else class="gap-column grid" @submit.prevent="submit">
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center" <AuthFormFeedback
> :state="protection.state.value"
<Spinner class="size-6" /> :response-status="protection.responseStatus.value"
</div> @retry="submit"
<div />
v-else <div class="grid gap-2">
class="relative z-10 overflow-hidden border p-5" <Label for="authentication-code">{{
> t('security.authenticationCode')
<div }}</Label>
v-html="qrCodeSvg" <Input
class="flex aspect-square size-full items-center justify-center" id="authentication-code"
:style="{ ref="codeInput"
filter: v-model="form.code"
resolvedAppearance === 'dark' name="code"
? 'invert(1) brightness(1.5)' type="text"
: undefined, inputmode="numeric"
}" autocomplete="one-time-code"
/> pattern="[0-9]{6}"
</div> maxlength="6"
</div> required
</div> :disabled="form.processing"
:aria-invalid="!!form.errors.code"
<div class="flex w-full items-center space-x-5"> aria-describedby="authentication-code-error"
<Button class="w-full" @click="handleModalNextStep"> class="font-mono text-lg tracking-widest"
{{ modalConfig.buttonText }} />
</Button> <InputError
</div> id="authentication-code-error"
:message="form.errors.code"
<div />
class="relative flex w-full items-center justify-center" </div>
> <DialogFooter>
<div <Button
class="bg-border absolute inset-0 top-1/2 h-px w-full" type="button"
/> variant="outline"
<span class="bg-card relative px-2 py-1" :disabled="form.processing"
>or, enter the code manually</span @click="
> verification = false;
</div> form.resetAndClearErrors();
protection.clearFeedback();
<div "
class="flex w-full items-center justify-center space-x-2" >{{ t('common.back') }}</Button
>
<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 }"
> >
<input type="hidden" name="code" :value="code" /> <Button
<div type="submit"
ref="pinInputContainerRef" :disabled="form.code.length !== 6"
class="relative w-full space-y-3" :loading="form.processing"
> >{{ t('security.confirm') }}</Button
<div >
class="flex w-full flex-col items-center justify-center space-y-3 py-2" </DialogFooter>
> </form>
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import {
destroyPriority,
destroyType,
storePriority,
storeType,
updatePriority,
updateType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
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 type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { MetadataEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: MetadataEditorTarget | null;
types: TicketTypeModel[];
priorities: TicketPriorityModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
position: 1,
is_parent: false,
replacement_ticket_type_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deletionError = ref<string | null>(null);
const deleting = computed(() => props.target?.action === 'delete');
const entries = computed(() =>
props.target?.kind === 'type' ? props.types : props.priorities,
);
const replacements = computed(() =>
props.types.filter((type) => type.id !== props.target?.entry?.id),
);
const needsReplacement = computed(
() =>
deleting.value &&
props.target?.kind === 'type' &&
(props.target.entry?.tickets_count ?? 0) > 0,
);
const title = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.action === 'create') {
return t(
target.kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
);
}
return t(
target.action === 'edit'
? 'metadata.editEntry'
: 'metadata.deleteEntry',
{ name: target.entry.name },
);
});
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name: target.entry?.name ?? '',
position: target.entry?.position ?? 1,
is_parent: target.entry?.is_parent ?? false,
replacement_ticket_type_id: '',
});
form.resetAndClearErrors();
protection.clearFeedback();
deletionError.value = null;
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(deleting.value && entries.value.length <= 1)
) {
return;
}
const route =
target.kind === 'type'
? target.action === 'create'
? storeType(props.projectId)
: target.action === 'edit'
? updateType([props.projectId, target.entry.id])
: destroyType([props.projectId, target.entry.id])
: target.action === 'create'
? storePriority(props.projectId)
: target.action === 'edit'
? updatePriority([props.projectId, target.entry.id])
: destroyPriority([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? target.kind === 'type'
? {
replacement_ticket_type_id:
data.replacement_ticket_type_id || null,
}
: {}
: target.action === 'edit'
? { name: data.name, position: data.position }
: target.kind === 'type'
? { name: data.name, is_parent: data.is_parent }
: { name: data.name },
);
deletionError.value = null;
protection.submit(route, {
preserveScroll: true,
onSuccess: (page) => {
if (page.props.error) {
deletionError.value =
typeof page.props.status === 'string'
? page.props.status
: t('common.error');
return;
}
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>{{
t(
deleting
? target?.kind === 'type'
? 'metadata.deleteTypeHelp'
: 'metadata.deletePriorityHelp'
: 'metadata.editHelp',
)
}}</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback
:state="deletionError ? 'idle' : protection.state.value"
@retry="submit"
/>
<p
v-if="deletionError"
role="alert"
class="text-destructive text-sm"
>
{{ deletionError }}
</p>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<template v-if="!deleting">
<div class="grid gap-2">
<Label for="metadata-name">{{
t('metadata.name')
}}</Label>
<Input
id="metadata-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="metadata-name-error"
/>
<InputError
id="metadata-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="target?.action === 'edit'"
class="grid gap-2"
>
<Label for="metadata-position">{{
t('workflow.position')
}}</Label>
<Input
id="metadata-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="entries.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="metadata-position-help metadata-position-error"
/>
<p
id="metadata-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: entries.length,
})
}}
</p>
<InputError
id="metadata-position-error"
:message="form.errors.position"
/>
</div>
<div v-if="target?.kind === 'type'" class="grid gap-2">
<Label
v-if="target.action === 'create'"
for="metadata-parent"
class="flex items-center gap-3"
>
<Checkbox
id="metadata-parent"
v-model="form.is_parent"
name="is_parent"
:invalid="!!form.errors.is_parent"
aria-describedby="metadata-parent-help metadata-parent-error"
/>{{ t('metadata.allowChildren') }}
</Label>
<p v-else class="text-sm font-medium">
{{
t(
form.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}
</p>
<p
id="metadata-parent-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentFixed') }}
</p>
<InputError
id="metadata-parent-error"
:message="form.errors.is_parent"
/>
</div>
</template>
<div v-if="needsReplacement" class="grid gap-2">
<p class="text-sm">
{{
t('metadata.replacementHelp', {
count: target?.entry?.tickets_count ?? 0,
})
}}
</p>
<Label for="metadata-replacement">{{
t('metadata.replacement')
}}</Label>
<select
id="metadata-replacement"
v-model="form.replacement_ticket_type_id"
name="replacement_ticket_type_id"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="
!!form.errors.replacement_ticket_type_id
"
aria-describedby="metadata-replacement-help metadata-replacement-error"
>
<option value="">
{{ t('metadata.chooseType') }}
</option>
<option
v-for="entry in replacements"
:key="entry.id"
:value="entry.id"
>
{{ entry.name
}}{{
entry.is_parent
? ` (${t('metadata.parentType')})`
: ''
}}
</option>
</select>
<p
id="metadata-replacement-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentReplacement') }}
</p>
</div>
<InputError
id="metadata-replacement-error"
:message="form.errors.replacement_ticket_type_id"
/>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
:disabled="deleting && entries.length <= 1"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type {
MetadataEditorTarget,
MetadataEntry,
MetadataKind,
} from '@/types/project-metadata';
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
:aria-labelledby="`metadata-${kind}`"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<h3 :id="`metadata-${kind}`" class="font-semibold">
{{
t(
kind === 'type'
? 'metadata.types'
: 'metadata.priorities',
)
}}
</h3>
<Badge variant="secondary">{{ entries.length }}</Badge>
</div>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="$emit('edit', { kind, action: 'create' })"
>
<Plus class="size-4" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
)
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{
t(
kind === 'type'
? 'metadata.typesDescription'
: 'metadata.prioritiesDescription',
)
}}
</p>
<ol
class="divide-y"
:aria-label="
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
"
>
<li
v-for="entry in entries"
:key="entry.id"
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
:data-metadata-entry="`${kind}-${entry.id}`"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: entry.position,
})
"
>{{ entry.position }}</span
>
<div class="grid min-w-0 flex-1 basis-28 gap-1">
<p class="break-words font-medium">{{ entry.name }}</p>
<div
v-if="kind === 'type'"
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
t(
entry.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}</Badge>
<span>{{
t('metadata.assignedTickets', {
count: entry.tickets_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editEntry', { name: entry.name })
"
@click="$emit('edit', { kind, action: 'edit', entry })"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="entries.length <= 1"
:aria-label="
t('metadata.deleteEntry', { name: entry.name })
"
:aria-describedby="
entries.length <= 1
? `metadata-${kind}-protection`
: undefined
"
@click="
$emit('edit', { kind, action: 'delete', entry })
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ol>
<p
v-if="entries.length <= 1"
:id="`metadata-${kind}-protection`"
class="text-caption text-muted-foreground flex items-start gap-2"
>
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.lastType'
: 'metadata.lastPriority',
)
}}
</p>
</section>
</template>
@@ -1,26 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController'; import { Layers3, RotateCcw } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types'; import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
areas: string[]; areas: string[];
projectId: number; projectId: number;
templateState?: ProjectTemplateState | null; templateState?: ProjectTemplateState | null;
}>(); }>();
const { t } = useLocalization();
const form = useForm<{ const form = useForm<{
areas: string[]; areas: string[];
status_replacements: Record<string, number | undefined>; status_replacements: Record<string, number | undefined>;
@@ -28,51 +34,82 @@ const form = useForm<{
areas: [], areas: [],
status_replacements: {}, status_replacements: {},
}); });
const protection = useFormProtection(form);
const activeArea = computed(() => form.areas[0]); const pendingArea = ref<ProjectTemplateStateArea | null>(null);
const inheritedAreas = computed( const inheritedAreas = computed(
() => () =>
props.templateState?.inheritedAreas.filter((area) => props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value), props.areas.includes(area.value),
) ?? [], ) ?? [],
); );
const overriddenAreas = computed( const overriddenAreas = computed(
() => () =>
props.templateState?.overriddenAreas.filter((area) => props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value), props.areas.includes(area.value),
) ?? [], ) ?? [],
); );
const statusRequirement = computed(() =>
const statusRequirement = computed( pendingArea.value?.value === 'ticket_statuses'
() => props.templateState?.resetRequirements?.ticket_statuses, ? props.templateState?.resetRequirements?.ticket_statuses
: undefined,
); );
const canReset = computed(
const canReset = (area: ProjectTemplateStateArea): boolean => { () =>
if (area.value !== 'ticket_statuses') { statusRequirement.value?.removedStatuses.every((status) =>
return true; statusRequirement.value?.replacementOptions.some(
} (option) =>
option.id === form.status_replacements[String(status.id)],
return ( ),
statusRequirement.value?.removedStatuses.every( ) ?? true,
(status) => );
form.status_replacements[String(status.id)] !== undefined, const areaKeys = {
) ?? true project_settings: 'inheritance.area.projectSettings',
); board_columns: 'inheritance.area.boardColumns',
}; ticket_statuses: 'inheritance.area.ticketStatuses',
status_transitions: 'inheritance.area.statusTransitions',
const resetArea = (area: ProjectTemplateStateArea): void => { ticket_types: 'inheritance.area.ticketTypes',
if (!canReset(area)) { ticket_priorities: 'inheritance.area.ticketPriorities',
ticket_fields: 'inheritance.area.ticketFields',
ticket_link_types: 'inheritance.area.ticketLinkTypes',
repository_connections: 'inheritance.area.repositoryConnections',
notification_actions: 'inheritance.area.notificationActions',
} as const;
function areaLabel(area: ProjectTemplateStateArea): string {
const key = areaKeys[area.value as keyof typeof areaKeys];
return key ? t(key) : area.label;
}
function openReset(area: ProjectTemplateStateArea): void {
form.resetAndClearErrors();
protection.clearFeedback();
pendingArea.value = area;
}
function closeReset(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return; return;
} }
pendingArea.value = null;
form.areas = [area.value]; form.resetAndClearErrors();
form.post(ProjectController.resetTemplate.url(props.projectId), { protection.clearFeedback();
}
function resetArea(): void {
if (
!pendingArea.value ||
!canReset.value ||
!overriddenAreas.value.some(
(area) => area.value === pendingArea.value?.value,
)
) {
return;
}
form.areas = [pendingArea.value.value];
protection.submit(resetTemplate(props.projectId), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => form.reset(), onSuccess: () => {
form.resetAndClearErrors();
pendingArea.value = null;
},
}); });
}; }
</script> </script>
<template> <template>
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
templateState?.inheritsFromTemplate && templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0) (inheritedAreas.length > 0 || overriddenAreas.length > 0)
" "
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
> >
<CardHeader> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<CardTitle> <div class="flex items-center gap-2">
Template inheritance: {{ templateState.templateName }} <Layers3
</CardTitle> class="text-primary size-5 shrink-0"
<CardDescription> aria-hidden="true"
Every setting area inherits independently. Local changes stay />
unchanged when the template is updated until that area is reset. <h2 class="text-title">{{ t('inheritance.title') }}</h2>
</CardDescription> </div>
<p class="text-muted-foreground break-words">
{{
t('inheritance.description', {
template: templateState.templateName,
})
}}
</p>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap gap-2"> <p class="text-caption text-muted-foreground">
<Badge {{ t('inheritance.help') }}
v-for="area in inheritedAreas" </p>
:key="`inherited-${area.value}`" <ul
variant="outline" class="grid min-w-0 divide-y"
> :aria-label="t('inheritance.areas')"
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
> >
<div class="flex flex-wrap items-center justify-between gap-3"> <li
<Badge>{{ area.label }} · template overridden</Badge> v-for="area in inheritedAreas"
<Button :key="area.value"
type="button" class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
size="sm"
variant="outline"
:disabled="form.processing || !canReset(area)"
@click="resetArea(area)"
>
{{
activeArea === area.value && form.processing
? 'Resetting…'
: 'Reset to template'
}}
</Button>
</div>
<div
v-if="area.value === 'ticket_statuses' && statusRequirement"
class="space-y-3"
> >
<p class="text-muted-foreground text-sm"> <span class="min-w-0 break-words font-medium">{{
Choose where tickets in local statuses should move areaLabel(area)
before those statuses are removed. }}</span>
</p> <Badge
<div variant="outline"
v-for="status in statusRequirement.removedStatuses" class="border-primary/25 text-primary"
:key="status.id" >{{ t('projectSettings.inherited') }}</Badge
class="grid gap-2 sm:grid-cols-2 sm:items-center"
> >
<Label :for="`replacement-${status.id}`"> </li>
{{ status.name }} ({{ status.ticketCount }} tickets) <li
</Label> v-for="area in overriddenAreas"
<div class="grid gap-1"> :key="area.value"
<select class="gap-stack grid min-w-0 py-3"
:id="`replacement-${status.id}`" >
v-model=" <div
form.status_replacements[String(status.id)] class="flex min-w-0 flex-wrap items-center justify-between gap-3"
" >
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]" <span class="min-w-0 break-words font-medium">{{
> areaLabel(area)
<option :value="undefined" disabled> }}</span>
Select replacement status <Badge variant="secondary">{{
</option> t('projectSettings.override')
<option }}</Badge>
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</div> </div>
</div> <div
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
<InputError :message="form.errors.areas" /> >
</div> <p class="text-caption text-muted-foreground">
{{ t('inheritance.overrideHelp') }}
</p>
<Button
type="button"
size="sm"
variant="outline"
class="shrink-0"
:disabled="form.processing"
:aria-label="
t('inheritance.resetArea', {
area: areaLabel(area),
})
"
@click="openReset(area)"
>
<RotateCcw aria-hidden="true" />{{
t('inheritance.reset')
}}
</Button>
</div>
</li>
</ul>
</CardContent> </CardContent>
<Dialog :open="pendingArea !== null" @update:open="closeReset">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{
t('inheritance.confirmTitle')
}}</DialogTitle>
<DialogDescription>{{
t('inheritance.confirmDescription', {
area: pendingArea ? areaLabel(pendingArea) : '',
template: templateState.templateName,
})
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="resetArea"
>
<FormFeedback
:state="protection.state.value"
:title="
form.processing
? t('inheritance.resetting')
: undefined
"
@retry="resetArea"
/>
<InputError
:message="form.errors.areas ?? form.errors['areas.0']"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
>
<legend class="sr-only">
{{ t('inheritance.confirmTitle') }}
</legend>
<div
v-if="statusRequirement"
class="gap-column grid min-w-0"
>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }}
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2"
>
<Label :for="'replacement-' + status.id">{{
t('inheritance.statusLabel', {
status: status.name,
count: status.ticketCount,
})
}}</Label>
<select
:id="'replacement-' + status.id"
v-model="
form.status_replacements[
String(status.id)
]
"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
:aria-invalid="
!!form.errors[
`status_replacements.${status.id}`
]
"
:aria-describedby="
'replacement-error-' + status.id
"
>
<option :value="undefined" disabled>
{{ t('inheritance.selectStatus') }}
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:id="'replacement-error-' + status.id"
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</div>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
>
{{ t('inheritance.resetWarning') }}
</p>
<DialogFooter>
<Button
type="button"
variant="secondary"
@click="closeReset(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit" :disabled="!canReset">{{
t(
form.processing
? 'inheritance.resetting'
: 'inheritance.reset',
)
}}</Button>
</DialogFooter>
</fieldset>
</form>
</DialogContent>
</Dialog>
</Card> </Card>
</template> </template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectModel } from '@/types/models/project-model';
const props = defineProps<{ project: ProjectModel }>();
const { t } = useLocalization();
const form = useForm({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
const protection = useFormProtection(form);
watch(
() => [props.project.show_author, props.project.show_story_points],
() => {
form.defaults({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
form.resetAndClearErrors();
},
);
function submit(): void {
protection.submit(update(props.project.id), {
preserveScroll: true,
onSuccess: () => form.defaults(),
});
}
</script>
<template>
<form
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
<p class="text-muted-foreground text-sm">
{{ t('metadata.displayDescription') }}
</p>
<FormFeedback :state="protection.state.value" @retry="submit" />
<fieldset :disabled="form.processing" class="grid gap-4">
<div
v-for="option in ['show_author', 'show_story_points'] as const"
:key="option"
class="grid gap-2"
>
<Label
:for="option"
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
:id="option"
v-model="form[option]"
:name="option"
:invalid="!!form.errors[option]"
:aria-describedby="`${option}-help ${option}-error`"
/>
<span class="grid gap-1">
<span>{{
t(
option === 'show_author'
? 'metadata.author'
: 'metadata.storyPoints',
)
}}</span>
<span
:id="`${option}-help`"
class="text-muted-foreground text-sm font-normal"
>{{
t(
option === 'show_author'
? 'metadata.authorHelp'
: 'metadata.storyPointsHelp',
)
}}</span
>
</span>
</Label>
<InputError
:id="`${option}-error`"
:message="form.errors[option]"
/>
</div>
</fieldset>
<Button
type="submit"
class="w-full sm:w-fit"
:loading="form.processing"
>{{ t('common.save') }}</Button
>
</form>
</template>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
destroy,
destroyStatus,
store,
storeStatus,
update,
updateStatus,
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
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 type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<{
projectId: number;
columnCount: number;
target: WorkflowEditorTarget | null;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({ name: '', position: 1 });
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.kind === 'column') {
return t(
props.target.action === 'create'
? 'workflow.addColumn'
: props.target.action === 'edit'
? 'workflow.editColumn'
: 'workflow.deleteColumn',
);
}
return t(
props.target?.action === 'create'
? 'workflow.addStatus'
: props.target?.action === 'edit'
? 'workflow.editStatus'
: 'workflow.deleteStatus',
);
});
const description = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.kind === 'column') {
return t(
target.action === 'delete'
? 'workflow.deleteColumnDescription'
: target.action === 'create'
? 'workflow.addColumnDescription'
: 'workflow.editColumnDescription',
{ name: target.column?.name ?? '' },
);
}
return t(
target.action === 'delete'
? 'workflow.deleteStatusDescription'
: 'workflow.statusContext',
{
name: target.status?.name ?? '',
context: target.column?.name ?? t('workflow.backlog'),
replacement: target.replacement?.name ?? '',
},
);
});
const otherErrors = computed(() =>
Object.entries(form.errors)
.filter(([key]) => !['name', 'position'].includes(key))
.map(([, message]) => message),
);
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name:
target.kind === 'column'
? (target.column?.name ?? '')
: (target.status?.name ?? ''),
position: target.column?.position ?? 1,
});
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(target.kind === 'status' &&
target.action === 'delete' &&
!target.replacement)
) {
return;
}
const route =
target.kind === 'column'
? target.action === 'create'
? store(props.projectId)
: target.action === 'edit'
? update([props.projectId, target.column.id])
: destroy([props.projectId, target.column.id])
: target.action === 'create'
? storeStatus(props.projectId)
: target.action === 'edit'
? updateStatus([props.projectId, target.status.id])
: destroyStatus([props.projectId, target.status.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.kind === 'column' && target.action === 'edit'
? data
: target.kind === 'status' && target.action === 'create'
? {
name: data.name,
board_column_id: target.column?.id ?? null,
}
: { name: data.name },
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="break-words">{{
description
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<InputError
v-for="message in otherErrors"
:key="message"
:message="message"
/>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="workflow-name">{{
t(
target?.kind === 'column'
? 'workflow.columnName'
: 'workflow.statusName',
)
}}</Label>
<Input
id="workflow-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
:aria-describedby="
form.errors.name
? 'workflow-name-error'
: undefined
"
/>
<InputError
id="workflow-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="
target?.kind === 'column' &&
target.action === 'edit'
"
class="grid gap-2"
>
<Label for="workflow-position">{{
t('workflow.position')
}}</Label>
<Input
id="workflow-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="columnCount"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="workflow-position-help workflow-position-error"
/>
<p
id="workflow-position-help"
class="text-caption text-muted-foreground"
>
{{
t('workflow.positionHelp', {
count: columnCount,
})
}}
</p>
<InputError
id="workflow-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
defineProps<{
column: BoardColumnModel | null;
statuses: TicketStatusModel[];
}>();
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<div class="grid min-w-0 gap-3">
<ul
class="divide-y"
:aria-label="
t('workflow.statusesIn', {
context: column?.name ?? t('workflow.backlog'),
})
"
>
<li
v-for="status in statuses"
:key="status.id"
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
>
<span
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
>{{ status.name }}</span
>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('workflow.renameStatus', { name: status.name })
"
@click="
$emit('edit', {
kind: 'status',
action: 'edit',
column,
status,
})
"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="statuses.length <= 1"
:aria-label="
t('workflow.removeStatus', { name: status.name })
"
:aria-describedby="
statuses.length <= 1
? `status-protection-${column?.id ?? 'backlog'}`
: undefined
"
@click="
$emit('edit', {
kind: 'status',
action: 'delete',
column,
status,
replacement: statuses.find(
(item) => item.id !== status.id,
),
})
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ul>
<p
v-if="statuses.length <= 1"
:id="`status-protection-${column?.id ?? 'backlog'}`"
class="text-caption text-muted-foreground"
>
{{ t('workflow.lastStatus') }}
</p>
<Button
type="button"
variant="outline"
class="w-full sm:w-fit"
:aria-label="
t('workflow.addStatusTo', {
context: column?.name ?? t('workflow.backlog'),
})
"
@click="$emit('edit', { kind: 'status', action: 'create', column })"
><Plus class="size-4" aria-hidden="true" />{{
t('workflow.addStatus')
}}</Button
>
</div>
</template>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRightLeft } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<{
projectId: number;
statuses: TicketStatusModel[];
}>();
const { t } = useLocalization();
const sourceId = ref(props.statuses[0]?.id ?? null);
const source = computed(() =>
props.statuses.find((status) => status.id === sourceId.value),
);
const targets = computed(() =>
props.statuses.filter((status) => status.id !== sourceId.value),
);
const savedTargets = computed(
() =>
source.value?.allowed_target_statuses
?.map((status) => status.id)
.sort((a, b) => a - b) ?? [],
);
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
const protection = useFormProtection(form);
const saved = ref(false);
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
function reset(): void {
saved.value = false;
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
form.resetAndClearErrors();
protection.clearFeedback();
}
watch(
() => props.statuses,
() => {
if (!source.value) {
sourceId.value = props.statuses[0]?.id ?? null;
reset();
} else if (!form.isDirty && !form.processing) {
reset();
}
},
);
function selectSource(event: Event): void {
const select = event.target as HTMLSelectElement;
if (form.processing || !protection.confirmLeave()) {
select.value = String(sourceId.value);
return;
}
sourceId.value = Number(select.value);
reset();
}
function toggleTarget(id: number, event: Event): void {
saved.value = false;
const checked = (event.target as HTMLInputElement).checked;
form.allowed_ticket_status_ids = checked
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
: form.allowed_ticket_status_ids.filter((target) => target !== id);
form.clearErrors();
protection.clearFeedback();
}
function submit(): void {
if (!source.value || !form.isDirty || form.processing) {
return;
}
protection.submit(
updateStatusTransitions([props.projectId, source.value.id]),
{
preserveScroll: true,
onSuccess: () => {
saved.value = true;
form.defaults({
allowed_ticket_status_ids: [...savedTargets.value],
});
form.resetAndClearErrors();
},
},
);
}
</script>
<template>
<section
class="bg-card min-w-0 overflow-hidden rounded-xl border"
aria-labelledby="workflow-transitions"
>
<header class="grid gap-3 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<ArrowRightLeft
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="workflow-transitions" class="font-semibold">
{{ t('workflow.transitions') }}
</h3>
</div>
<p class="text-muted-foreground text-sm">
{{ t('workflow.transitionsDescription') }}
</p>
</header>
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
<p v-if="!source" class="text-muted-foreground text-sm">
{{ t('workflow.noStatuses') }}
</p>
<template v-else>
<div class="grid min-w-0 gap-2">
<Label for="workflow-source">{{
t('workflow.sourceStatus')
}}</Label>
<select
id="workflow-source"
:value="sourceId"
:disabled="form.processing"
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
@change="selectSource"
>
<option
v-for="status in statuses"
:key="status.id"
:value="status.id"
>
{{ ticketStatusLabel(status, '') }}
</option>
</select>
<p class="text-muted-foreground text-sm">
{{ t('workflow.directionHelp') }}
</p>
</div>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-3"
aria-describedby="workflow-target-help workflow-transition-errors"
:aria-invalid="hasErrors"
>
<legend class="mb-3 break-words font-medium">
{{
t('workflow.targetsFrom', { name: source.name })
}}
</legend>
<p
id="workflow-target-help"
class="text-muted-foreground text-sm"
>
{{ t('workflow.targetHelp') }}
</p>
<p
v-if="targets.length === 0"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noTargets') }}
</p>
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<label
v-for="target in targets"
:key="target.id"
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
:class="
form.allowed_ticket_status_ids.includes(
target.id,
)
? 'border-primary/40 bg-accent/40'
: 'border-input'
"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="target.id"
:checked="
form.allowed_ticket_status_ids.includes(
target.id,
)
"
class="accent-primary size-4 shrink-0"
@change="toggleTarget(target.id, $event)"
/>
<span class="grid min-w-0 gap-1">
<span
class="break-words text-sm font-medium"
>{{
ticketStatusLabel(target, '')
}}</span
>
<span
class="text-muted-foreground text-xs"
>{{
t(
form.allowed_ticket_status_ids.includes(
target.id,
)
? 'workflow.allowed'
: 'workflow.notAllowed',
)
}}</span
>
</span>
</label>
</div>
</fieldset>
<p
v-if="
targets.length > 0 &&
form.allowed_ticket_status_ids.length === 0
"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noMovesHelp') }}
</p>
<div id="workflow-transition-errors" role="alert">
<InputError
v-for="(message, key) in form.errors"
:key="key"
:message="message"
/>
</div>
<FormFeedback
:state="
!saved && protection.state.value === 'success'
? 'idle'
: protection.state.value
"
@retry="submit"
/>
<div
class="flex flex-wrap items-center gap-3 border-t pt-4"
>
<Badge
:variant="form.isDirty ? 'warning' : 'secondary'"
role="status"
>{{
t(
form.isDirty
? 'workflow.unsavedMoves'
: 'workflow.savedMoves',
{ count: savedTargets.length },
)
}}</Badge
>
<div
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
>
<Button
type="button"
variant="outline"
:disabled="!form.isDirty || form.processing"
@click="reset"
>{{ t('workflow.resetMoves') }}</Button
>
<Button
type="submit"
:disabled="!form.isDirty"
:loading="form.processing"
>{{ t('workflow.saveMoves') }}</Button
>
</div>
</div>
</form>
</template>
</div>
</section>
</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 { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3'; 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 ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.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 { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue'; import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue'; import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { import {
normalizeAcceptanceCriteria, normalizeAcceptanceCriteria,
@@ -81,6 +88,16 @@ const savedFormData = () => ({
const form = useForm(draftKey, 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( const hasUnsavedAcceptanceCriteriaDraft = computed(
() => () =>
JSON.stringify(acceptanceCriteriaItems.value) !== JSON.stringify(acceptanceCriteriaItems.value) !==
@@ -364,44 +381,6 @@ const submit = (): void => {
/> />
</template> </template>
</TicketFormFields> </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 <fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5" class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled=" :disabled="
@@ -1,273 +1,12 @@
<script setup lang="ts"> <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 { useLocalization } from '@/composables/useLocalization';
import { import type { TicketDetailGitProps } from '@/types/ticket-detail';
Card,
CardContent, defineProps<TicketDetailGitProps>();
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
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 { 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> </script>
<template> <template>
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
<h2 id="ticket-git-title" class="text-lg font-semibold"> <h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }} {{ t('ticketDetail.git') }}
</h2> </h2>
<div class="grid min-w-0 gap-6"> <div class="grid min-w-0 gap-6">
<Card class="h-fit"> <TicketGitBranches
<CardHeader :ticket="ticket"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" :project="project"
> :can-edit-ticket="canEditTicket"
<div class="space-y-1.5"> :repository-branches="repositoryBranches"
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle> :suggested-branch-name="suggestedBranchName"
<CardDescription>{{ />
t('ticketDetail.branchesDescription') <TicketGitPullRequests
}}</CardDescription> :repository-pull-requests="repositoryPullRequests"
</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>
</div> </div>
</section> </section>
</template> </template>
@@ -15,6 +15,7 @@ const sections = [
'description', 'description',
'checklist', 'checklist',
'metadata', 'metadata',
'blocker',
'relationships', 'relationships',
'history', 'history',
'git', 'git',
@@ -8,11 +8,21 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail'; 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>(); defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization(); const { t, date } = useLocalization();
const formatDateTime = (value: string): string => const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' }); 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> </script>
<template> <template>
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
{{ t('ticketDetail.noHistory') }} {{ t('ticketDetail.noHistory') }}
</div> </div>
<div v-else class="flex flex-col gap-4"> <ol v-else class="relative grid gap-0" role="list">
<div <li
v-for="worklogEntry in ticket.worklog_entries" v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id" :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"> <div
<Badge variant="outline"> class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
{{ worklogEntry.entry_type }} >
</Badge> <UserAvatar
<span class="text-foreground text-sm font-medium"> v-if="worklogEntry.actor"
{{ :user="worklogEntry.actor"
worklogEntry.actor?.name ?? size="lg"
t('ticketDetail.system') />
}} <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> </span>
</div> </div>
<p class="text-foreground mt-3 text-sm"> <article
{{ worklogEntry.summary }} class="bg-muted/20 min-w-0 rounded-xl border p-4"
</p> >
<p class="text-muted-foreground mt-2 text-xs"> <div
{{ formatDateTime(worklogEntry.created_at) }} class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
</p> >
</div> <div
</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> </CardContent>
</Card> </Card>
</section> </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>
+87 -100
View File
@@ -1,120 +1,107 @@
import type { ComputedRef, Ref } from 'vue'; import { computed, onScopeDispose, ref } from 'vue';
import { computed, ref } from 'vue'; import { useLocalization } from '@/composables/useLocalization';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = { /** Keep secrets within the consuming component and discard late responses after hiding them. */
qrCodeSvg: Ref<string | null>; export function useTwoFactorAuth() {
manualSetupKey: Ref<string | null>; const { t } = useLocalization();
recoveryCodesList: Ref<string[]>; const errors = ref<string[]>([]);
errors: Ref<string[]>; const manualSetupKey = ref<string | null>(null);
hasSetupData: ComputedRef<boolean>; const qrCodeSvg = ref<string | null>(null);
clearSetupData: () => void; const recoveryCodesList = ref<string[]>([]);
clearErrors: () => void; const loading = ref(false);
clearTwoFactorAuthData: () => void; let pending: AbortController | null = null;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
const fetchJson = async <T>(url: string): Promise<T> => { async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); credentials: 'same-origin',
signal,
if (!response.ok) { });
throw new Error(`Failed to fetch: ${response.status}`); 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[]>([]); function clearTwoFactorAuthData(): void {
const manualSetupKey = ref<string | null>(null); pending?.abort();
const qrCodeSvg = ref<string | null>(null); pending = null;
const recoveryCodesList = ref<string[]>([]); loading.value = false;
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 => {
manualSetupKey.value = null; manualSetupKey.value = null;
qrCodeSvg.value = null; qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = []; 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 { try {
clearErrors(); if (kind === 'setup') {
recoveryCodesList.value = await fetchJson<string[]>( const [qr, key] = await Promise.all([
recoveryCodes.url(), fetchJson<{ svg: string }>(qrCode.url(), request.signal),
); fetchJson<{ secretKey: string }>(
} catch { secretKey.url(),
errors.value.push('Failed to fetch recovery codes'); request.signal,
recoveryCodesList.value = []; ),
]);
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 { return {
qrCodeSvg, qrCodeSvg,
manualSetupKey, manualSetupKey,
recoveryCodesList, recoveryCodesList,
errors, errors,
hasSetupData, loading,
clearSetupData, hasSetupData: computed(() =>
clearErrors, Boolean(qrCodeSvg.value && manualSetupKey.value),
),
clearSetupData: clearTwoFactorAuthData,
clearRecoveryCodes: clearTwoFactorAuthData,
clearTwoFactorAuthData, clearTwoFactorAuthData,
fetchQrCode, clearErrors,
fetchSetupKey, fetchSetupData: () => load('setup'),
fetchSetupData, fetchRecoveryCodes: () => load('recovery'),
fetchRecoveryCodes,
}; };
}; }
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Settings2 } from 'lucide-vue-next';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
defineProps<{ projectName: string; projectKey: string }>();
const { projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
</script>
<template>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl 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 break-all uppercase">
{{ projectKey }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.projectSettings') }}
</h1>
<p class="text-muted-foreground break-words">
{{ t('projectSettings.description', { project: projectName }) }}
</p>
</header>
<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 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.projectSettings')"
>
<Link
v-for="item in projectSettingsItems"
:key="toUrl(item.href)"
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="[
'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',
isCurrentUrl(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',
]"
>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
<slot />
</section>
</div>
</div>
</div>
</template>
+55 -32
View File
@@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
import { computed } from 'vue'; 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 { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: t('navigation.profile'), title: t('navigation.profile'),
href: editProfile(), href: editProfile(),
icon: UserRound,
}, },
{ {
title: t('navigation.security'), title: t('navigation.security'),
href: editSecurity(), href: editSecurity(),
icon: ShieldCheck,
}, },
{ {
title: t('navigation.gitConnections'), title: t('navigation.gitConnections'),
href: editGitConnections(), href: editGitConnections(),
icon: GitBranch,
}, },
{ {
title: t('navigation.applications'), title: t('navigation.applications'),
href: editApplications(), href: editApplications(),
icon: KeyRound,
}, },
{ {
title: t('navigation.appearance'), title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
icon: Palette,
}, },
]); ]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<div class="px-4 py-6"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
:title="t('navigation.settings')" >
:description="t('navigation.settingsDescription')" <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"> <div
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0"> 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 <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')" :aria-label="t('navigation.settings')"
> >
<Button <Link
v-for="item in sidebarNavItems" v-for="item in sidebarNavItems"
:key="toUrl(item.href)" :key="toUrl(item.href)"
variant="ghost" :href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href) ? 'page' : undefined
"
:class="[ :class="[
'shrink-0 justify-start lg:w-full', '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',
{ 'bg-muted': isCurrentOrParentUrl(item.href) }, 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 <component
:href="item.href" :is="item.icon"
:aria-current=" class="size-4 shrink-0"
isCurrentOrParentUrl(item.href) aria-hidden="true"
? 'page' />
: undefined <span class="whitespace-nowrap">{{ item.title }}</span>
" </Link>
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
</nav> </nav>
</aside> </aside>
<Separator class="lg:hidden" /> <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">
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<slot /> <slot />
</section> </section>
</div> </div>
+402
View File
@@ -1,4 +1,159 @@
export const en = { export const en = {
'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow',
'workflow.description':
'Organize your board into columns and give each stage the statuses your team needs.',
'workflow.updated': 'Workflow updated.',
'workflow.backlog': 'Backlog',
'workflow.offBoard': 'Off the board',
'workflow.backlogDescription':
'Your intake queue. Tickets with these statuses stay outside the board columns.',
'workflow.columns': 'Board columns',
'workflow.columnsDescription':
'Columns appear from left to right in this order. Edit a column to change its name or position.',
'workflow.emptyColumns': 'No board columns yet',
'workflow.emptyColumnsDescription':
'Add your first column to start organizing tickets on the board.',
'workflow.addColumn': 'Add column',
'workflow.editColumn': 'Edit column',
'workflow.deleteColumn': 'Delete column?',
'workflow.addColumnDescription':
'The column is added at the end of the board with a first status of the same name.',
'workflow.editColumnDescription':
'Rename this column or move it to a different position. Its statuses keep their names.',
'workflow.deleteColumnDescription':
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
'workflow.addStatus': 'Add status',
'workflow.editStatus': 'Rename status',
'workflow.deleteStatus': 'Delete status?',
'workflow.statusContext': 'This status belongs to {context}.',
'workflow.deleteStatusDescription':
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
'workflow.columnName': 'Column name',
'workflow.statusName': 'Status name',
'workflow.position': 'Position',
'workflow.positionValue': 'Position {position}',
'workflow.positionHelp':
'Choose a position from 1 to {count}. Other columns shift automatically.',
'workflow.statusesIn': 'Statuses in {context}',
'workflow.renameStatus': 'Rename status {name}',
'workflow.removeStatus': 'Delete status {name}',
'workflow.changeColumn': 'Edit column {name}',
'workflow.removeColumn': 'Delete column {name}',
'workflow.addStatusTo': 'Add status to {context}',
'workflow.lastStatus':
'Keep at least one status in this group. The last status cannot be deleted.',
'workflow.transitions': 'Status moves',
'workflow.transitionsDescription':
'Choose exactly where each status is allowed to move next.',
'workflow.sourceStatus': 'From status',
'workflow.directionHelp':
'Rules apply in one direction. Select another source to configure the return move.',
'workflow.targetsFrom': 'Allowed destinations from {name}',
'workflow.targetHelp':
'Checked destinations are allowed. Unchecked destinations are forbidden. Save to apply these rules to ticket moves and board drop targets.',
'workflow.allowed': 'Allowed',
'workflow.notAllowed': 'Not allowed',
'workflow.noMovesHelp':
'With no destinations selected, tickets cannot leave this status after you save.',
'workflow.noStatuses':
'Add a status to configure its allowed destinations.',
'workflow.unsavedMoves': 'Unsaved changes',
'workflow.savedMoves': 'Saved destinations: {count}',
'workflow.resetMoves': 'Reset changes',
'workflow.saveMoves': 'Save moves',
'workflow.noTargets': 'No other statuses exist yet.',
'workflow.confirmDelete': 'Delete',
'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.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete', 'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.', 'criteria.empty': 'No acceptance criteria yet.',
@@ -359,6 +514,7 @@ export const en = {
'navigation.appearance': 'Appearance', 'navigation.appearance': 'Appearance',
'navigation.settingsDescription': 'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.', 'Manage your profile, security, and Git integration settings.',
'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket', 'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project', 'navigation.createProject': 'New project',
'navigation.open': 'Open navigation', 'navigation.open': 'Open navigation',
@@ -373,8 +529,58 @@ export const en = {
'navigation.breadcrumbs': 'Breadcrumbs', 'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme', 'appearance.label': 'Color theme',
'appearance.light': 'Light', 'appearance.light': 'Light',
'appearance.lightDescription':
'Use a bright workspace with dark text and clear borders.',
'appearance.dark': 'Dark', 'appearance.dark': 'Dark',
'appearance.darkDescription':
'Reduce glare with a darker workspace and balanced contrast.',
'appearance.system': 'System', '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.cancel': 'Cancel',
'common.close': 'Close', 'common.close': 'Close',
'common.retry': 'Try again', 'common.retry': 'Try again',
@@ -456,9 +662,24 @@ export const en = {
'ticketDetail.checklistPlaceholder': 'ticketDetail.checklistPlaceholder':
'[ ] User can sign in\n [x] Validation is shown for invalid credentials', '[ ] User can sign in\n [x] Validation is shown for invalid credentials',
'ticketDetail.blocked': 'Mark ticket as blocked', '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.blockerComment': 'Blocker comment',
'ticketDetail.blockerCommentHelp':
'Explain the concrete dependency or decision preventing progress.',
'ticketDetail.blockerPlaceholder': 'ticketDetail.blockerPlaceholder':
'Describe what currently blocks progress on this ticket.', '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.customFields': 'Custom fields',
'ticketDetail.customFieldsDescription': 'ticketDetail.customFieldsDescription':
'Project-configured fields can be tracked directly on the ticket.', 'Project-configured fields can be tracked directly on the ticket.',
@@ -476,8 +697,20 @@ export const en = {
'ticketDetail.branchName': 'Branch name', 'ticketDetail.branchName': 'Branch name',
'ticketDetail.branchAccess': 'ticketDetail.branchAccess':
'You can only create branches in repositories that match one of your personal Git connections.', '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.openGitConnections': 'Open Git connections',
'ticketDetail.createNow': 'Create now', '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': 'ticketDetail.inspectBranches':
'Connect a matching Git account in your user settings to inspect branches for this repository.', 'Connect a matching Git account in your user settings to inspect branches for this repository.',
'ticketDetail.pullRequests': 'Pull requests', 'ticketDetail.pullRequests': 'Pull requests',
@@ -533,6 +766,11 @@ export const en = {
'Every tracked ticket change is recorded with actor and timestamp.', 'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.', 'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System', '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}', 'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket', 'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket', 'ticketForm.pageTitle': 'Create {project} ticket',
@@ -558,4 +796,168 @@ export const en = {
'ticketForm.saved': 'All changes saved', 'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.', 'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker', 'ticketForm.blocker': 'Blocker',
'projectSettings.pageTitle': '{project} general settings',
'projectSettings.description':
'Manage settings and template inheritance for {project}.',
'projectSettings.generalDescription':
'Review project identity, setting sources, and administrative actions.',
'projectSettings.identity': 'Project identity',
'projectSettings.identityDescription':
'The name and key belong to this project and are never inherited from a template.',
'projectSettings.name': 'Project name',
'projectSettings.key': 'Project key',
'projectSettings.local': 'Local',
'projectSettings.inherited': 'Inherited',
'projectSettings.override': 'Local override',
'projectSettings.display': 'Ticket display',
'projectSettings.showAuthor': 'Show author',
'projectSettings.showStoryPoints': 'Show story points',
'projectSettings.enabled': 'Enabled',
'projectSettings.disabled': 'Disabled',
'projectSettings.displayDescription':
'Manage these display options in Ticket settings.',
'projectSettings.template': 'Template baseline',
'projectSettings.templateDescription':
'Manage the linked template or reset individual areas using the inheritance panel.',
'projectSettings.currentTemplate': 'Current linked template',
'projectSettings.noTemplate': 'No linked template',
'projectSettings.linked': 'Template inheritance active',
'projectSettings.standalone': 'Standalone',
'projectSettings.standaloneDescription':
'This project manages its settings locally. Published template updates are not applied.',
'projectSettings.noAlternatives': 'No other published templates',
'projectSettings.noAlternativesDescription':
'A different published template is required to switch this project.',
'projectSettings.switch': 'Switch template',
'projectSettings.switching': 'Switching template…',
'projectSettings.switchWarning':
'Switching templates can replace or remove project configuration in copied areas, including workflows, ticket metadata, repositories, and notifications.',
'projectSettings.publishedTemplate': 'Published template',
'projectSettings.selectTemplate': 'Select a template',
'projectSettings.confirmation': 'Confirmation key',
'projectSettings.confirmPlaceholder': 'Type {key} exactly',
'projectSettings.confirmHelp':
'Enter the exact project key {key} to confirm this action. The key is case sensitive.',
'projectSettings.danger': 'Danger zone',
'projectSettings.deleteDescription':
'Permanently delete this project and all of its tickets, board columns, ticket metadata, repository connections, and project permissions.',
'projectSettings.irreversible': 'This action cannot be undone.',
'projectSettings.delete': 'Delete project',
'projectSettings.deleting': 'Deleting project…',
'projectSettings.updated': 'Project settings updated.',
'inheritance.title': 'Template inheritance',
'inheritance.description': 'Settings linked to {template}.',
'inheritance.help':
'Each area inherits independently. Settings outside the template are managed locally.',
'inheritance.areas': 'Setting sources',
'inheritance.overrideHelp':
'Local changes stay in place when the template is updated.',
'inheritance.reset': 'Reset to template',
'inheritance.resetArea': 'Reset {area} to template',
'inheritance.resetting': 'Resetting…',
'inheritance.confirmTitle': 'Reset this setting area?',
'inheritance.confirmDescription':
'Replace local changes in {area} with the current baseline from {template}. Other setting areas keep their current values.',
'inheritance.resetWarning':
'Local configuration in this area may be replaced or removed.',
'inheritance.statusHelp':
'Choose where tickets in local statuses should move before those statuses are removed.',
'inheritance.statusLabel': '{status} ({count} tickets)',
'inheritance.selectStatus': 'Select replacement status',
'inheritance.area.projectSettings': 'Project settings',
'inheritance.area.boardColumns': 'Board columns',
'inheritance.area.ticketStatuses': 'Ticket statuses',
'inheritance.area.statusTransitions': 'Status transitions',
'inheritance.area.ticketTypes': 'Ticket types',
'inheritance.area.ticketPriorities': 'Ticket priorities',
'inheritance.area.ticketFields': 'Custom ticket fields',
'inheritance.area.ticketLinkTypes': 'Ticket link types',
'inheritance.area.repositoryConnections': 'Repository connections',
'inheritance.area.notificationActions': 'Notification actions',
'gitConnections.description':
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
'gitConnections.privacy':
'Connections belong to your account. Saved tokens stay hidden. Without a matching connection, only repository names are available.',
'gitConnections.add': 'Add connection',
'gitConnections.editAction': 'Edit',
'gitConnections.edit': 'Edit connection',
'gitConnections.remove': 'Remove',
'gitConnections.removeTitle': 'Remove connection',
'gitConnections.saved': 'Saved connections',
'gitConnections.empty': 'No Git connections yet',
'gitConnections.emptyDescription':
'Add a Gitea connection to access your repositories and their Git activity.',
'gitConnections.unnamedAccount': 'Unnamed remote account',
'gitConnections.remoteId': 'Remote user ID: {id}',
'gitConnections.editAccount': 'Edit {account} at {server}',
'gitConnections.removeAccount': 'Remove {account} at {server}',
'gitConnections.addDescription':
'Connect a Gitea account using its server URL and a personal application token.',
'gitConnections.editDescription':
'Update the server URL and enter a fresh token to verify this connection.',
'gitConnections.removeDescription':
'Remove this personal connection? Repository links remain, but Git activity will require another matching connection.',
'gitConnections.gitea': 'Gitea',
'gitConnections.baseUrl': 'Gitea base URL',
'gitConnections.token': 'Application token',
'gitConnections.newToken': 'New application token',
'gitConnections.tokenPlaceholder': 'Paste your Gitea application token',
'gitConnections.tokenHelp':
'Your token is verified with Gitea before saving. It is never shown in the saved connection.',
'gitConnections.verifying': 'Verifying connection…',
'gitConnections.save': 'Save changes',
'gitConnections.removing': 'Removing connection…',
'gitConnections.forbidden': 'Connection access denied',
'gitConnections.forbiddenDescription':
'You can only manage connections belonging to your account. Close this dialog and refresh the page.',
'gitConnections.added': 'Git connection added.',
'gitConnections.updated': 'Git connection updated.',
'gitConnections.removed': 'Git connection removed.',
'metadata.title': 'Ticket settings',
'metadata.pageTitle': '{project} ticket settings',
'metadata.description':
'Manage ticket types, priorities, and the information shown on your tickets.',
'metadata.updated': 'Ticket settings updated.',
'metadata.types': 'Ticket types',
'metadata.priorities': 'Priorities',
'metadata.addType': 'Add type',
'metadata.addPriority': 'Add priority',
'metadata.typesDescription':
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
'metadata.prioritiesDescription':
'Define and order project-specific priorities. The last priority must be kept.',
'metadata.parentType': 'Parent type',
'metadata.standardType': 'Standard type',
'metadata.assignedTickets': 'Assigned tickets: {count}',
'metadata.editEntry': 'Edit {name}',
'metadata.deleteEntry': 'Delete {name}',
'metadata.lastType':
'The last ticket type cannot be deleted. Add another type first.',
'metadata.lastPriority':
'The last priority cannot be deleted. Add another priority first.',
'metadata.display': 'Display options',
'metadata.displayDescription':
'Choose which optional information is available on this project’s tickets.',
'metadata.author': 'Show ticket author',
'metadata.authorHelp': 'Display the author on ticket details.',
'metadata.storyPoints': 'Enable story points',
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
'metadata.name': 'Name',
'metadata.positionHelp':
'Choose a position from 1 to {count}. Other entries shift automatically.',
'metadata.allowChildren': 'Allow child tickets',
'metadata.parentFixed':
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
'metadata.editHelp':
'Choose a project-specific name. Changes apply to tickets using this entry.',
'metadata.deleteTypeHelp':
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
'metadata.deletePriorityHelp':
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
'metadata.replacementHelp':
'Move {count} assigned tickets to another type before deleting this one.',
'metadata.replacement': 'Replacement ticket type',
'metadata.chooseType': 'Choose a type',
'metadata.parentReplacement':
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
} as const; } as const;
+8 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed, useTemplateRef } from 'vue'; import { computed, useTemplateRef } from 'vue';
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue'; import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue'; import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue'; import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
@@ -64,7 +65,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
/> />
<aside <aside
:aria-label="t('ticketDetail.metadata')" :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 <TicketDetailMetadata
:ticket="ticket" :ticket="ticket"
@@ -73,6 +74,12 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:ticket-field-definitions="ticketFieldDefinitions" :ticket-field-definitions="ticketFieldDefinitions"
/> />
</aside> </aside>
<TicketDetailBlocker
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
class="xl:col-start-1"
/>
<TicketDetailRelationships <TicketDetailRelationships
:ticket="ticket" :ticket="ticket"
:project="project" :project="project"
+175 -640
View File
@@ -1,674 +1,209 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController'; import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue'; import FeedbackState from '@/components/FeedbackState.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardHeader } from '@/components/ui/card';
Card, import { useLocalization } from '@/composables/useLocalization';
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index'; import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { import { index, show } from '@/routes/projects';
board as boardSettings, import { board } from '@/routes/projects/settings';
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<ProjectBoardSettingsPageProps>(); const props = defineProps<ProjectBoardSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const target = ref<WorkflowEditorTarget | null>(null);
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'Projects', { title: t('navigation.projects'), href: index() },
href: index(), { title: props.project.name, href: show(props.project.id) },
}, { title: t('workflow.title'), href: board(props.project.id) },
{ ]);
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board settings',
href: boardSettings(props.project.id),
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const transitionTargetOptions = (
sourceStatus: TicketStatusModel,
): TicketStatusModel[] => {
return props.ticketStatuses.filter(
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
);
};
const statusAllowsTarget = (
sourceStatus: TicketStatusModel,
targetStatus: TicketStatusModel,
): boolean => {
return (
sourceStatus.allowed_target_statuses?.some(
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
) ?? false
);
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board settings`" /> <Head :title="t('workflow.pageTitle', { project: project.name })" />
<SettingsLayout :project-name="project.name" :project-key="project.key">
<div class="flex flex-1 flex-col gap-6 p-4"> <header class="grid gap-3">
<div <h2 class="text-title">{{ t('workflow.title') }}</h2>
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" <p class="text-muted-foreground">
> {{ t('workflow.description') }}
<div class="space-y-3"> </p>
<Badge variant="outline" class="px-3 py-1"> </header>
{{ project.key }} <FeedbackState
</Badge>
<Heading
:title="`${project.name} board settings`"
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status" 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" state="success"
> :title="t('workflow.updated')"
{{ status }} />
</div>
<TemplateInheritancePanel <TemplateInheritancePanel
:project-id="project.id"
:template-state="templateState"
:areas="[ :areas="[
'board_columns', 'board_columns',
'ticket_statuses', 'ticket_statuses',
'status_transitions', 'status_transitions',
]" ]"
:project-id="project.id"
:template-state="templateState"
/> />
<Card
<Card> class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
<CardHeader> >
<CardTitle>Workflow model</CardTitle> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<CardDescription> <div class="flex flex-wrap items-center gap-2">
Backlog is always the intake queue; board columns group <Inbox
the statuses that appear on the board. class="text-primary size-5 shrink-0"
</CardDescription> aria-hidden="true"
</CardHeader> />
<h3 class="font-semibold">
<CardContent class="flex flex-wrap gap-3"> {{ t('workflow.backlog') }}
<Badge class="px-3 py-1">Backlog</Badge> </h3>
<Badge <Badge variant="secondary">{{
v-for="ticketStatus in backlogTicketStatuses" t('workflow.offBoard')
:key="ticketStatus.id" }}</Badge>
variant="outline" </div>
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
Tickets move between statuses; backlog statuses are not {{ t('workflow.backlogDescription') }}
included in a board column.
</p> </p>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Status moves</CardTitle>
<CardDescription>
Choose exactly where each status is allowed to move
next.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="p-5 sm:p-6"
<CardContent class="p-0"> ><WorkflowStatusList
<div class="overflow-x-auto"> :column="null"
<table :statuses="backlogTicketStatuses"
class="divide-border min-w-full divide-y text-sm" @edit="target = $event"
> /></CardContent>
<thead </Card>
class="bg-muted/40 text-muted-foreground text-left" <section
> class="grid min-w-0 gap-4"
<tr> aria-labelledby="workflow-columns"
<th class="px-4 py-3 font-medium"> >
Source status <header
</th> class="flex flex-wrap items-center justify-between gap-3"
<th class="px-4 py-3 font-medium"> >
Allowed move targets <div class="flex items-center gap-2">
</th> <Columns3
<th class="px-4 py-3 font-medium">Save</th> class="text-primary size-5"
</tr> aria-hidden="true"
</thead> />
<tbody class="divide-border divide-y"> <h3 id="workflow-columns" class="font-semibold">
<tr {{ t('workflow.columns') }}
v-for="sourceStatus in ticketStatuses" </h3>
:key="sourceStatus.id" <Badge variant="secondary">{{
class="bg-background align-top" boardColumns.length
> }}</Badge>
<td class="px-4 py-4">
<Badge variant="outline">
{{
ticketStatusLabel(sourceStatus)
}}
</Badge>
</td>
<td class="px-4 py-4">
<Form
:id="`status-transitions-${sourceStatus.id}`"
v-bind="
ProjectBoardSettingsController.updateStatusTransitions.form(
[
project.id,
sourceStatus.id,
],
)
"
class="grid gap-3"
v-slot="{ errors }"
>
<div
v-if="
transitionTargetOptions(
sourceStatus,
).length === 0
"
class="text-muted-foreground"
>
No other statuses exist yet.
</div>
<div
v-else
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
>
<label
v-for="targetStatus in transitionTargetOptions(
sourceStatus,
)"
:key="targetStatus.id"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="targetStatus.id"
:checked="
statusAllowsTarget(
sourceStatus,
targetStatus,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
ticketStatusLabel(
targetStatus,
)
}}</span>
</label>
</div>
<InputError
:message="
errors.allowed_ticket_status_ids
"
/>
</Form>
</td>
<td class="px-4 py-4">
<Button
type="submit"
variant="outline"
:form="`status-transitions-${sourceStatus.id}`"
>
Save
</Button>
</td>
</tr>
</tbody>
</table>
</div> </div>
</CardContent> <Button
</Card> type="button"
class="w-full sm:w-auto"
<Card> @click="target = { kind: 'column', action: 'create' }"
<CardHeader> ><Plus class="size-4" aria-hidden="true" />{{
<CardTitle>Backlog statuses</CardTitle> t('workflow.addColumn')
<CardDescription> }}</Button
New backlog tickets choose one of these statuses.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
class="border-border rounded-lg border p-4"
> >
<Form </header>
v-bind=" <p class="text-muted-foreground text-sm">
ProjectBoardSettingsController.updateStatus.form( {{ t('workflow.columnsDescription') }}
[project.id, ticketStatus.id], </p>
) <FeedbackState
" v-if="boardColumns.length === 0"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" state="empty"
v-slot="{ errors, processing }" :title="t('workflow.emptyColumns')"
:description="t('workflow.emptyColumnsDescription')"
/>
<ol
v-else
class="grid min-w-0 gap-4"
:aria-label="t('workflow.columns')"
>
<li
v-for="column in boardColumns"
:key="column.id"
class="bg-card min-w-0 overflow-hidden rounded-xl border"
:data-column-id="column.id"
>
<header
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
> >
<div class="grid gap-2"> <span
<Label class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:for="`backlog-status-${ticketStatus.id}`" :aria-label="
> t('workflow.positionValue', {
Status name position: column.position,
</Label> })
<Input
:id="`backlog-status-${ticketStatus.id}`"
name="name"
:default-value="ticketStatus.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[project.id, ticketStatus.id],
)
" "
v-slot="{ errors, processing }" >{{ column.position }}</span
> >
<InputError <h4
:message="errors.ticket_status_id" class="min-w-0 flex-1 basis-24 break-words font-semibold"
class="mb-2" >
/> {{ column.name }}
</h4>
<div class="flex gap-1">
<Button <Button
type="submit" type="button"
variant="destructive" variant="ghost"
:disabled=" size="icon"
processing || :aria-label="
backlogTicketStatuses.length <= 1 t('workflow.changeColumn', {
name: column.name,
})
" "
> @click="
Delete target = {
</Button> kind: 'column',
</Form> action: 'edit',
</div> column,
</div> }
"
<Form ><Pencil class="size-4" aria-hidden="true"
v-bind=" /></Button>
ProjectBoardSettingsController.storeStatus.form( <Button
project.id, type="button"
) variant="ghost"
" size="icon"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" class="text-destructive"
v-slot="{ errors, processing }" :aria-label="
> t('workflow.removeColumn', {
<div class="grid gap-2"> name: column.name,
<Label for="backlog-status-name"> })
New backlog status "
</Label> @click="
<Input target = {
id="backlog-status-name" kind: 'column',
name="name" action: 'delete',
placeholder="Ready" column,
required }
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</header>
<div class="p-4 sm:p-5">
<WorkflowStatusList
:column="column"
:statuses="column.ticket_statuses ?? []"
@edit="target = $event"
/> />
<InputError :message="errors.name" />
</div> </div>
</li>
<div class="flex items-end"> </ol>
<Button type="submit" :disabled="processing"> </section>
Add status <WorkflowTransitionMatrix
</Button> :project-id="project.id"
</div> :statuses="ticketStatuses"
</Form> />
</CardContent> <WorkflowEditorDialog
</Card> :project-id="project.id"
:column-count="boardColumns.length"
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]"> :target="target"
<Card> @close="target = null"
<CardHeader> />
<CardTitle>Add column</CardTitle> </SettingsLayout>
<CardDescription>
New columns extend the project-specific workflow and
are inserted at the end unless you specify a
position later.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="
ProjectBoardSettingsController.store.form(
project.id,
)
"
class="space-y-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Column name</Label>
<Input
id="name"
name="name"
required
autofocus
placeholder="In Review"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add column
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Board columns</CardTitle>
<CardDescription>
Rename columns or change the numeric position to
reorder the board from left to right.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="boardColumns.length === 0"
class="text-muted-foreground text-sm"
>
No board columns exist yet.
</div>
<div
v-for="column in boardColumns"
:key="column.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectBoardSettingsController.update.form([
project.id,
column.id,
])
"
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label :for="`column-name-${column.id}`">
Name
</Label>
<Input
:id="`column-name-${column.id}`"
name="name"
:default-value="column.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`column-position-${column.id}`"
>
Position
</Label>
<Input
:id="`column-position-${column.id}`"
name="position"
type="number"
min="1"
:max="boardColumns.length"
:default-value="column.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroy.form(
[project.id, column.id],
)
"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Delete
</Button>
</Form>
</div>
<div class="border-border mt-4 border-t pt-4">
<div class="mb-3">
<p class="text-sm font-medium">
Ticket statuses
</p>
<p class="text-muted-foreground text-sm">
Moving into this column requires a
specific status.
</p>
</div>
<div class="space-y-3">
<div
v-for="ticketStatus in column.ticket_statuses ??
[]"
:key="ticketStatus.id"
class="border-border rounded-lg border p-3"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`column-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`column-status-${ticketStatus.id}`"
name="name"
:default-value="
ticketStatus.name
"
required
/>
<InputError
:message="errors.name"
/>
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="
errors.ticket_status_id
"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
(column.ticket_statuses
?.length ?? 0) <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<input
type="hidden"
name="board_column_id"
:value="column.id"
/>
<div class="grid gap-2">
<Label
:for="`new-column-status-${column.id}`"
>
New status
</Label>
<Input
:id="`new-column-status-${column.id}`"
name="name"
placeholder="Ready for QA"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
:disabled="processing"
>
Add status
</Button>
</div>
</Form>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout> </AppLayout>
</template> </template>
+393 -311
View File
@@ -1,281 +1,316 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController'; import { Layers3, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed } from 'vue';
import {
destroy,
switchTemplate,
} from '@/actions/App/Http/Controllers/ProjectController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardHeader } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { import { general } from '@/routes/projects/settings';
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types'; import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps<ProjectGeneralSettingsPageProps>(); const props = defineProps<ProjectGeneralSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', { title: props.project.name, href: show(props.project.id) },
href: index(), { title: t('navigation.general'), href: general(props.project.id) },
}, ]);
{ const switchTemplateForm = useForm<{
title: props.project.name, confirmation_key: string;
href: show(props.project.id), source_template_id: number | undefined;
}, }>({
{
title: 'General settings',
href: generalSettings(props.project.id),
},
];
const switchTemplateForm = useForm({
confirmation_key: '', confirmation_key: '',
source_template_id: props.sourceTemplates.find( source_template_id: undefined,
(template) => template.id !== props.project.project_template_id,
)?.id,
}); });
const deleteForm = useForm({ confirmation_key: '' });
const deleteForm = useForm({ const switchProtection = useFormProtection(switchTemplateForm);
confirmation_key: '', const deleteProtection = useFormProtection(deleteForm);
}); const processing = computed(
() => switchTemplateForm.processing || deleteForm.processing,
);
const availableSwitchTemplates = computed(() => const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter( props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id, (template) => template.id !== props.project.project_template_id,
), ),
); );
const currentTemplate = computed(() => props.project.project_template ?? null); const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed( const canSwitchTemplate = computed(
() => () =>
isSwitchConfirmationValid.value && switchTemplateForm.confirmation_key === props.project.key &&
switchTemplateForm.source_template_id !== undefined, availableSwitchTemplates.value.some(
(template) => template.id === switchTemplateForm.source_template_id,
),
); );
const isDeleteConfirmationValid = computed( const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key, () => deleteForm.confirmation_key === props.project.key,
); );
const inheritanceAreas = computed(() =>
[
...(props.templateState?.inheritedAreas ?? []),
...(props.templateState?.overriddenAreas ?? []),
].map((area) => area.value),
);
const settingsSource = computed(() => {
if (!props.templateState?.inheritsFromTemplate) {
return 'projectSettings.local';
}
if (
props.templateState.inheritedAreas.some(
(area) => area.value === 'project_settings',
)
) {
return 'projectSettings.inherited';
}
return props.templateState.overriddenAreas.some(
(area) => area.value === 'project_settings',
)
? 'projectSettings.override'
: 'projectSettings.local';
});
const submitSwitchTemplate = (): void => { function submitSwitchTemplate(): void {
if (!canSwitchTemplate.value) { if (!canSwitchTemplate.value || processing.value) {
return; return;
} }
switchProtection.submit(switchTemplate(props.project.id), {
preserveScroll: true,
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
});
}
switchTemplateForm.post( function submitDelete(): void {
ProjectController.switchTemplate.url(props.project.id), if (!isDeleteConfirmationValid.value || processing.value) {
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) {
return; return;
} }
deleteProtection.submit(destroy(props.project.id), {
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; }
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} general settings`" /> <Head
:title="t('projectSettings.pageTitle', { project: project.name })"
<div class="flex flex-1 flex-col gap-6 p-4"> />
<div <SettingsLayout :project-name="project.name" :project-key="project.key">
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" <FeedbackState
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} general settings`"
description="Review the core project identity and manage irreversible administrative actions."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status" 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" state="success"
:title="t('projectSettings.updated')"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
> >
{{ status }} <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
</div> <h2 class="text-title">{{ t('navigation.general') }}</h2>
<p class="text-muted-foreground">
<Card> {{ t('projectSettings.generalDescription') }}
<CardHeader> </p>
<CardTitle>Project identity</CardTitle>
<CardDescription>
These values define the project across tickets, boards,
and repository matching.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<CardContent class="grid gap-4 md:grid-cols-2"> <div class="flex flex-wrap items-center gap-2">
<div class="border-border/70 rounded-xl border p-4"> <h3 class="font-semibold">
<p {{ t('projectSettings.identity') }}
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]" </h3>
> <Badge variant="secondary">{{
Project name t('projectSettings.local')
</p> }}</Badge>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.name }}
</p>
</div> </div>
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4"> <div
<p class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]" >
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.name') }}
</dt>
<dd class="break-words font-semibold">
{{ project.name }}
</dd>
</div>
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.key') }}
</dt>
<dd class="break-all font-semibold">
{{ project.key }}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.identityDescription') }}
</p>
<div
class="flex flex-wrap items-center gap-2 border-t pt-4"
>
<h3 class="font-semibold">
{{ t('projectSettings.display') }}
</h3>
<Badge
:variant="
settingsSource === 'projectSettings.inherited'
? 'outline'
: 'secondary'
"
>{{ t(settingsSource) }}</Badge
> >
Project key
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.key }}
</p>
</div> </div>
<dl class="grid gap-3">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showAuthor') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_author
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showStoryPoints') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_story_points
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.displayDescription') }}
</p>
</CardContent> </CardContent>
</Card> </Card>
<TemplateInheritancePanel
<Card> :project-id="project.id"
<CardHeader> :areas="inheritanceAreas"
<CardTitle>Template baseline</CardTitle> :template-state="templateState"
<CardDescription> />
Switch this project to another published template. Use <Card
the inheritance panels to reset individual settings to class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
the currently linked template. >
</CardDescription> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
</CardHeader> <div class="flex items-center gap-2">
<Layers3
<CardContent class="space-y-6"> class="text-primary size-5"
<div class="grid gap-4 md:grid-cols-2"> aria-hidden="true"
<div class="border-border/70 rounded-xl border p-4"> />
<p <h2 class="text-title">
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]" {{ t('projectSettings.template') }}
> </h2>
Current linked template
</p>
<p
class="text-foreground mt-2 text-sm font-semibold"
>
{{
currentTemplate?.name ??
'No linked template'
}}
</p>
<p class="text-muted-foreground mt-2 text-sm">
{{
templateState?.inheritsFromTemplate
? 'Inherited and overridden areas stay visible after switching.'
: 'This project currently behaves like a standalone baseline.'
}}
</p>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Warning
</p>
<p class="text-foreground mt-2 text-sm font-medium">
Switching templates can replace inherited board
workflow, ticket metadata, repositories, and
notifications.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key
to avoid accidental overwrites.
</p>
</div>
</div> </div>
<p class="text-muted-foreground">
<div class="grid gap-6"> {{ t('projectSettings.templateDescription') }}
<form </p>
class="space-y-4 rounded-xl border p-4" </CardHeader>
@submit.prevent="submitSwitchTemplate" <CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.currentTemplate') }}
</p>
<p class="break-words font-semibold">
{{
currentTemplate?.name ??
t('projectSettings.noTemplate')
}}
</p>
<Badge variant="outline" class="justify-self-start">{{
t(
templateState?.inheritsFromTemplate
? 'projectSettings.linked'
: 'projectSettings.standalone',
)
}}</Badge>
<p
v-if="!templateState?.inheritsFromTemplate"
class="text-caption text-muted-foreground"
> >
<div class="space-y-1"> {{ t('projectSettings.standaloneDescription') }}
<h3 class="text-sm font-semibold"> </p>
Switch to another template </div>
</h3> <FeedbackState
<p class="text-muted-foreground text-sm"> v-if="availableSwitchTemplates.length === 0"
Applying a different template replaces the state="empty"
copied areas with that template's current :title="t('projectSettings.noAlternatives')"
baseline. :description="
</p> t('projectSettings.noAlternativesDescription')
</div> "
/>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="switchTemplateForm.processing"
@submit.prevent="submitSwitchTemplate"
>
<FormFeedback
:state="switchProtection.state.value"
:title="
switchTemplateForm.processing
? t('projectSettings.switching')
: undefined
"
@retry="submitSwitchTemplate"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="mb-3 font-semibold">
{{ t('projectSettings.switch') }}
</legend>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
>
{{ t('projectSettings.switchWarning') }}
</p>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="source_template_id" <Label for="source_template_id">{{
>Published template</Label t('projectSettings.publishedTemplate')
> }}</Label>
<select <select
id="source_template_id" id="source_template_id"
v-model=" v-model="
switchTemplateForm.source_template_id switchTemplateForm.source_template_id
" "
:disabled=" class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
switchTemplateForm.processing || :aria-invalid="
availableSwitchTemplates.length === 0 !!switchTemplateForm.errors
.source_template_id
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]" aria-describedby="source-template-error"
> >
<option :value="undefined" disabled> <option :value="undefined" disabled>
Select a template {{
t('projectSettings.selectTemplate')
}}
</option> </option>
<option <option
v-for="template in availableSwitchTemplates" v-for="template in availableSwitchTemplates"
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
</option> </option>
</select> </select>
<InputError <InputError
id="source-template-error"
:message=" :message="
switchTemplateForm.errors switchTemplateForm.errors
.source_template_id .source_template_id
" "
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="switch_confirmation_key" <Label for="switch_confirmation_key">{{
>Confirmation key</Label t('projectSettings.confirmation')
> }}</Label>
<Input <Input
id="switch_confirmation_key" id="switch_confirmation_key"
v-model=" v-model="
switchTemplateForm.confirmation_key switchTemplateForm.confirmation_key
" "
:disabled="switchTemplateForm.processing" autocomplete="off"
placeholder="Type the project key exactly" :placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!switchTemplateForm.errors
.confirmation_key
"
aria-describedby="switch-confirm-help switch-confirm-error"
/> />
<p
id="switch-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError <InputError
id="switch-confirm-error"
:message=" :message="
switchTemplateForm.errors switchTemplateForm.errors
.confirmation_key .confirmation_key
" "
/> />
</div> </div>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
Existing project-specific configuration in
copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="
switchTemplateForm.processing ||
!canSwitchTemplate
"
>
Switch template
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
<Card
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
>
<CardHeader>
<CardTitle class="text-red-900 dark:text-red-100">
Danger zone
</CardTitle>
<CardDescription
class="text-red-800/80 dark:text-red-200/80"
>
Deleting this project is permanent. All tickets, board
columns, ticket metadata, repository connections, and
project permissions will be removed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
>
<p class="text-foreground text-sm font-medium">
Confirm by typing the exact project key:
<span class="font-semibold">{{ project.key }}</span>
</p>
</div>
<form class="space-y-4" @submit.prevent="submitDelete">
<div class="grid gap-2">
<Label for="confirmation_key">
Confirmation key
</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
:disabled="deleteForm.processing"
placeholder="Type the project key exactly"
/>
<InputError
:message="deleteForm.errors.confirmation_key"
/>
</div>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
This action cannot be undone.
</p>
<Button <Button
type="submit" type="submit"
variant="destructive" variant="outline"
:disabled=" class="justify-self-start"
deleteForm.processing || :disabled="!canSwitchTemplate"
!isDeleteConfirmationValid >{{
" t(
switchTemplateForm.processing
? 'projectSettings.switching'
: 'projectSettings.switch',
)
}}</Button
> >
Delete project </fieldset>
</Button>
</div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div> <Card
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
>
<div class="flex items-center gap-2">
<Trash2
class="text-destructive size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.danger') }}
</h2>
</div>
<p class="text-muted-foreground">
{{ t('projectSettings.deleteDescription') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid min-w-0"
:aria-busy="deleteForm.processing"
@submit.prevent="submitDelete"
>
<FormFeedback
:state="deleteProtection.state.value"
:title="
deleteForm.processing
? t('projectSettings.deleting')
: undefined
"
@retry="submitDelete"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="sr-only">
{{ t('projectSettings.delete') }}
</legend>
<p class="text-caption font-medium">
{{ t('projectSettings.irreversible') }}
</p>
<div class="grid gap-2">
<Label for="confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
autocomplete="off"
:placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!deleteForm.errors.confirmation_key
"
aria-describedby="delete-confirm-help delete-confirm-error"
/>
<p
id="delete-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError
id="delete-confirm-error"
:message="
deleteForm.errors.confirmation_key
"
/>
</div>
<Button
type="submit"
variant="destructive"
class="justify-self-start"
:disabled="!isDeleteConfirmationValid"
>{{
t(
deleteForm.processing
? 'projectSettings.deleting'
: 'projectSettings.delete',
)
}}</Button
>
</fieldset>
</form>
</CardContent>
</Card>
</SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -1,35 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import AppearanceTabs from '@/components/AppearanceTabs.vue'; 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 AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance/index'; import { edit } from '@/routes/appearance/index';
import type { BreadcrumbItem } from '@/types'; 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(), href: edit(),
}, },
]; ]);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" /> <Head :title="t('appearance.title')" />
<h1 class="sr-only">Appearance settings</h1>
<SettingsLayout> <SettingsLayout>
<div class="space-y-6"> <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<Heading <CardHeader class="gap-stack border-b p-5 sm:p-6">
variant="small" <h2 class="text-title">{{ t('appearance.title') }}</h2>
title="Appearance settings" <p class="text-muted-foreground">
description="Update your account's appearance settings" {{ t('appearance.description') }}
/> </p>
<AppearanceTabs /> </CardHeader>
</div> <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> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+290 -170
View File
@@ -1,198 +1,318 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController'; import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { import {
Card, destroy,
CardContent, store,
CardDescription, } from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
CardHeader, import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
CardTitle, import InputError from '@/components/InputError.vue';
} from '@/components/ui/card'; 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.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'; 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[] = [ function formatDate(value: string | null): string {
{ if (!value) {
title: 'Applications', return t('applications.never');
href: editApplications(),
},
];
const formatDate = (value: string | null): string => {
if (value === null) {
return 'Never';
} }
return new Intl.DateTimeFormat(locale.value, {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}).format(new Date(value)); }).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> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Applications" /> <Head :title="t('applications.title')" />
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col gap-6"> <div class="gap-stack grid">
<div class="space-y-2"> <h2 class="text-title">{{ t('applications.title') }}</h2>
<Heading <p class="text-muted-foreground">
variant="small" {{ t('applications.description') }}
title="Applications" </p>
description="Create API keys for tools that should use KanbanGit with your account permissions." </div>
/> <p
<p class="text-muted-foreground text-sm"> v-if="status"
Keys are shown once. Delete an application when a tool role="status"
should lose access. class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
</p> >
</div> {{ status }}
</p>
<div <section
v-if="status" v-if="applicationKey"
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" class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
> aria-labelledby="application-key-title"
{{ status }} >
</div> <h3 id="application-key-title" class="font-semibold">
{{ t('applications.saveKey') }}
<div </h3>
v-if="applicationKey" <p class="text-body text-muted-foreground">
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" {{ t('applications.keyOnce') }}
> </p>
<p class="font-medium">Save this API key now.</p> <Label for="application-key" class="sr-only">{{
<p class="mt-1"> t('applications.apiKey')
It will not be shown again after you leave this page. }}</Label>
</p> <div class="flex min-w-0 flex-wrap gap-2">
<Input <Input
class="mt-3 font-mono text-xs" id="application-key"
:model-value="applicationKey" :model-value="applicationKey"
readonly 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>
<p
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]"> v-if="copied"
<Card> role="status"
<CardHeader> class="text-caption text-success-foreground"
<CardTitle>Create application</CardTitle> >
<CardDescription> {{ t('security.copied') }}
Name the tool that will receive an API key. </p>
</CardDescription> <InputError
</CardHeader> v-if="copyError"
:message="t('security.copyError')"
<CardContent> />
<Form </section>
v-bind="UserApplicationController.store.form()" <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
class="space-y-4" <CardHeader class="gap-stack border-b p-5 sm:p-6">
v-slot="{ errors, processing }" <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"> <div>
<Label for="name">Application name</Label> <dt class="inline font-medium">
<Input {{ t('applications.created') }}
id="name" </dt>
name="name" <dd class="inline">
type="text" {{ formatDate(application.created_at) }}
required </dd>
autofocus
placeholder="Claude Desktop"
/>
<InputError :message="errors.name" />
</div> </div>
<div>
<Button type="submit" :disabled="processing"> <dt class="inline font-medium">
Create application {{ t('applications.lastUsed') }}
</Button> </dt>
</Form> <dd class="inline">
</CardContent> {{
</Card> formatDate(application.last_used_at)
}}
<Card class="gap-0 overflow-hidden"> </dd>
<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> </div>
</div> </dl>
</CardContent> </div>
</Card> <Button
</div> variant="outline"
</div> 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> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+464 -238
View File
@@ -1,267 +1,493 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController'; import {
import Heading from '@/components/Heading.vue'; GitBranch,
LockKeyhole,
Pencil,
Plus,
Server,
Trash2,
} from 'lucide-vue-next';
import { computed, ref } from 'vue';
import {
destroy,
store,
update,
} from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index'; import { edit } from '@/routes/git-connections/index';
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types'; import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
defineProps<SettingsGitConnectionsPageProps>(); const props = defineProps<SettingsGitConnectionsPageProps>();
const { t } = useLocalization();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.gitConnections'), href: edit() },
]);
const mode = ref<'create' | 'edit' | 'remove'>('create');
const dialogOpen = ref(false);
const selectedConnection = ref<UserGitConnectionModel | null>(null);
// Keep provider credentials out of Inertia's remembered browser history.
const connectionForm = useForm({
provider: 'gitea',
base_url: '',
api_token: '',
});
const connectionProtection = useFormProtection(connectionForm);
const removalForm = useForm({});
const removalProtection = useFormProtection(removalForm);
const processing = computed(
() => connectionForm.processing || removalForm.processing,
);
const dialogTitle = computed(() =>
t(
mode.value === 'create'
? 'gitConnections.add'
: mode.value === 'edit'
? 'gitConnections.edit'
: 'gitConnections.removeTitle',
),
);
const successTitle = computed(() =>
props.status === 'Git connection was added.'
? t('gitConnections.added')
: props.status === 'Git connection was updated.'
? t('gitConnections.updated')
: t('gitConnections.removed'),
);
const page = usePage(); function resetForms(): void {
const appName = String(page.props.name); connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
connectionForm.resetAndClearErrors();
connectionProtection.clearFeedback();
removalForm.resetAndClearErrors();
removalProtection.clearFeedback();
}
const breadcrumbItems: BreadcrumbItem[] = [ function openDialog(
{ nextMode: typeof mode.value,
title: 'Git connections', connection: UserGitConnectionModel | null = null,
href: editGitConnections(), ): void {
}, resetForms();
]; mode.value = nextMode;
selectedConnection.value = connection;
connectionForm.defaults({
provider: connection?.provider ?? 'gitea',
base_url: connection?.base_url ?? '',
api_token: '',
});
connectionForm.reset();
dialogOpen.value = true;
}
function closeDialog(open: boolean): void {
if (open || processing.value || !connectionProtection.confirmLeave()) {
return;
}
dialogOpen.value = false;
resetForms();
}
function finish(): void {
resetForms();
dialogOpen.value = false;
}
function saveConnection(): void {
const target =
mode.value === 'edit' && selectedConnection.value
? update(selectedConnection.value.id)
: store();
connectionProtection.submit(target, {
preserveScroll: true,
onSuccess: finish,
});
}
function removeConnection(): void {
if (!selectedConnection.value) {
return;
}
removalProtection.submit(destroy(selectedConnection.value.id), {
preserveScroll: true,
onSuccess: finish,
});
}
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Git connections" /> <Head :title="t('navigation.gitConnections')" />
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col gap-6"> <Card
<div class="space-y-2"> class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
<Heading >
variant="small" <CardHeader
title="Git connections" class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access."
/>
<p class="text-muted-foreground text-sm">
Each user needs their own application key. Without a
matching personal connection, {{ appName }} can only
show repository names and will link you back to this
page.
</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> class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]"> <div class="gap-stack grid min-w-0">
<Card> <h2 class="text-title">
<CardHeader> {{ t('navigation.gitConnections') }}
<CardTitle>Add Gitea connection</CardTitle> </h2>
<CardDescription> <p class="text-muted-foreground">
Connect a Gitea account by storing the instance {{ t('gitConnections.description') }}
base URL and a personal application token. </p>
</CardDescription> </div>
</CardHeader> <Button
type="button"
<CardContent> class="shrink-0 self-start"
<Form @click="openDialog('create')"
v-bind="GitConnectionController.store.form()" >
class="space-y-4" <Plus aria-hidden="true" />{{
v-slot="{ errors, processing }" t('gitConnections.add')
> }}
<input </Button>
name="provider" </div>
type="hidden" <div
value="gitea" class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
/> >
<LockKeyhole
<div class="grid gap-2"> class="text-primary mt-0.5 size-4 shrink-0"
<Label for="base_url">Gitea base URL</Label> aria-hidden="true"
<Input />
id="base_url" <p>{{ t('gitConnections.privacy') }}</p>
name="base_url" </div>
type="url" </CardHeader>
required <CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
autofocus <FeedbackState
placeholder="https://git.example.com" v-if="status"
/> state="success"
<InputError :message="errors.base_url" /> :title="successTitle"
</div> />
<div class="flex flex-wrap items-center gap-2">
<div class="grid gap-2"> <h3 class="font-semibold">
<Label for="api_token" {{ t('gitConnections.saved') }}
>Application token</Label </h3>
> <Badge variant="secondary">{{
<Input gitConnections.length
id="api_token" }}</Badge>
name="api_token" </div>
type="password" <FeedbackState
required v-if="gitConnections.length === 0"
placeholder="Paste your Gitea application key" state="empty"
/> :title="t('gitConnections.empty')"
<InputError :message="errors.api_token" /> :description="t('gitConnections.emptyDescription')"
</div> />
<ul
<Button type="submit" :disabled="processing"> v-else
Add connection class="gap-column grid min-w-0"
</Button> :aria-label="t('gitConnections.saved')"
</Form> >
</CardContent> <li
</Card> v-for="connection in gitConnections"
:key="connection.id"
<Card class="gap-0 overflow-hidden"> class="gap-column grid min-w-0 rounded-lg border p-4"
<CardHeader class="border-b"> >
<CardTitle>Saved connections</CardTitle> <div class="flex min-w-0 items-start gap-3">
<CardDescription>
Tokens stay hidden after save. Updating a
connection requires entering a fresh token.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="gitConnections.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No Git connections have been added yet.
</div>
<div
v-for="gitConnection in gitConnections"
:key="gitConnection.id"
class="border-border rounded-xl border p-4"
>
<div <div
class="mb-4 flex flex-wrap items-start justify-between gap-3" class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
> >
<div class="space-y-1"> <GitBranch
<div class="size-5"
class="flex flex-wrap items-center gap-2" aria-hidden="true"
/>
</div>
<div class="gap-stack grid min-w-0 flex-1">
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<h4
class="min-w-0 break-all font-semibold"
> >
<Badge>{{ {{ connection.account_login }}
gitConnection.provider_label </h4>
}}</Badge> <Badge variant="secondary">{{
<span class="text-sm font-medium"> connection.provider_label
{{ }}</Badge>
gitConnection.account_login
}}
</span>
</div>
<p
class="text-muted-foreground text-sm"
>
{{
gitConnection.account_name ??
'Unnamed remote account'
}}
</p>
<p
class="text-muted-foreground text-xs"
>
{{ gitConnection.base_url }}
</p>
</div> </div>
<p
<p class="text-muted-foreground text-xs"> class="text-caption text-muted-foreground break-words"
Remote user ID: >
{{ gitConnection.provider_user_id }} {{
connection.account_name ??
t('gitConnections.unnamedAccount')
}}
</p>
<div
class="text-caption text-muted-foreground flex min-w-0 items-start gap-2"
>
<Server
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<span class="min-w-0 break-all">{{
connection.base_url
}}</span>
</div>
<p
class="text-caption text-muted-foreground break-all"
>
{{
t('gitConnections.remoteId', {
id: connection.provider_user_id,
})
}}
</p> </p>
</div> </div>
<Form
v-bind="
GitConnectionController.update.form(
gitConnection.id,
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<input
name="provider"
type="hidden"
:value="gitConnection.provider"
/>
<div class="grid gap-2">
<Label
:for="`base-url-${gitConnection.id}`"
>
Gitea base URL
</Label>
<Input
:id="`base-url-${gitConnection.id}`"
name="base_url"
type="url"
:default-value="
gitConnection.base_url
"
required
/>
<InputError
:message="errors.base_url"
/>
</div>
<div class="grid gap-2">
<Label
:for="`token-${gitConnection.id}`"
>
New application token
</Label>
<Input
:id="`token-${gitConnection.id}`"
name="api_token"
type="password"
required
placeholder="Paste a new token to verify and save"
/>
<InputError
:message="errors.api_token"
/>
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save changes
</Button>
</div>
</Form>
<Form
v-bind="
GitConnectionController.destroy.form(
gitConnection.id,
)
"
class="mt-3 flex justify-end"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Remove connection
</Button>
</Form>
</div> </div>
</CardContent> <div
</Card> class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
</div> >
</div> <Button
type="button"
variant="outline"
:aria-label="
t('gitConnections.editAccount', {
account: connection.account_login,
server: connection.base_url,
})
"
@click="openDialog('edit', connection)"
>
<Pencil aria-hidden="true" />{{
t('gitConnections.editAction')
}}
</Button>
<Button
type="button"
variant="ghost"
:aria-label="
t('gitConnections.removeAccount', {
account: connection.account_login,
server: connection.base_url,
})
"
@click="openDialog('remove', connection)"
>
<Trash2 aria-hidden="true" />{{
t('gitConnections.remove')
}}
</Button>
</div>
</li>
</ul>
</CardContent>
</Card>
<Dialog :open="dialogOpen" @update:open="closeDialog">
<DialogContent
:show-close-button="!processing"
@escape-key-down="processing && $event.preventDefault()"
@interact-outside="processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ dialogTitle }}</DialogTitle>
<DialogDescription>{{
t(
mode === 'remove'
? 'gitConnections.removeDescription'
: mode === 'edit'
? 'gitConnections.editDescription'
: 'gitConnections.addDescription',
)
}}</DialogDescription>
</DialogHeader>
<form
v-if="mode !== 'remove'"
class="gap-column grid min-w-0"
:aria-busy="connectionForm.processing"
@submit.prevent="saveConnection"
>
<FormFeedback
:state="connectionProtection.state.value"
:title="
connectionProtection.responseStatus.value ===
403
? t('gitConnections.forbidden')
: connectionForm.processing
? t('gitConnections.verifying')
: undefined
"
:description="
connectionProtection.responseStatus.value ===
403
? t('gitConnections.forbiddenDescription')
: undefined
"
:retryable="
connectionProtection.responseStatus.value !==
403
"
@retry="saveConnection"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="connectionForm.processing"
>
<legend class="sr-only">{{ dialogTitle }}</legend>
<Badge
variant="secondary"
class="justify-self-start"
>{{ t('gitConnections.gitea') }}</Badge
>
<InputError
:message="connectionForm.errors.provider"
/>
<div class="grid gap-2">
<Label for="git-base-url">{{
t('gitConnections.baseUrl')
}}</Label>
<Input
id="git-base-url"
v-model="connectionForm.base_url"
name="base_url"
type="url"
required
placeholder="https://git.example.com"
autocomplete="url"
:aria-invalid="
!!connectionForm.errors.base_url
"
aria-describedby="git-base-url-error"
/>
<InputError
id="git-base-url-error"
:message="connectionForm.errors.base_url"
/>
</div>
<div class="grid gap-2">
<Label for="git-token">{{
t(
mode === 'edit'
? 'gitConnections.newToken'
: 'gitConnections.token',
)
}}</Label>
<Input
id="git-token"
v-model="connectionForm.api_token"
name="api_token"
type="password"
required
autocomplete="new-password"
:placeholder="
t('gitConnections.tokenPlaceholder')
"
:aria-invalid="
!!connectionForm.errors.api_token
"
aria-describedby="git-token-help git-token-error"
/>
<p
id="git-token-help"
class="text-caption text-muted-foreground"
>
{{ t('gitConnections.tokenHelp') }}
</p>
<InputError
id="git-token-error"
:message="connectionForm.errors.api_token"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
@click="closeDialog(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit">{{
t(
connectionForm.processing
? 'gitConnections.verifying'
: mode === 'edit'
? 'gitConnections.save'
: 'gitConnections.add',
)
}}</Button>
</DialogFooter>
</fieldset>
</form>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="removalForm.processing"
@submit.prevent="removeConnection"
>
<div
class="bg-destructive/8 border-destructive/25 text-body grid min-w-0 gap-2 rounded-lg border p-4"
>
<p class="break-all font-semibold">
{{ selectedConnection?.account_login }}
</p>
<p class="text-muted-foreground break-all">
{{ selectedConnection?.base_url }}
</p>
</div>
<FormFeedback
:state="removalProtection.state.value"
:title="
removalProtection.responseStatus.value === 403
? t('gitConnections.forbidden')
: removalForm.processing
? t('gitConnections.removing')
: undefined
"
:description="
removalProtection.responseStatus.value === 403
? t('gitConnections.forbiddenDescription')
: undefined
"
:retryable="
removalProtection.responseStatus.value !== 403
"
@retry="removeConnection"
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="removalForm.processing"
@click="closeDialog(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
variant="destructive"
:disabled="removalForm.processing"
>{{
t(
removalForm.processing
? 'gitConnections.removing'
: 'gitConnections.removeTitle',
)
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+330 -154
View File
@@ -1,209 +1,385 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3'; import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Trash2 } from 'lucide-vue-next'; import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue'; 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 InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types'; import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import type { UserModel } from '@/types/models/user-model';
defineProps<SettingsProfilePageProps>(); defineProps<SettingsProfilePageProps>();
const breadcrumbItems: BreadcrumbItem[] = [ const { t } = useLocalization();
{
title: 'Profile settings',
href: edit(),
},
];
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); 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 avatarDeleteForm = useForm({});
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
const avatarPreview = ref<string | null>(null);
const displayedAvatar = computed( const displayedAvatar = computed(
() => avatarPreview.value ?? user.value.avatar, () => avatarPreview.value ?? user.value.avatar,
); );
const displayedUser = computed<UserModel>(() => ({
...user.value,
avatar: displayedAvatar.value,
}));
const canRemoveAvatar = computed( 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) { if (avatarPreview.value) {
URL.revokeObjectURL(avatarPreview.value); URL.revokeObjectURL(avatarPreview.value);
avatarPreview.value = null; 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 input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0] ?? null;
clearAvatarPreview(); clearAvatarPreview();
profileForm.avatar = file;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (file) { if (file) {
avatarPreview.value = URL.createObjectURL(file); avatarPreview.value = URL.createObjectURL(file);
} }
}; }
const removeAvatar = (): void => { function submitProfile(): void {
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), { profileProtection.submit(profileUpdateTarget, {
forceFormData: true,
preserveScroll: true, preserveScroll: true,
onSuccess: clearAvatarPreview, onSuccess: () => {
profileForm.defaults({
name: profileForm.name,
email: profileForm.email,
avatar: null,
});
clearAvatarSelection();
},
}); });
}; }
onBeforeUnmount(() => { function removeAvatar(): void {
clearAvatarPreview(); avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
}); preserveScroll: true,
onSuccess: clearAvatarSelection,
});
}
onBeforeUnmount(clearAvatarPreview);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" /> <Head :title="t('profile.title')" />
<h1 class="sr-only">Profile settings</h1>
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col space-y-6"> <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<Heading <CardHeader class="gap-stack border-b p-5 sm:p-6">
variant="small" <h2 class="text-title">{{ t('profile.title') }}</h2>
title="Profile information" <p class="text-muted-foreground">
description="Update your name, email address, and profile picture" {{ t('profile.description') }}
/> </p>
</CardHeader>
<Form <CardContent class="p-5 sm:p-6">
v-bind="ProfileController.update.form()" <form
class="space-y-6" class="gap-column grid min-w-0"
v-slot="{ :aria-busy="profileForm.processing"
errors, @submit.prevent="submitProfile"
processing, >
progress, <FormFeedback
recentlySuccessful, :state="profileProtection.state.value"
}" :title="
> profileForm.processing
<div class="grid gap-3"> ? t('profile.saving')
<Label for="avatar">Profile picture</Label> : undefined
<div class="flex items-center gap-4"> "
<UserAvatar class="size-16" /> @retry="submitProfile"
<div class="grid flex-1 gap-2"> />
<div class="flex flex-col gap-2 sm:flex-row">
<Input <fieldset
id="avatar" class="gap-column grid min-w-0"
name="avatar" :disabled="profileForm.processing"
type="file" >
accept="image/png,image/jpeg,image/webp" <legend class="sr-only">
@change="handleAvatarChange" {{ t('profile.information') }}
/> </legend>
<Button
v-if="canRemoveAvatar" <section
type="button" aria-labelledby="profile-picture-title"
variant="outline" class="gap-column grid min-w-0 border-b pb-6"
:disabled="avatarDeleteForm.processing" >
data-test="delete-profile-picture-button" <div class="gap-stack grid">
@click="removeAvatar" <h3
id="profile-picture-title"
class="font-semibold"
> >
<Trash2 class="size-4" /> {{ t('profile.picture') }}
Remove </h3>
</Button> <p
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureDescription') }}
</p>
</div> </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> </p>
</div> </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"> <div class="flex flex-wrap items-center gap-3">
<Label for="name">Name</Label> <Button
<Input type="submit"
id="name" :disabled="profileForm.processing"
class="mt-1 block w-full" data-test="update-profile-button"
name="name" >
:default-value="user.name" {{
required profileForm.processing
autocomplete="name" ? t('profile.saving')
placeholder="Full name" : t('profile.save')
/> }}
<InputError class="mt-2" :message="errors.name" /> </Button>
</div> <p
v-if="profileForm.isDirty"
<div class="grid gap-2"> class="text-caption text-muted-foreground"
<Label for="email">Email address</Label> >
<Input {{ t('profile.unsaved') }}
id="email" </p>
type="email" </div>
class="mt-1 block w-full" </fieldset>
name="email" </form>
:default-value="user.email" </CardContent>
required </Card>
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>
<DeleteUser /> <DeleteUser />
</SettingsLayout> </SettingsLayout>
+309 -191
View File
@@ -1,222 +1,340 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next'; import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted, ref } from 'vue'; import { computed, onUnmounted, ref } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/Heading.vue'; import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue'; import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue'; import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
import { Button } from '@/components/ui/button'; 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 { 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 AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/security/index'; import { edit } from '@/routes/security/index';
import { disable, enable } from '@/routes/two-factor'; import { disable, enable } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
type Props = { const props = withDefaults(
canManageTwoFactor?: boolean; defineProps<{
mustChangePassword?: boolean; canManageTwoFactor?: boolean;
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
status?: string | null; twoFactorEnabled?: boolean;
twoFactorEnabled?: boolean; }>(),
};
withDefaults(defineProps<Props>(), {
canManageTwoFactor: false,
mustChangePassword: false,
requiresConfirmation: false,
status: null,
twoFactorEnabled: false,
});
const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Security settings', canManageTwoFactor: false,
href: edit(), 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(); function savePassword(): void {
const showSetupModal = ref<boolean>(false); passwordProtection.submit(update(), {
preserveScroll: true,
onUnmounted(() => clearTwoFactorAuthData()); 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> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Security settings" /> <Head :title="t('security.title')" />
<h1 class="sr-only">Security settings</h1>
<SettingsLayout> <SettingsLayout>
<div <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
v-if="mustChangePassword || status" <CardHeader class="gap-stack border-b p-5 sm:p-6">
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" <h2 class="text-title">
> {{ t('security.passwordTitle') }}
{{ </h2>
mustChangePassword <p class="text-muted-foreground">
? 'You must change your password before continuing anywhere else in the app.' {{ t('security.passwordDescription') }}
: 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.
</p> </p>
</CardHeader>
<div> <CardContent class="p-5 sm:p-6">
<Button <form
v-if="hasSetupData" class="gap-column grid"
@click="showSetupModal = true" :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 <legend class="sr-only">
</Button> {{ t('security.passwordTitle') }}
<Form </legend>
v-else <div class="grid gap-2">
v-bind="enable.form()" <Label for="current_password">{{
@success="showSetupModal = true" t('security.currentPassword')
#default="{ processing }" }}</Label>
> <PasswordInput
<Button type="submit" :disabled="processing"> id="current_password"
Enable 2FA v-model="passwordForm.current_password"
</Button> name="current_password"
</Form> required
</div> autocomplete="current-password"
</div> :disabled="passwordForm.processing"
:aria-invalid="
<div !!passwordForm.errors.current_password
v-else "
class="flex flex-col items-start justify-start space-y-4" aria-describedby="current-password-error"
> />
<p class="text-muted-foreground text-sm"> <InputError
You will be prompted for a secure, random pin during id="current-password-error"
login, which you can retrieve from the TOTP-supported :message="
application on your phone. passwordForm.errors.current_password
</p> "
/>
<div class="relative inline"> </div>
<Form v-bind="disable.form()" #default="{ processing }"> <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 <Button
variant="destructive"
type="submit" 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 </fieldset>
</Button> </form>
</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> </div>
<p class="text-muted-foreground">
<TwoFactorRecoveryCodes /> {{
</div> t(
props.twoFactorEnabled
<TwoFactorSetupModal ? 'security.enabledDescription'
v-model:isOpen="showSetupModal" : 'security.twoFactorDescription',
:requiresConfirmation="requiresConfirmation" )
:twoFactorEnabled="twoFactorEnabled" }}
/> </p>
</div> </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> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+10
View File
@@ -0,0 +1,10 @@
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
export type MetadataKind = 'type' | 'priority';
export type MetadataEntry = TicketPriorityModel &
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
export type MetadataEditorTarget = { kind: MetadataKind } & (
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: MetadataEntry }
);
+20
View File
@@ -0,0 +1,20 @@
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export type WorkflowEditorTarget =
| { kind: 'column'; action: 'create'; column?: never }
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
| {
kind: 'status';
action: 'create';
column: BoardColumnModel | null;
status?: never;
replacement?: never;
}
| {
kind: 'status';
action: 'edit' | 'delete';
column: BoardColumnModel | null;
status: TicketStatusModel;
replacement?: TicketStatusModel;
};
+23
View File
@@ -1,4 +1,5 @@
import type { ProjectTicketPageProps } from '@/types/page-props'; import type { ProjectTicketPageProps } from '@/types/page-props';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
export type TicketDetailHeaderProps = Pick< export type TicketDetailHeaderProps = Pick<
ProjectTicketPageProps, ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket' 'ticket' | 'project' | 'canEditTicket'
@@ -15,6 +16,10 @@ export type TicketDetailContentProps = Pick<
| 'ticketTypes' | 'ticketTypes'
| 'users' | 'users'
>; >;
export type TicketDetailBlockerProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailMetadataProps = Pick< export type TicketDetailMetadataProps = Pick<
ProjectTicketPageProps, ProjectTicketPageProps,
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions' 'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
@@ -28,6 +33,24 @@ export type TicketDetailGitProps = Pick<
| 'repositoryPullRequests' | 'repositoryPullRequests'
| 'suggestedBranchName' | '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< export type TicketDetailRelationshipsProps = Pick<
ProjectTicketPageProps, ProjectTicketPageProps,
| 'ticket' | 'ticket'
@@ -298,6 +298,79 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get()); $this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
} }
/**
* Ensure saved outgoing targets reach both consumers without changing the reverse direction.
*/
public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
$this->syncProjectPermissions($project, $user, ['edit']);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$sourceStatus = TicketStatus::factory()->for($project)->for($column)->create([
'name' => 'Progress',
'position' => 1,
]);
$reviewStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Review',
'position' => 2,
]);
$doneStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Done',
'position' => 3,
]);
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]);
$sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]);
$reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]);
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [$reviewStatus->id],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys());
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.current_status.id', $sourceStatus->id)
->assertJsonCount(1, 'data.target_statuses')
->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id);
$this->get(route('projects.board.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Board')
->where('boardColumns.0.tickets.0.id', $ticket->id)
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1)
->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id),
);
$this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
$this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.target_statuses', []);
$this->get(route('projects.board.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Board')
->where('boardColumns.0.tickets.0.id', $ticket->id)
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0),
);
}
/** /**
* Ensure transition updates only accept targets from the same project and never the source status itself. * Ensure transition updates only accept targets from the same project and never the source status itself.
*/ */
@@ -372,4 +445,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
'id' => $backlogStatus->id, 'id' => $backlogStatus->id,
]); ]);
} }
public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$backlog = TicketStatus::factory()->for($project)->create(['position' => 1]);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$statuses = TicketStatus::factory()->for($project)->for($column)->count(2)
->sequence(['position' => 1], ['position' => 2])->create();
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]);
$statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]);
$this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $column]))
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertModelMissing($column);
$this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id);
$this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all());
$this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all());
$this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null));
$this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
}
public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$status = TicketStatus::factory()->for($project)->for($column)->create();
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]);
$this->actingAs($user)->from(route('projects.settings.board', $project))
->delete(route('projects.settings.board.statuses.destroy', [$project, $status]))
->assertSessionHasErrors('ticket_status_id');
$this->assertModelExists($status);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->actingAs($user)->from(route('projects.settings.board', $project))
->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0])
->assertSessionHasErrors('position');
$this->assertSame(1, $column->fresh()->position);
$this->assertSame($column->name, $column->fresh()->name);
}
} }
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectBoardSettingsUiTest extends TestCase
{
public function test_workflow_renders_ordered_groups_deletion_protection_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-board-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectGeneralSettingsUiTest extends TestCase
{
public function test_general_settings_render_setting_sources_confirmations_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-general-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project general settings rendering checks passed.', $process->getOutput());
}
}
@@ -487,4 +487,75 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'id' => $ticketLinkType->id, 'id' => $ticketLinkType->id,
]); ]);
} }
public function test_last_priority_is_protected_from_deletion(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$priority = TicketPriority::factory()->for($project)->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('error', true)
->assertSessionHas('status', 'The last ticket priority cannot be deleted.');
$this->assertModelExists($priority);
}
public function test_type_order_and_usage_counts_are_returned_after_reordering(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$types = $project->ticketTypes()->get();
$first = $types->first();
$last = $types->last();
Ticket::factory()->for($project)->create([
'ticket_type_id' => $last->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.metadata.types.update', [$project, $last]), [
'name' => 'Customer request', 'position' => 1,
])
->assertSessionHasNoErrors();
$this->get(route('projects.settings.metadata', $project))
->assertInertia(fn (Assert $page) => $page
->where('ticketTypes.0.id', $last->id)
->where('ticketTypes.0.name', 'Customer request')
->where('ticketTypes.0.position', 1)
->where('ticketTypes.0.tickets_count', 1)
->where('ticketTypes.1.id', $first->id)
->where('ticketTypes.1.position', 2)
->where('ticketTypes.1.tickets_count', 0));
}
public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$type = $project->ticketTypes()->firstOrFail();
$foreignType = TicketType::factory()->create();
$ticket = Ticket::factory()->for($project)->create([
'ticket_type_id' => $type->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [
'replacement_ticket_type_id' => $foreignType->id,
])
->assertSessionHasErrors('replacement_ticket_type_id');
$this->assertModelExists($type);
$this->assertSame($type->id, $ticket->fresh()->ticket_type_id);
}
} }
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectTicketMetadataSettingsUiTest extends TestCase
{
public function test_metadata_settings_show_configured_values_and_deletion_protection(): void
{
$process = new Process(['node', 'tests/Fixtures/project-metadata-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput());
}
}
+61 -1
View File
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
'base_url' => 'https://git.example.test', 'base_url' => 'https://git.example.test',
]); ]);
UserGitConnection::factory()->create();
$this->actingAs($user) $this->actingAs($user)
->get(route('git-connections.edit')) ->get(route('git-connections.edit'))
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections') ->component('settings/GitConnections')
->has('gitConnections', 1)
->missing('gitConnections.0.api_token')
->where('gitConnections.0.account_login', 'nico') ->where('gitConnections.0.account_login', 'nico')
->where('gitConnections.0.base_url', 'https://git.example.test') ->where('gitConnections.0.base_url', 'https://git.example.test')
->where('gitConnections.0.provider', GitProviderKey::Gitea->value), ->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
'api_token' => 'wrong-token', 'api_token' => 'wrong-token',
]) ])
->assertRedirect(route('git-connections.edit')) ->assertRedirect(route('git-connections.edit'))
->assertSessionHasErrors('api_token'); ->assertSessionHasErrors('api_token')
->assertSessionMissing('_old_input.api_token');
} }
/** /**
@@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase
'id' => $gitConnection->id, 'id' => $gitConnection->id,
]); ]);
} }
public function test_empty_personal_connections_are_displayed(): void
{
UserGitConnection::factory()->create();
$this->actingAs(User::factory()->create())
->get(route('git-connections.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections')
->where('gitConnections', []),
);
}
public function test_guests_cannot_access_git_connections(): void
{
$this->get(route('git-connections.edit'))->assertRedirect(route('login'));
}
public function test_users_cannot_delete_foreign_connections(): void
{
$connection = UserGitConnection::factory()->create();
$this->actingAs(User::factory()->create())
->delete(route('git-connections.destroy', $connection))
->assertForbidden();
$this->assertModelExists($connection);
}
public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void
{
Http::preventStrayRequests();
Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]);
$user = User::factory()->create();
$connection = UserGitConnection::factory()->for($user)->create([
'base_url' => 'https://git.original.test',
'api_token' => 'original-secret',
]);
$original = $connection->fresh()->getAttributes();
$this->actingAs($user)
->from(route('git-connections.edit'))
->patch(route('git-connections.update', $connection), [
'provider' => 'gitea',
'base_url' => 'https://git.changed.test',
'api_token' => 'replacement-secret',
])
->assertRedirect(route('git-connections.edit'))
->assertSessionHasErrors('api_token')
->assertSessionMissing('_old_input.api_token');
$this->assertSame($original, $connection->fresh()->getAttributes());
$this->assertSame('original-secret', $connection->fresh()->api_token);
}
} }
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Settings;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class GitConnectionsUiTest extends TestCase
{
public function test_personal_git_connections_rendering_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/git-connections.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput());
}
}
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;
use Tests\TestCase; use Tests\TestCase;
class ProfileUpdateTest extends TestCase class ProfileUpdateTest extends TestCase
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
$response->assertSessionHasErrors('avatar'); $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() public function test_user_can_delete_their_account()
{ {
Storage::fake('public'); Storage::fake('public');
+52
View File
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Tests\TestCase; use Tests\TestCase;
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
$this->assertTrue(Hash::check('password', $user->fresh()->password)); $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());
}
} }
+18
View File
@@ -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 -1
View File
@@ -25,7 +25,9 @@ class UserApplicationTest extends TestCase
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('settings/Applications') ->component('settings/Applications')
->where('applications.0.name', 'Claude Desktop') ->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, '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);
}
} }
+5 -9
View File
@@ -84,15 +84,11 @@ try {
renderTheme('light'), renderTheme('light'),
renderTheme('dark'), renderTheme('dark'),
]); ]);
const selectedButton = (html) => assert.match(light, /value="light"[^>]*checked/);
html.match( assert.match(dark, /value="dark"[^>]*checked/);
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/, assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
)[1]; assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
assert.match(selectedButton(light), />Light</); assert.equal((dark.match(/ checked/g) ?? []).length, 1);
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.equal(normalizeAppearance('unexpected'), 'system'); assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8'); const blade = await readFile('resources/views/app.blade.php', 'utf8');
+242
View File
@@ -0,0 +1,242 @@
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:4186';
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));
const dialog = () => page.getByRole('dialog');
const button = (name) => dialog().getByRole('button', { name, exact: true });
const state = (value) =>
dialog()
.locator(`[data-form-state="${value}"]`)
.waitFor({ state: 'visible' });
const setResult = (value) =>
context.addCookies([{ name: 'git_test_result', value, url: base }]);
const add = () =>
page.getByRole('button', { name: 'Add connection', exact: true }).click();
const edit = () =>
page
.getByRole('button', {
name: 'Edit alex at https://git.example.test',
exact: true,
})
.click();
const visit = async (query = '?reset') => {
await page.goto(`${base}/settings/git-connections${query}`);
await page
.getByRole('heading', { name: 'Git connections', exact: true })
.waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const fill = async (token = 'test-secret') => {
await dialog()
.getByLabel('Gitea base URL', { exact: true })
.fill('https://git.example.test');
await page.locator('#git-token').fill(token);
};
const closed = () => dialog().waitFor({ state: 'hidden' });
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 visit();
await noOverflow();
assert.equal(
await page
.locator('[data-slot="card"]')
.first()
.evaluate(
(element) =>
element.getBoundingClientRect().right <=
window.innerWidth,
),
true,
`Connection list clips at ${width}px in ${theme}`,
);
if (width === 390) {
await page.screenshot({
path: `/private/tmp/KAN-38-${theme}-list.png`,
fullPage: true,
});
}
await add();
await dialog().waitFor();
await noOverflow();
assert.equal(
await page.locator('#git-token').getAttribute('type'),
'password',
);
assert.equal(
await page.locator('#git-token').getAttribute('autocomplete'),
'new-password',
);
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((element) =>
element.contains(document.activeElement),
),
true,
);
await dialog().evaluate((element) =>
Promise.all(
element
.getAnimations()
.map((animation) => animation.finished),
),
);
if (width === 390) {
await page.screenshot({
path: `/private/tmp/KAN-38-${theme}-dialog.png`,
fullPage: true,
});
}
await page.keyboard.press('Escape');
await closed();
assert.equal(
await page
.getByRole('button', {
name: 'Add connection',
exact: true,
})
.evaluate((element) => document.activeElement === element),
true,
);
await edit();
assert.equal(
await page.locator('#git-base-url').inputValue(),
'https://git.example.test',
);
assert.equal(await page.locator('#git-token').inputValue(), '');
await noOverflow();
await button('Cancel').click();
await closed();
}
}
await visit('?empty');
await page.getByText('No Git connections yet', { exact: true }).waitFor();
await add();
await fill();
await setResult('validation');
await button('Add connection').click();
await state('validation');
assert.equal(
await page.locator('#git-token').getAttribute('aria-invalid'),
'true',
);
assert.equal(await page.locator('#git-token').inputValue(), 'test-secret');
await page
.getByText(
'Gitea could not authenticate with this base URL and token.',
{ exact: true },
)
.waitFor();
assert.doesNotMatch(
await page.evaluate(() => JSON.stringify(history.state)),
/test-secret/,
);
await page.route('**/settings/git-connections', (route) =>
route.abort('failed'),
);
await button('Add connection').click();
await state('network');
await page.unroute('**/settings/git-connections');
await setResult('server');
await button('Try again').click();
await state('server');
assert.equal(await page.locator('#git-token').inputValue(), 'test-secret');
await setResult('success');
const submitted = page.waitForRequest(
(request) =>
request.method() === 'POST' &&
request.url().endsWith('/settings/git-connections'),
);
await button('Try again').click();
assert.deepEqual((await submitted).postDataJSON(), {
provider: 'gitea',
base_url: 'https://git.example.test',
api_token: 'test-secret',
});
await closed();
await page.getByText('Git connection added.', { exact: true }).waitFor();
await add();
assert.equal(await page.locator('#git-token').inputValue(), '');
await button('Cancel').click();
await closed();
await visit();
await edit();
await page.locator('#git-token').fill('updated-secret');
const patch = page.waitForRequest(
(request) => request.method() === 'PATCH',
);
await button('Save changes').click();
assert.equal((await patch).postDataJSON().api_token, 'updated-secret');
await closed();
await page.getByText('Git connection updated.', { exact: true }).waitFor();
await edit();
assert.equal(await page.locator('#git-token').inputValue(), '');
await page.locator('#git-token').fill('discard-me');
page.once('dialog', (confirmation) => confirmation.dismiss());
await button('Cancel').click();
assert.equal(await dialog().isVisible(), true);
page.once('dialog', (confirmation) => confirmation.accept());
await button('Cancel').click();
await closed();
await edit();
assert.equal(await page.locator('#git-token').inputValue(), '');
await button('Cancel').click();
await closed();
await page
.getByRole('button', {
name: 'Remove alex at https://git.example.test',
exact: true,
})
.click();
await setResult('forbidden');
await button('Remove connection').click();
await state('server');
await page.getByText('Connection access denied', { exact: true }).waitFor();
assert.equal(await button('Try again').count(), 0);
await button('Cancel').click();
await closed();
await page
.getByRole('button', {
name: 'Remove alex at https://git.example.test',
exact: true,
})
.click();
await setResult('success');
const deletion = page.waitForRequest(
(request) => request.method() === 'DELETE',
);
await button('Remove connection').click();
assert.equal(
new URL((await deletion).url()).pathname,
'/settings/git-connections/1',
);
await closed();
await page.getByText('No Git connections yet', { exact: true }).waitFor();
assert.deepEqual(errors, []);
console.log(
'Git connections browser checks passed: create, edit, remove, token privacy, provider errors, retries, permissions, discard, keyboard and both themes at 320/390/1440px.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,16 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import GitConnections from '../../resources/js/pages/settings/GitConnections.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: () => GitConnections,
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+210
View File
@@ -0,0 +1,210 @@
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/git-connections-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const connection = {
id: 1,
user_id: 1,
provider: 'gitea',
provider_label: 'Gitea',
base_url: 'https://git.example.test',
provider_user_id: '42',
account_login: 'alex',
account_name: 'Alex Morgan',
created_at: null,
updated_at: null,
};
function makePage(extra = {}) {
return {
component: 'GitConnections',
url: '/settings/git-connections',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
status: null,
gitConnections: [connection],
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) {
let connections = [connection];
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/settings')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
const mutation = request.method !== 'GET';
const result =
request.headers.cookie?.match(/git_test_result=(\w+)/)?.[1] ??
'success';
let extra = {};
if (!mutation && url.searchParams.has('reset')) {
connections = [connection];
}
if (mutation && ['server', 'forbidden'].includes(result)) {
response.statusCode = result === 'forbidden' ? 403 : 503;
response.end('Simulated request failure');
return;
}
if (mutation) {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = body ? JSON.parse(body) : {};
if (result === 'validation') {
extra.errors = {
api_token:
'Gitea could not authenticate with this base URL and token.',
};
} else if (request.method === 'DELETE') {
connections = connections.filter(
(item) =>
item.id !== Number(url.pathname.split('/').at(-1)),
);
extra.status = 'alex @ https://git.example.test was removed.';
} else if (request.method === 'PATCH') {
connections = connections.map((item) =>
item.id === Number(url.pathname.split('/').at(-1))
? { ...item, base_url: data.base_url }
: item,
);
extra.status = 'Git connection was updated.';
} else {
connections.push({
...connection,
id: 2,
account_login: 'new-account',
base_url: data.base_url,
});
extra.status = 'Git connection was added.';
}
}
const page = makePage({
gitConnections: url.searchParams.has('empty') ? [] : connections,
...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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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/git-connections-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Git connections 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 { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule(
'/resources/js/pages/settings/GitConnections.vue',
)
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /Gitea/);
assert.match(html, /https:\/\/git.example.test/);
assert.match(html, /Edit alex at https:\/\/git.example.test/);
assert.doesNotMatch(html, /name="api_token"/);
assert.match(
await render({ gitConnections: [] }),
/No Git connections yet/,
);
assert.match(
await render(
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'gitConnections.add': 'Verbindung hinzufügen',
},
},
),
),
/Verbindung hinzufügen/,
);
console.log('Git connections rendering checks passed.');
} finally {
await server.close();
}
}
@@ -0,0 +1,263 @@
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:4188';
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));
const requests = [];
page.on('request', (request) => {
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
requests.push(request);
}
});
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const result = (value) =>
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
const visit = async (query = '?reset') => {
await result('success');
await page.goto(base + '/projects/1/settings/board' + query);
await page
.getByRole('heading', { name: 'Board workflow', exact: true })
.waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const submit = async (label, method, pathname, expected) => {
const sent = page.waitForRequest((request) => request.method() === method);
await dialog().getByRole('button', { name: label, exact: true }).click();
const request = await sent;
assert.equal(new URL(request.url()).pathname, pathname);
assert.deepEqual(request.postDataJSON(), expected);
};
const closed = () => dialog().waitFor({ state: 'hidden' });
const formState = (state) =>
dialog()
.locator('[data-form-state="' + state + '"]')
.waitFor({ state: 'visible' });
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
await noOverflow();
assert.equal(
await button('Delete status Backlog').isDisabled(),
true,
);
assert.equal(
await button('Delete status Ready').isDisabled(),
true,
);
assert.equal(
await button('Delete status Review').isDisabled(),
false,
);
const trigger = button('Edit column In progress');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
assert.equal(
await page
.getByLabel('Column name', { exact: true })
.inputValue(),
'In progress',
);
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((element) =>
element.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.keyboard.press('Escape');
await closed();
assert.equal(
await trigger.evaluate(
(element) => document.activeElement === element,
),
true,
);
if ([390, 1440].includes(width)) {
await page.screenshot({
path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png',
fullPage: true,
});
}
assert.equal(requests.length, 0);
}
}
await visit('?reset&empty');
await page.getByText('No board columns yet', { exact: true }).waitFor();
await visit();
await button('Add column').click();
await page
.getByLabel('Column name', { exact: true })
.fill('Quality assurance');
await result('validation');
await submit('Add column', 'POST', '/projects/1/settings/board/columns', {
name: 'Quality assurance',
});
await formState('validation');
assert.equal(
await page.getByLabel('Column name', { exact: true }).inputValue(),
'Quality assurance',
);
assert.equal(
await page
.getByLabel('Column name', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await result('server');
await dialog()
.getByRole('button', { name: 'Add column', exact: true })
.click();
await formState('server');
await result('success');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await closed();
await button('Edit column Quality assurance').waitFor();
const newColumnId = await page
.locator('[data-column-id]')
.last()
.getAttribute('data-column-id');
await button('Edit column Quality assurance').click();
await page.getByLabel('Column name', { exact: true }).fill('QA');
await page.getByLabel('Position', { exact: true }).fill('1');
await result('position');
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
await formState('validation');
assert.equal(
await page
.getByLabel('Position', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await result('success');
await submit(
'Save',
'PATCH',
'/projects/1/settings/board/columns/' + newColumnId,
{ name: 'QA', position: 1 },
);
await closed();
assert.equal(
await page
.locator('[data-column-id]')
.first()
.getAttribute('data-column-id'),
newColumnId,
);
await button('Edit column QA').click();
assert.equal(
await page.getByLabel('Position', { exact: true }).inputValue(),
'1',
);
assert.equal(
await page.getByLabel('Column name', { exact: true }).inputValue(),
'QA',
);
await page.getByLabel('Column name', { exact: true }).fill('Unsaved');
page.once('dialog', (prompt) => prompt.dismiss());
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
assert.equal(await dialog().isVisible(), true);
page.once('dialog', (prompt) => prompt.accept());
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
await button('Add status to Backlog').click();
assert.equal(
await page.getByLabel('Status name', { exact: true }).inputValue(),
'',
);
await page.getByLabel('Status name', { exact: true }).fill('Triage');
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
name: 'Triage',
board_column_id: null,
});
await closed();
await button('Rename status Triage').waitFor();
await button('Add status to Todo').click();
await page.getByLabel('Status name', { exact: true }).fill('Refinement');
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
name: 'Refinement',
board_column_id: 10,
});
await closed();
await button('Rename status Review').click();
await page.getByLabel('Status name', { exact: true }).fill('Peer review');
await page.route('**/statuses/4', (route) => route.abort('failed'));
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
await formState('network');
await page.unroute('**/statuses/4');
await submit(
'Try again',
'PATCH',
'/projects/1/settings/board/statuses/4',
{ name: 'Peer review' },
);
await closed();
const before = requests.length;
await button('Delete status Peer review').click();
await dialog()
.getByText(/assigned to “Building”/)
.waitFor();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
assert.equal(requests.length, before);
await button('Delete status Peer review').click();
await result('validation');
await submit(
'Delete',
'DELETE',
'/projects/1/settings/board/statuses/4',
{},
);
await formState('validation');
await dialog()
.getByText('This group needs at least one status.', { exact: true })
.waitFor();
await result('success');
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
await closed();
assert.equal(await button('Delete status Building').isDisabled(), true);
await button('Delete column In progress').click();
await dialog()
.getByText(/statuses move to the backlog/)
.waitFor();
await submit(
'Delete',
'DELETE',
'/projects/1/settings/board/columns/20',
{},
);
await closed();
await page
.getByRole('list', { name: 'Statuses in Backlog', exact: true })
.getByText('Building', { exact: true })
.waitFor();
assert.equal(await button('Edit column In progress').count(), 0);
assert.deepEqual(errors, []);
console.log(
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Board from '../../resources/js/pages/projects/settings/Board.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Board'
? Board
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+354
View File
@@ -0,0 +1,354 @@
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/project-board-settings-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4188, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const makeStatus = (id, name, column, position = 1) => ({
id,
name,
board_column_id: column,
position,
allowed_target_statuses: [],
});
function initial() {
const backlog = makeStatus(1, 'Backlog', null);
const ready = makeStatus(2, 'Ready', 10);
const active = makeStatus(3, 'Building', 20);
const review = makeStatus(4, 'Review', 20, 2);
backlog.allowed_target_statuses = [ready];
const columns = [
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
{
id: 20,
name: 'In progress',
position: 2,
ticket_statuses: [active, review],
},
];
return {
project: { id: 1, key: 'CORE', name: 'Core platform' },
status: null,
templateState: null,
backlogTicketStatuses: [backlog],
boardColumns: columns,
ticketStatuses: [
backlog,
...columns.flatMap((column) =>
column.ticket_statuses.map((status) => ({
...status,
board_column: { id: column.id, name: column.name },
})),
),
],
};
}
function makePage(
extra = {},
url = '/projects/1/settings/board',
component = 'Board',
) {
return {
component,
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
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: {
id: 1,
name: 'Core platform',
key: 'CORE',
canManage: true,
},
},
...initial(),
...extra,
},
};
}
if (serve) {
let data = initial();
let nextId = 100;
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/projects')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
const mutation = request.method !== 'GET';
if (!mutation && url.searchParams.has('reset')) {
data = initial();
}
let errors = {};
if (mutation) {
const result =
request.headers.cookie?.match(
/workflow_test_result=(\w+)/,
)?.[1] ?? 'success';
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
if (['server', 'forbidden'].includes(result)) {
response.statusCode = result === 'server' ? 503 : 403;
response.end('Simulated request failure');
return;
}
if (result === 'validation') {
errors = url.pathname.endsWith('/transitions')
? {
'allowed_ticket_status_ids.0':
'Choose a destination from this project.',
}
: request.method === 'DELETE'
? {
ticket_status_id:
'This group needs at least one status.',
}
: { name: 'Choose a different name.' };
} else if (result === 'position') {
errors = { position: 'The position must be at least 1.' };
} else {
const segments = url.pathname.split('/');
const id = Number(segments.at(-1));
const columns = data.boardColumns;
if (url.pathname.includes('/columns')) {
if (request.method === 'POST') {
const column = {
id: nextId++,
name: submitted.name,
position: columns.length + 1,
ticket_statuses: [],
};
column.ticket_statuses.push(
makeStatus(nextId++, submitted.name, column.id),
);
columns.push(column);
} else if (request.method === 'PATCH') {
const column = columns.find((item) => item.id === id);
column.name = submitted.name;
columns.splice(columns.indexOf(column), 1);
columns.splice(
Math.max(
0,
Math.min(
Number(submitted.position) - 1,
columns.length,
),
),
0,
column,
);
} else {
const column = columns.find((item) => item.id === id);
for (const status of column.ticket_statuses) {
status.board_column_id = null;
status.position =
data.backlogTicketStatuses.length + 1;
data.backlogTicketStatuses.push(status);
}
columns.splice(columns.indexOf(column), 1);
}
columns.forEach((column, index) => {
column.position = index + 1;
});
} else if (url.pathname.endsWith('/transitions')) {
const sourceId = Number(segments.at(-2));
const statuses = [
...data.backlogTicketStatuses,
...columns.flatMap((column) => column.ticket_statuses),
];
statuses.find(
(status) => status.id === sourceId,
).allowed_target_statuses = (
submitted.allowed_ticket_status_ids ?? []
).map((id) => ({ id: Number(id) }));
} else if (request.method === 'POST') {
const group =
submitted.board_column_id === null
? data.backlogTicketStatuses
: columns.find(
(item) =>
item.id === submitted.board_column_id,
).ticket_statuses;
group.push(
makeStatus(
nextId++,
submitted.name,
submitted.board_column_id,
group.length + 1,
),
);
} else {
const group = [
data.backlogTicketStatuses,
...columns.map((column) => column.ticket_statuses),
].find((items) => items.some((item) => item.id === id));
const status = group.find((item) => item.id === id);
if (request.method === 'PATCH') {
status.name = submitted.name;
} else {
group.splice(group.indexOf(status), 1);
}
}
data.ticketStatuses = [
...data.backlogTicketStatuses,
...columns.flatMap((column) =>
column.ticket_statuses.map((status) => ({
...status,
board_column: { id: column.id, name: column.name },
})),
),
];
data.status = 'Updated';
}
}
if (url.searchParams.has('empty')) {
data.boardColumns = [];
data.ticketStatuses = data.backlogTicketStatuses;
}
const page = makePage({ ...data, errors });
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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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/project-board-settings-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Project board settings 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 { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule(
'/resources/js/pages/projects/settings/Board.vue',
)
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /Board workflow/);
assert.match(html, /Off the board/);
assert.match(html, /aria-current="page"/);
assert.ok(
html.indexOf('data-column-id="10"') <
html.indexOf('data-column-id="20"'),
);
assert.match(html, /From status/);
assert.match(html, /Saved destinations: 1/);
assert.match(
await render({ ticketStatuses: [] }),
/Add a status to configure/,
);
assert.match(
await render({ ticketStatuses: [makeStatus(1, 'Backlog', null)] }),
/No other statuses exist yet/,
);
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
for (const name of ['Backlog', 'Ready']) {
assert.match(
buttons.find((button) =>
button.includes('aria-label="Delete status ' + name + '"'),
),
/aria-disabled="true"/,
);
}
assert.doesNotMatch(
buttons.find((button) =>
button.includes('aria-label="Delete status Review"'),
),
/aria-disabled="true"/,
);
assert.match(
await render({ boardColumns: [] }),
/No board columns yet/,
);
assert.match(
await render(
{},
createLocalization(
{ locale: 'de' },
{ de: { ...en, 'workflow.title': 'Board-Ablauf' } },
),
),
/Board-Ablauf/,
);
console.log('Project board settings rendering checks passed.');
} finally {
await server.close();
}
}
@@ -0,0 +1,263 @@
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:4187';
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));
const mutations = [];
page.on('request', (request) => {
if (['POST', 'DELETE'].includes(request.method())) {
mutations.push(request);
}
});
const dialog = () => page.getByRole('dialog');
const button = (name) => page.getByRole('button', { name, exact: true });
const confirmReset = () =>
dialog().getByRole('button', { name: 'Reset to template', exact: true });
const result = (value) =>
context.addCookies([{ name: 'general_test_result', value, url: base }]);
const visit = async (query = '?reset') => {
await result('success');
await page.goto(base + '/projects/1/settings/general' + query);
await page.getByRole('heading', { name: 'General', exact: true }).waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const formState = (form, state) =>
form
.locator('[data-form-state="' + state + '"]')
.waitFor({ state: 'visible' });
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 visit();
await noOverflow();
const nav = page.getByRole('navigation', {
name: 'Project settings',
exact: true,
});
assert.equal(
await nav
.getByRole('link', { name: 'General', exact: true })
.getAttribute('aria-current'),
'page',
);
assert.equal(
await nav
.getByRole('link', { name: 'Ticket settings', exact: true })
.getAttribute('href'),
'/projects/1/settings/metadata',
);
const sources = page.getByRole('list', { name: 'Setting sources' });
await sources.getByText('Inherited', { exact: true }).waitFor();
await sources
.getByText('Local override', { exact: true })
.first()
.waitFor();
assert.equal(await button('Switch template').isDisabled(), true);
assert.equal(await button('Delete project').isDisabled(), true);
if (width === 390 || width === 1440) {
await page.screenshot({
path: '/private/tmp/KAN-39-' + width + '-' + theme + '.png',
fullPage: true,
});
}
const trigger = button('Reset Project settings to template');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((element) =>
element.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.keyboard.press('Escape');
await dialog().waitFor({ state: 'hidden' });
assert.equal(
await trigger.evaluate(
(element) => document.activeElement === element,
),
true,
);
assert.equal(
mutations.length,
0,
'Opening and cancelling reset must not send a mutation',
);
}
}
await visit('?reset&noTemplate');
await page.getByText('No linked template', { exact: true }).waitFor();
assert.equal(await button('Reset Project settings to template').count(), 0);
await visit('?reset&baseline');
assert.equal(
await page.getByText('Local override', { exact: true }).count(),
0,
);
await visit('?reset&noAlternatives');
await page
.getByText('No other published templates', { exact: true })
.waitFor();
assert.equal(await button('Switch template').count(), 0);
await visit();
await button('Reset Ticket statuses to template').click();
await dialog().waitFor();
assert.equal(await confirmReset().isDisabled(), true);
const replacement = page.locator('#replacement-10');
await replacement.selectOption('1');
await result('validation');
const resetRequest = page.waitForRequest(
(request) => request.method() === 'POST',
);
await confirmReset().click();
assert.deepEqual((await resetRequest).postDataJSON(), {
areas: ['ticket_statuses'],
status_replacements: { 10: 1 },
});
await formState(dialog(), 'validation');
await page
.getByText('This area could not be reset.', { exact: true })
.waitFor();
assert.equal(await replacement.inputValue(), '1');
await result('server');
await confirmReset().click();
await formState(dialog(), 'server');
await result('success');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await dialog().waitFor({ state: 'hidden' });
const sources = page.getByRole('list', { name: 'Setting sources' });
assert.equal(
await sources.getByText('Local override', { exact: true }).count(),
1,
'Resetting statuses preserves project settings override',
);
assert.equal(await button('Reset Ticket statuses to template').count(), 0);
await button('Reset Project settings to template').click();
await confirmReset().click();
await dialog().waitFor({ state: 'hidden' });
assert.equal(
await sources.getByText('Local override', { exact: true }).count(),
0,
);
assert.equal(await page.getByText('Enabled', { exact: true }).count(), 2);
await visit();
const switchForm = page
.locator('form')
.filter({ has: page.locator('#source_template_id') });
const select = page.locator('#source_template_id');
assert.equal(
await select.locator('option[value="1"]').count(),
0,
'Current template must not be selectable',
);
await select.selectOption('2');
await page.locator('#switch_confirmation_key').fill('core');
assert.equal(await button('Switch template').isDisabled(), true);
const countBefore = mutations.length;
await switchForm.evaluate((element) =>
element.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(
mutations.length,
countBefore,
'Submit guard must reject a mismatching key',
);
await page.locator('#switch_confirmation_key').fill('CORE');
await result('validation');
await button('Switch template').click();
await formState(switchForm, 'validation');
assert.equal(
await page.locator('#switch_confirmation_key').inputValue(),
'CORE',
);
assert.equal(
await page
.locator('#switch_confirmation_key')
.getAttribute('aria-invalid'),
'true',
);
await page.route('**/switch-template', (route) => route.abort('failed'));
await button('Switch template').click();
await formState(switchForm, 'network');
await page.unroute('**/switch-template');
await result('success');
const switched = page.waitForRequest(
(request) => request.method() === 'POST',
);
await switchForm
.getByRole('button', { name: 'Try again', exact: true })
.click();
assert.deepEqual((await switched).postDataJSON(), {
confirmation_key: 'CORE',
source_template_id: 2,
});
await page.getByText('Delivery baseline', { exact: true }).waitFor();
assert.equal(
await page.locator('#switch_confirmation_key').inputValue(),
'',
);
assert.equal(await button('Switch template').isDisabled(), true);
await visit();
const deleteForm = page
.locator('form')
.filter({ has: page.locator('#confirmation_key') });
await page.locator('#confirmation_key').fill('core');
assert.equal(await button('Delete project').isDisabled(), true);
const beforeDelete = mutations.length;
await deleteForm.evaluate((element) =>
element.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(mutations.length, beforeDelete);
await page.locator('#confirmation_key').fill('CORE');
await result('validation');
await button('Delete project').click();
await formState(deleteForm, 'validation');
assert.equal(
await page.locator('#confirmation_key').getAttribute('aria-invalid'),
'true',
);
assert.equal(await page.locator('#confirmation_key').inputValue(), 'CORE');
await result('success');
const deleted = page.waitForRequest(
(request) => request.method() === 'DELETE',
);
await button('Delete project').click();
const deletion = await deleted;
assert.equal(new URL(deletion.url()).pathname, '/projects/1');
assert.deepEqual(deletion.postDataJSON(), { confirmation_key: 'CORE' });
await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
assert.deepEqual(errors, []);
console.log(
'Project general settings browser checks passed: setting sources, scoped resets, confirmations, replacements, errors, retries, keyboard, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import General from '../../resources/js/pages/projects/settings/General.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'General'
? General
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+307
View File
@@ -0,0 +1,307 @@
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/project-general-settings-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4187, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const area = (value, label) => ({ value, label });
const template = {
id: 1,
name: 'Team baseline',
is_published: true,
copy_areas: [],
};
const alternative = { ...template, id: 2, name: 'Delivery baseline' };
function initial() {
return {
project: {
id: 1,
key: 'CORE',
name: 'Core platform',
project_template_id: 1,
project_template: template,
show_author: false,
show_story_points: false,
},
sourceTemplates: [template, alternative],
status: null,
templateState: {
templateId: 1,
templateName: 'Team baseline',
inheritsFromTemplate: true,
inheritedAreas: [area('board_columns', 'Board columns')],
overriddenAreas: [
area('project_settings', 'Project settings'),
area('ticket_statuses', 'Ticket statuses'),
],
effectiveConfiguration: {},
resetRequirements: {
ticket_statuses: {
removedStatuses: [
{ id: 10, name: 'Local review', ticketCount: 3 },
],
replacementOptions: [{ id: 1, name: 'Ready' }],
},
},
},
};
}
function makePage(
extra = {},
url = '/projects/1/settings/general',
component = 'General',
) {
return {
component,
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
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: {
id: 1,
name: 'Core platform',
key: 'CORE',
canManage: true,
},
},
...initial(),
...extra,
},
};
}
if (serve) {
let data = initial();
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/projects')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
const mutation = request.method !== 'GET';
const result =
request.headers.cookie?.match(/general_test_result=(\w+)/)?.[1] ??
'success';
if (!mutation && url.searchParams.has('reset')) {
data = initial();
}
let extra = {};
let component = 'General';
let pageUrl = '/projects/1/settings/general';
if (mutation) {
if (['server', 'forbidden'].includes(result)) {
response.statusCode = result === 'forbidden' ? 403 : 503;
response.end('Simulated request failure');
return;
}
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
if (result === 'validation') {
extra.errors = url.pathname.endsWith('reset-template')
? { 'areas.0': 'This area could not be reset.' }
: { confirmation_key: 'Enter the exact project key.' };
} else if (request.method === 'DELETE') {
component = 'Destination';
pageUrl = '/projects';
} else if (url.pathname.endsWith('reset-template')) {
const reset = data.templateState.overriddenAreas.find(
(item) => item.value === submitted.areas[0],
);
data.templateState.overriddenAreas =
data.templateState.overriddenAreas.filter(
(item) => item.value !== reset.value,
);
data.templateState.inheritedAreas.push(reset);
if (reset.value === 'project_settings') {
data.project.show_author = true;
data.project.show_story_points = true;
}
data.status = 'Project template baseline was reset.';
} else {
data.project.project_template = alternative;
data.project.project_template_id = 2;
data.templateState.templateName = alternative.name;
data.templateState.templateId = 2;
data.status = 'Project template baseline was switched.';
}
}
if (url.searchParams.has('noTemplate')) {
extra = {
project: {
...data.project,
project_template: null,
project_template_id: null,
},
templateState: null,
};
}
if (url.searchParams.has('baseline')) {
extra.templateState = {
...data.templateState,
inheritsFromTemplate: false,
};
}
if (url.searchParams.has('noAlternatives')) {
extra.sourceTemplates = [template];
}
const page = makePage({ ...data, ...extra }, pageUrl, component);
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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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/project-general-settings-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Project general settings 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 { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule(
'/resources/js/pages/projects/settings/General.vue',
)
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /Local override/);
assert.match(html, /Inherited/);
assert.match(html, /Team baseline/);
assert.match(html, /Show author/);
assert.match(html, /href="\/projects\/1\/settings\/metadata"/);
assert.match(html, /aria-current="page"/);
assert.match(html, /Reset Project settings to template/);
assert.match(html, /Type CORE exactly/);
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
for (const label of ['Switch template', 'Delete project']) {
const button = buttons.find((item) => item.includes(label));
assert.match(button, /disabled/);
assert.match(button, /type="submit"/);
}
const standalone = await render({
templateState: null,
project: { ...initial().project, project_template: null },
});
assert.match(standalone, /No linked template/);
assert.doesNotMatch(standalone, /Reset Project settings to template/);
assert.doesNotMatch(
await render({
templateState: {
...initial().templateState,
inheritsFromTemplate: false,
},
}),
/Local override/,
);
assert.match(
await render({ sourceTemplates: [template] }),
/No other published templates/,
);
const inherited = await render({
templateState: {
...initial().templateState,
inheritedAreas: [area('project_settings', 'Project settings')],
overriddenAreas: [],
},
});
assert.doesNotMatch(
inherited,
/Local override|Reset Project settings to template/,
);
assert.match(
await render(
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'projectSettings.danger': 'Gefahrenbereich',
'inheritance.area.projectSettings':
'Projekteinstellungen',
},
},
),
),
/Gefahrenbereich/,
);
console.log('Project general settings rendering checks passed.');
} finally {
await server.close();
}
}
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Metadata from '../../resources/js/pages/projects/settings/Metadata.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Metadata'
? Metadata
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
@@ -0,0 +1,281 @@
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/project-metadata-settings-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4189, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
function initial() {
return {
project: {
id: 1,
key: 'CORE',
name: 'Core platform',
show_author: true,
show_story_points: false,
},
status: null,
error: false,
templateState: null,
ticketTypes: [
{
id: 1,
name: 'Task',
position: 1,
is_parent: false,
tickets_count: 2,
},
{
id: 2,
name: 'Release',
position: 2,
is_parent: true,
tickets_count: 1,
},
],
ticketPriorities: [
{ id: 3, name: 'Normal', position: 1 },
{ id: 4, name: 'Urgent', position: 2 },
],
ticketFieldDefinitions: [],
ticketLinkTypes: [],
fieldTypeOptions: [],
};
}
function makePage(
extra = {},
url = '/projects/1/settings/metadata',
component = 'Metadata',
) {
return {
component,
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
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: {
id: 1,
name: 'Core platform',
key: 'CORE',
canManage: true,
},
},
...initial(),
...extra,
},
};
}
if (serve) {
let data = initial();
let nextId = 100;
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/projects')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
const mutation = request.method !== 'GET';
if (!mutation && url.searchParams.has('reset')) {
data = initial();
}
let errors = {};
if (mutation) {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
const result =
request.headers.cookie?.match(
/metadata_test_result=(\w+)/,
)?.[1] ?? 'success';
if (result === 'server') {
response.statusCode = 500;
response.end('Unavailable');
return;
}
const kind = url.pathname.includes('/types') ? 'type' : 'priority';
const entries =
kind === 'type' ? data.ticketTypes : data.ticketPriorities;
const id = Number(url.pathname.split('/').at(-1));
if (
result === 'validation' ||
(submitted.name &&
entries.some(
(entry) =>
entry.name === submitted.name && entry.id !== id,
))
) {
errors =
request.method === 'DELETE'
? {
replacement_ticket_type_id:
'Choose a parent-enabled replacement type.',
}
: { name: 'This name is already in use.' };
} else if (request.method === 'DELETE') {
entries.splice(
entries.findIndex((item) => item.id === id),
1,
);
} else if (url.pathname.endsWith('/metadata')) {
Object.assign(data.project, submitted);
} else if (request.method === 'POST') {
entries.push({
id: nextId++,
position: entries.length + 1,
tickets_count: 0,
...submitted,
});
} else {
const item = entries.find((item) => item.id === id);
entries.splice(entries.indexOf(item), 1);
Object.assign(item, submitted);
entries.splice(submitted.position - 1, 0, item);
}
entries.forEach((entry, index) => (entry.position = index + 1));
data.status = Object.keys(errors).length ? null : 'Updated';
}
if (url.searchParams.has('single')) {
data.ticketTypes = data.ticketTypes.slice(0, 1);
data.ticketPriorities = data.ticketPriorities.slice(0, 1);
}
const page = makePage({ ...data, errors });
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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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/project-metadata-settings-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Metadata settings 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 { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule(
'/resources/js/pages/projects/settings/Metadata.vue',
)
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /Ticket settings/);
assert.match(html, /aria-current="page"/);
assert.ok(
html.indexOf('data-metadata-entry="type-1"') <
html.indexOf('data-metadata-entry="type-2"'),
);
assert.match(html, /Assigned tickets: 2/);
assert.match(html, /Parent type/);
assert.match(html, /id="show_author"[^>]*aria-checked="true"/);
assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/);
const single = await render({
ticketTypes: initial().ticketTypes.slice(0, 1),
ticketPriorities: initial().ticketPriorities.slice(0, 1),
});
const buttons = single.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
for (const name of ['Task', 'Normal']) {
assert.match(
buttons.find((button) =>
button.includes('aria-label="Delete ' + name + '"'),
),
/aria-disabled="true"/,
);
}
assert.match(single, /The last ticket type cannot be deleted/);
assert.match(single, /The last priority cannot be deleted/);
assert.match(
await render(
{},
createLocalization(
{ locale: 'de' },
{ de: { ...en, 'metadata.title': 'Ticket-Einstellungen' } },
),
),
/Ticket-Einstellungen/,
);
assert.match(
await render({
status: 'The last ticket type cannot be deleted.',
error: true,
}),
/role="alert"/,
);
console.log('Metadata settings checks passed.');
} finally {
await server.close();
}
}
+316
View File
@@ -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();
}
+17
View File
@@ -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),
});
+238
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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();
}
}
+146
View File
@@ -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();
}
+17
View File
@@ -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),
});
+207
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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();
}
}
+139
View File
@@ -101,6 +101,102 @@ try {
} }
await page.setViewportSize({ width: 390, height: 1000 }); await page.setViewportSize({ width: 390, height: 1000 });
await visit(); 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 tabTo(edit());
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft'); await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
@@ -246,6 +342,49 @@ try {
.waitFor(); .waitFor();
await page.getByRole('button', { name: 'Create now', exact: true }).click(); await page.getByRole('button', { name: 'Create now', exact: true }).click();
await dialog.waitFor({ state: 'hidden' }); 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( const linkSaved = page.waitForResponse(
(response) => (response) =>
response.url().includes('/tickets/42/links') && response.url().includes('/tickets/42/links') &&
+46 -8
View File
@@ -42,6 +42,7 @@ const repository = {
provider_label: 'Gitea', provider_label: 'Gitea',
base_url: 'https://git.example.test', base_url: 'https://git.example.test',
repository_full_name: 'Engineering/platform', repository_full_name: 'Engineering/platform',
repository_url: 'https://git.example.test/Engineering/platform',
has_personal_access: true, has_personal_access: true,
}; };
const parent = { const parent = {
@@ -108,9 +109,18 @@ const ticket = {
}, },
], ],
worklog_entries: [ 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, id: 1,
actor: user, actor: user,
actor_id: user.id,
entry_type: 'created', entry_type: 'created',
summary: 'Ticket created in the project backlog.', summary: 'Ticket created in the project backlog.',
created_at: '2026-09-11T12:00:00Z', created_at: '2026-09-11T12:00:00Z',
@@ -345,6 +355,9 @@ if (serve) {
const data = body ? JSON.parse(body) : {}; const data = body ? JSON.parse(body) : {};
const previous = new URL(request.headers.referer); const previous = new URL(request.headers.referer);
const params = previous.searchParams; const params = previous.searchParams;
if (request.url.includes('/branches') && params.has('slowBranch')) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (params.has('serverError')) { if (params.has('serverError')) {
response.writeHead(503); response.writeHead(503);
response.end('Simulated failure'); response.end('Simulated failure');
@@ -395,7 +408,19 @@ if (serve) {
if (params.has('reject')) { if (params.has('reject')) {
params.set('rejected', '1'); params.set('rejected', '1');
} else { } 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); params.set('saved', data.title);
} }
} }
@@ -476,6 +501,9 @@ if (serve) {
'feature/ENG-42-cache', 'feature/ENG-42-cache',
'Cache implementation', 'Cache implementation',
'Ticket created in the project backlog.', 'Ticket created in the project backlog.',
'Ticket created',
'Status changed',
'System',
]) { ]) {
assert.ok( assert.ok(
html.includes(text), html.includes(text),
@@ -486,6 +514,7 @@ if (serve) {
'description', 'description',
'checklist', 'checklist',
'metadata', 'metadata',
'blocker',
'relationships', 'relationships',
'history', 'history',
'git', 'git',
@@ -525,6 +554,14 @@ if (serve) {
assert.match(html, /aria-label="Criterion 1"/); assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/); assert.match(html, /Add criterion/);
assert.match(html, /Create branch/); 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, /Save link/);
assert.match(html, /Outgoing/); assert.match(html, /Outgoing/);
const incoming = await render('?incoming'); const incoming = await render('?incoming');
@@ -554,8 +591,7 @@ if (serve) {
'Unassigned', 'Unassigned',
'No author', 'No author',
'Not set', 'Not set',
'No matching branches', 'No linked repositories',
'No matching pull requests',
'No worklog entries exist', 'No worklog entries exist',
]) { ]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`); assert.ok(empty.includes(text), `Missing empty state: ${text}`);
@@ -565,11 +601,13 @@ if (serve) {
assert.match(hierarchy, /Platform delivery/); assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/); assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/); assert.match(hierarchy, /Change parent/);
assert.match(await render('?noAccess'), /Open Git connections/); const noAccess = await render('?noAccess');
assert.match( assert.match(noAccess, /Personal Git access required/);
await render('?gitError'), assert.match(noAccess, /Open Git connections/);
/Repository temporarily unavailable/, 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); const create = await render('?source=board', true);
assert.match(create, /Create ticket/); assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/); assert.match(create, /Delivery \/ In progress/);
@@ -0,0 +1,164 @@
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:4188';
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const editor = page.getByRole('region', { name: 'Status moves', exact: true });
const source = () => editor.getByLabel('From status', { exact: true });
const check = (name) =>
editor.getByRole('checkbox', { name: new RegExp(name) });
const button = (name) => editor.getByRole('button', { name, exact: true });
const state = (name) => editor.locator('[data-form-state="' + name + '"]');
const result = (value) =>
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
const visit = async (query = '?reset') => {
await result('success');
await page.goto(base + '/projects/1/settings/board' + query);
await source().waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const save = async (id, targets) => {
const sent = page.waitForRequest((request) => request.method() === 'PATCH');
await button('Save moves').click();
const request = await sent;
assert.equal(
new URL(request.url()).pathname,
'/projects/1/settings/board/statuses/' + id + '/transitions',
);
assert.deepEqual(request.postDataJSON(), {
allowed_ticket_status_ids: targets,
});
};
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
assert.equal(await check('Ready').isChecked(), true);
assert.equal(await check('Building').isChecked(), false);
assert.equal(await check('Backlog').count(), 0);
assert.equal(await button('Save moves').isDisabled(), true);
await check('Building').focus();
await page.keyboard.press('Space');
assert.equal(await check('Building').isChecked(), true);
await editor
.getByText('Unsaved changes', { exact: true })
.waitFor();
await noOverflow();
await button('Reset changes').click();
assert.equal(await check('Building').isChecked(), false);
assert.equal(await check('Ready').isChecked(), true);
if ([1440, 390].includes(width)) {
await editor.scrollIntoViewIfNeeded();
await page.screenshot({
path: '/private/tmp/KAN-41-' + width + '-' + theme + '.png',
});
}
}
}
await visit();
await check('Ready').uncheck();
await check('Review').check();
await result('validation');
await save(1, [4]);
await state('validation').waitFor();
await editor
.getByText('Choose a destination from this project.', { exact: true })
.waitFor();
assert.equal(await check('Review').isChecked(), true);
assert.equal(await check('Ready').isChecked(), false);
assert.equal(
await editor.getByRole('group').getAttribute('aria-invalid'),
'true',
);
await result('server');
await save(1, [4]);
await state('server').waitFor();
await result('success');
await button('Try again').click();
await state('success').waitFor();
assert.equal(await button('Save moves').isDisabled(), true);
assert.equal(await check('Review').isChecked(), true);
await page.reload();
await source().waitFor();
assert.equal(await check('Review').isChecked(), true);
assert.equal(await check('Ready').isChecked(), false);
await source().selectOption('4');
assert.equal(await check('Backlog').isChecked(), false);
assert.equal(await check('Review').count(), 0);
await check('Backlog').check();
page.once('dialog', (prompt) => prompt.dismiss());
await source().selectOption('1');
assert.equal(await source().inputValue(), '4');
assert.equal(await check('Backlog').isChecked(), true);
page.once('dialog', (prompt) => prompt.accept());
await source().selectOption('1');
assert.equal(await source().inputValue(), '1');
assert.equal(await check('Review').isChecked(), true);
await check('Review').uncheck();
await editor.getByText(/With no destinations selected/).waitFor();
await save(1, []);
await state('success').waitFor();
await page.reload();
await source().waitFor();
assert.equal(await editor.locator('input:checked').count(), 0);
await editor.getByText('Saved destinations: 0', { exact: true }).waitFor();
// A disconnected request retains the draft and supports a single retry.
await check('Building').check();
await page.route('**/statuses/1/transitions', (route) =>
route.abort('failed'),
);
await save(1, [3]);
await state('network').waitFor();
assert.equal(await check('Building').isChecked(), true);
await page.unroute('**/statuses/1/transitions');
await button('Try again').click();
await state('success').waitFor();
// Pending saves disable inputs, source changes and duplicate submits.
let release;
const blocked = new Promise((resolve) => {
release = resolve;
});
await page.route('**/statuses/1/transitions', async (route) => {
await blocked;
await route.continue();
});
await check('Review').check();
await save(1, [3, 4]);
await state('loading').waitFor();
assert.equal(await source().isDisabled(), true);
assert.equal(await check('Building').isDisabled(), true);
assert.equal(
await editor.locator('button[type=submit]').isDisabled(),
true,
);
release();
await state('success').waitFor();
await page.unroute('**/statuses/1/transitions');
await visit('?reset&empty');
await editor
.getByText('No other statuses exist yet.', { exact: true })
.waitFor();
assert.equal(await editor.getByRole('checkbox').count(), 0);
await noOverflow();
assert.deepEqual(errors, []);
console.log(
'Transition browser checks passed: directed rules, persisted selection, clearing all targets, reset, source-change protection, validation/network/server errors, retry, pending saves, keyboard, responsive widths and both themes.',
);
} finally {
await browser.close();
}