Compare commits

...
20 Commits
Author SHA1 Message Date
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
Twyco fed0b657c0 Merge pull request 'Feature/kan 33 ticketbeziehungen und hierarchie' (#128) from feature/KAN-33-ticketbeziehungen-und-hierarchie into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 20m52s
CI/CD / Publish immutable image (push) Successful in 3m39s
Reviewed-on: #128
2026-09-14 09:45:54 +00:00
Twyco 1fe0c0b2d9 feat(KAN-33): redesign ticket relationships and hierarchy
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 5s
2026-09-14 09:45:45 +00:00
Twyco 463cb7fe36 Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste 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: #127
2026-09-14 09:45:39 +00:00
Twyco 3bf5a0c508 feat(KAN-32): add hierarchical acceptance criteria checklist
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 31s
2026-09-14 09:45:28 +00:00
Twyco 32de1f425a docker test env fix
CI/CD / preconditions (push) Successful in 1m2s
CI/CD / test_and_check_app (push) Successful in 11m39s
CI/CD / Publish immutable image (push) Successful in 3m47s
2026-09-14 10:52:07 +02:00
Twyco 1af98cd56e docker env example fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Failing after 1m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:48:29 +02:00
Twyco aadf159ac5 docker test fix
CI/CD / preconditions (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 3m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:36:56 +02:00
Twyco 5c47453d4d rollback test app action
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Failing after 8m23s
CI/CD / Publish immutable image (push) Skipped
2026-09-12 14:23:18 +02:00
64 changed files with 8236 additions and 3081 deletions
-3
View File
@@ -33,9 +33,6 @@
vendor
**/.DS_Store
.editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production
LICENSE
README.md
-4
View File
@@ -1,4 +0,0 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+12 -66
View File
@@ -1,72 +1,18 @@
name: "Lint and Test API"
description: "Run Pint and the Pest test suite for the Laravel API"
name: test_and_check_app
description: Build the test image and run all application checks
runs:
using: composite
steps:
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
- name: Verify Composer lockfile
- name: Test and check app
shell: bash
run: |
if [ ! -f composer.lock ]; then
echo "::error::api/composer.lock not found"
exit 1
fi
shell: bash
set -Eeuo pipefail
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Build Vite app
run: pnpm run build
shell: bash
- name: Run ci check
run: composer run ci:check
shell: bash
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
+2 -1
View File
@@ -61,7 +61,8 @@ RUN composer install --prefer-dist --no-interaction --no-progress \
FROM build AS test
RUN touch .env \
RUN cp .env.example .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->dontFlash(['api_token']);
})->create();
+69 -28
View File
@@ -1,39 +1,80 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [
{ value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const;
const tabs = computed(() => [
{
value: 'light' as const,
Icon: Sun,
label: t('appearance.light'),
description: t('appearance.lightDescription'),
},
{
value: 'dark' as const,
Icon: Moon,
label: t('appearance.dark'),
description: t('appearance.darkDescription'),
},
{
value: 'system' as const,
Icon: Monitor,
label: t('appearance.system'),
description: t('appearance.systemDescription'),
},
]);
</script>
<template>
<div
role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)"
:class="[
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
appearance === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]"
>
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span>{{ t(label) }}</span>
</button>
</div>
<fieldset class="min-w-0">
<legend class="sr-only">{{ t('appearance.label') }}</legend>
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
<label
v-for="{ value, Icon, label, description } in tabs"
:key="value"
class="relative min-w-0"
>
<input
type="radio"
name="appearance"
:value="value"
:checked="appearance === value"
:aria-describedby="`appearance-${value}-description`"
class="peer sr-only"
@change="updateAppearance(value)"
/>
<span
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
>
<span class="flex items-center justify-between gap-3">
<span
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
>
<component
:is="Icon"
class="size-5"
aria-hidden="true"
/>
</span>
<Check
v-if="appearance === value"
class="text-primary size-5"
aria-hidden="true"
/>
</span>
<span class="font-semibold">{{ label }}</span>
<span
:id="`appearance-${value}-description`"
class="text-caption text-muted-foreground"
>
{{ description }}
</span>
</span>
</label>
</div>
</fieldset>
</template>
+91 -84
View File
@@ -2,10 +2,10 @@
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogClose,
@@ -17,97 +17,104 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script>
<template>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription>
</DialogHeader>
<Card
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
<p class="text-muted-foreground">
{{ t('profile.delete.description') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<div
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
>
<div class="gap-stack grid">
<p class="text-destructive font-semibold">
{{ t('profile.delete.warning') }}
</p>
<p class="text-body text-muted-foreground">
{{ t('profile.delete.warningDescription') }}
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button
variant="destructive"
class="justify-self-start"
data-test="delete-user-button"
>{{ t('profile.delete.action') }}</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('profile.delete.confirmTitle')
}}</DialogTitle>
<DialogDescription>
{{ t('profile.delete.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only"
>Password</Label
>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password" class="sr-only">{{
t('profile.delete.password')
}}</Label>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
:placeholder="t('profile.delete.password')"
/>
<InputError :message="errors.password" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
{{ t('common.cancel') }}
</Button>
</DialogClose>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Cancel
{{ t('profile.delete.confirmAction') }}
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</template>
+150 -104
View File
@@ -1,123 +1,169 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { ref } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
const { t } = useLocalization();
const {
recoveryCodesList,
fetchRecoveryCodes,
clearRecoveryCodes,
errors,
loading,
} = useTwoFactorAuth();
const visible = ref(false);
const showRegenerate = ref(false);
const form = useForm({});
const protection = useFormProtection(form);
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
function hide(): void {
visible.value = false;
clearRecoveryCodes();
}
function toggle(): void {
if (visible.value) {
hide();
} else {
visible.value = true;
void fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
function closeRegenerate(open: boolean): void {
if (!form.processing) {
showRegenerate.value = open;
protection.clearFeedback();
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
}
function regenerate(): void {
protection.submit(regenerateRecoveryCodes(), {
preserveScroll: true,
onBefore: hide,
onSuccess: () => {
showRegenerate.value = false;
visible.value = true;
return fetchRecoveryCodes();
},
});
}
</script>
<template>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
<section
class="gap-column grid min-w-0 rounded-lg border p-4"
aria-labelledby="recovery-title"
>
<div class="gap-stack grid">
<h3
id="recovery-title"
class="flex items-center gap-2 font-semibold"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
codes
</Button>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
<LockKeyhole class="text-primary size-4" />{{
t('security.recoveryTitle')
}}
</h3>
<p class="text-body text-muted-foreground">
{{ t('security.recoveryDescription') }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<Button
variant="outline"
:aria-expanded="visible"
aria-controls="recovery-codes"
:disabled="form.processing"
@click="toggle"
><component :is="visible ? EyeOff : Eye" />{{
t(
visible
? 'security.hideRecovery'
: 'security.viewRecovery',
)
}}</Button
>
<Button
v-if="visible && recoveryCodesList.length"
variant="secondary"
@click="showRegenerate = true"
><RefreshCw />{{ t('security.regenerate') }}</Button
>
</div>
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
<p
v-if="loading"
role="status"
class="bg-muted animate-pulse rounded-lg p-4"
>
{{ t('security.loadingRecovery') }}
</p>
<template v-else-if="errors.length">
<AlertError :errors="errors" />
<Button
class="justify-self-start"
variant="outline"
@click="fetchRecoveryCodes"
>{{ t('common.retry') }}</Button
>
<Button
variant="secondary"
type="submit"
:disabled="processing"
>
<RefreshCw /> Regenerate codes
</Button>
</Form>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible
? 'h-auto opacity-100'
: 'h-0 opacity-0',
]"
</template>
<template v-else>
<ul
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
:aria-label="t('security.recoveryTitle')"
>
<li v-for="code in recoveryCodesList" :key="code">
{{ code }}
</li>
</ul>
<p class="text-caption text-muted-foreground">
{{ t('security.recoveryOnce') }}
</p>
</template>
</div>
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<div v-if="errors?.length" class="mt-6">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
<DialogHeader>
<DialogTitle>{{
t('security.regenerateTitle')
}}</DialogTitle>
<DialogDescription>{{
t('security.regenerateDescription')
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="regenerate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="closeRegenerate(false)"
>{{ t('common.cancel') }}</Button
>
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div
v-for="n in 8"
:key="n"
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
></div>
</div>
<div
v-else
v-for="(code, index) in recoveryCodesList"
:key="index"
>
{{ code }}
</div>
</div>
<p class="text-muted-foreground select-none text-xs">
Each recovery code can be used once to access your
account and will be removed after use. If you need more,
click
<span class="font-bold">Regenerate codes</span> above.
</p>
</div>
</div>
</CardContent>
</Card>
<Button :loading="form.processing" @click="regenerate">{{
t('security.regenerate')
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</template>
+220 -256
View File
@@ -1,297 +1,261 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { useForm } from '@inertiajs/vue3';
import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
const props = defineProps<{
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
};
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
}>();
const isOpen = defineModel<boolean>('isOpen');
const { t } = useLocalization();
const {
qrCodeSvg,
manualSetupKey,
hasSetupData,
clearSetupData,
fetchSetupData,
errors,
loading,
} = useTwoFactorAuth();
const form = useForm({ code: '' }).dontRemember('code');
const protection = useFormProtection(form, { isDirty: () => false });
const verification = ref(false);
const copied = ref(false);
const copyError = ref(false);
const codeInput = useTemplateRef('codeInput');
const title = computed(() =>
t(
props.twoFactorEnabled
? 'security.setupEnabled'
: verification.value
? 'security.verifyTitle'
: 'security.setupTitle',
),
);
const description = computed(() =>
t(
props.twoFactorEnabled
? 'security.setupEnabledDescription'
: verification.value
? 'security.verifyDescription'
: 'security.setupDescription',
),
);
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<string>('');
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
const modalConfig = computed<TwoFactorConfigContent>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
function close(open: boolean): void {
if (!form.processing) {
isOpen.value = open;
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable two-factor authentication',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
}
async function next(): Promise<void> {
if (props.twoFactorEnabled || !props.requiresConfirmation) {
close(false);
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
verification.value = true;
await nextTick();
codeInput.value?.$el?.focus();
}
function submit(): void {
protection.submit(confirm(), {
preserveScroll: true,
errorBag: 'confirmTwoFactorAuthentication',
onSuccess: () => {
isOpen.value = false;
},
onFinish: () => {
form.reset();
},
});
}
async function copyKey(): Promise<void> {
const key = manualSetupKey.value;
if (!key) {
return;
}
showVerificationStep.value = false;
code.value = '';
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
} catch {
if (isOpen.value) {
copyError.value = true;
}
},
);
}
}
watch(isOpen, (open) => {
verification.value = false;
copied.value = false;
copyError.value = false;
form.resetAndClearErrors();
protection.clearFeedback();
clearSetupData();
if (open) {
void fetchSetupData();
}
});
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
<Dialog :open="isOpen" @update:open="close">
<DialogContent
class="sm:max-w-md"
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<ScanLine class="text-primary size-8" />
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<template v-if="!verification">
<p
v-if="loading"
role="status"
class="bg-muted animate-pulse rounded-lg p-6 text-center"
>
{{ t('security.loadingSetup') }}
</p>
<div v-else-if="errors.length" class="gap-stack grid">
<AlertError :errors="errors" />
<Button variant="outline" @click="fetchSetupData">{{
t('common.retry')
}}</Button>
</div>
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
<div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
>
<div
class="absolute inset-0 grid grid-cols-5 opacity-50"
>
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-border border-r last:border-r-0"
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
v-html="qrCodeSvg"
/>
<div class="grid min-w-0 gap-2">
<Label for="setup-key">{{
t('security.manualKey')
}}</Label>
<div class="flex min-w-0 gap-2">
<Input
id="setup-key"
:model-value="manualSetupKey"
readonly
class="min-w-0 font-mono text-xs"
/>
<Button
variant="outline"
:aria-label="
t(
copied
? 'security.copied'
: 'security.copyKey',
)
"
@click="copyKey"
><Check v-if="copied" /><Copy v-else
/></Button>
</div>
<div
class="absolute inset-0 grid grid-rows-5 opacity-50"
<p
v-if="copied"
role="status"
class="text-caption text-success-foreground"
>
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-border border-b last:border-b-0"
/>
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
{{ t('security.copied') }}
</p>
<InputError
v-if="copyError"
:message="t('security.copyError')"
/>
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
>
<div
v-if="!qrCodeSvg"
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
>
<Spinner class="size-6" />
</div>
<div
v-else
class="relative z-10 overflow-hidden border p-5"
>
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div
class="relative flex w-full items-center justify-center"
>
<div
class="bg-border absolute inset-0 top-1/2 h-px w-full"
/>
<span class="bg-card relative px-2 py-1"
>or, enter the code manually</span
>
</div>
<div
class="flex w-full items-center justify-center space-x-2"
>
<div
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
>
<div
v-if="!manualSetupKey"
class="bg-muted flex h-full w-full items-center justify-center p-3"
>
<Spinner />
</div>
<template v-else>
<input
type="text"
readonly
:value="manualSetupKey"
class="bg-background text-foreground h-full w-full p-3"
/>
<button
@click="copy(manualSetupKey || '')"
class="border-border hover:bg-muted relative block h-auto border-l px-3"
>
<Check
v-if="copied"
class="w-4 text-green-500"
/>
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
</template>
<template v-else>
<Form
v-bind="confirm.form()"
error-bag="confirmTwoFactorAuthentication"
reset-on-error
@finish="code = ''"
@success="isOpen = false"
v-slot="{ errors, processing }"
<p
v-if="requiresConfirmation && !twoFactorEnabled"
class="text-caption text-muted-foreground"
>
{{ t('security.setupPendingDescription') }}
</p>
<DialogFooter>
<Button variant="outline" @click="close(false)">{{
t('common.close')
}}</Button>
<Button v-if="hasSetupData" @click="next">{{
t(
twoFactorEnabled || !requiresConfirmation
? 'common.done'
: 'security.continue',
)
}}</Button>
</DialogFooter>
</template>
<form v-else class="gap-column grid" @submit.prevent="submit">
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="authentication-code">{{
t('security.authenticationCode')
}}</Label>
<Input
id="authentication-code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
required
:disabled="form.processing"
:aria-invalid="!!form.errors.code"
aria-describedby="authentication-code-error"
class="font-mono text-lg tracking-widest"
/>
<InputError
id="authentication-code-error"
:message="form.errors.code"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="
verification = false;
form.resetAndClearErrors();
protection.clearFeedback();
"
>{{ t('common.back') }}</Button
>
<input type="hidden" name="code" :value="code" />
<div
ref="pinInputContainerRef"
class="relative w-full space-y-3"
>
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
<InputError :message="errors?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button
type="submit"
class="w-auto flex-1"
:disabled="processing || code.length < 6"
>
Confirm
</Button>
</div>
</div>
</Form>
</template>
</div>
<Button
type="submit"
:disabled="form.code.length !== 6"
:loading="form.processing"
>{{ t('security.confirm') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -1,26 +1,32 @@
<script setup lang="ts">
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{
areas: string[];
projectId: number;
templateState?: ProjectTemplateState | null;
}>();
const { t } = useLocalization();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
@@ -28,51 +34,82 @@ const form = useForm<{
areas: [],
status_replacements: {},
});
const activeArea = computed(() => form.areas[0]);
const protection = useFormProtection(form);
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
const inheritedAreas = computed(
() =>
props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const overriddenAreas = computed(
() =>
props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const statusRequirement = computed(
() => props.templateState?.resetRequirements?.ticket_statuses,
const statusRequirement = computed(() =>
pendingArea.value?.value === 'ticket_statuses'
? props.templateState?.resetRequirements?.ticket_statuses
: undefined,
);
const canReset = (area: ProjectTemplateStateArea): boolean => {
if (area.value !== 'ticket_statuses') {
return true;
}
return (
statusRequirement.value?.removedStatuses.every(
(status) =>
form.status_replacements[String(status.id)] !== undefined,
) ?? true
);
};
const resetArea = (area: ProjectTemplateStateArea): void => {
if (!canReset(area)) {
const canReset = computed(
() =>
statusRequirement.value?.removedStatuses.every((status) =>
statusRequirement.value?.replacementOptions.some(
(option) =>
option.id === form.status_replacements[String(status.id)],
),
) ?? true,
);
const areaKeys = {
project_settings: 'inheritance.area.projectSettings',
board_columns: 'inheritance.area.boardColumns',
ticket_statuses: 'inheritance.area.ticketStatuses',
status_transitions: 'inheritance.area.statusTransitions',
ticket_types: 'inheritance.area.ticketTypes',
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;
}
form.areas = [area.value];
form.post(ProjectController.resetTemplate.url(props.projectId), {
pendingArea.value = null;
form.resetAndClearErrors();
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,
onSuccess: () => form.reset(),
onSuccess: () => {
form.resetAndClearErrors();
pendingArea.value = null;
},
});
};
}
</script>
<template>
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
"
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Every setting area inherits independently. Local changes stay
unchanged when the template is updated until that area is reset.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
</div>
<p class="text-muted-foreground break-words">
{{
t('inheritance.description', {
template: templateState.templateName,
})
}}
</p>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="area in inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<p class="text-caption text-muted-foreground">
{{ t('inheritance.help') }}
</p>
<ul
class="grid min-w-0 divide-y"
:aria-label="t('inheritance.areas')"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Badge>{{ area.label }} · template overridden</Badge>
<Button
type="button"
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"
<li
v-for="area in inheritedAreas"
:key="area.value"
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
>
<p class="text-muted-foreground text-sm">
Choose where tickets in local statuses should move
before those statuses are removed.
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2 sm:grid-cols-2 sm:items-center"
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge
variant="outline"
class="border-primary/25 text-primary"
>{{ t('projectSettings.inherited') }}</Badge
>
<Label :for="`replacement-${status.id}`">
{{ status.name }} ({{ status.ticketCount }} tickets)
</Label>
<div class="grid gap-1">
<select
:id="`replacement-${status.id}`"
v-model="
form.status_replacements[String(status.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 focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select replacement status
</option>
<option
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>
</li>
<li
v-for="area in overriddenAreas"
:key="area.value"
class="gap-stack grid min-w-0 py-3"
>
<div
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
>
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge variant="secondary">{{
t('projectSettings.override')
}}</Badge>
</div>
</div>
<InputError :message="form.errors.areas" />
</div>
<div
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
>
<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>
<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>
</template>
@@ -0,0 +1,320 @@
<script setup lang="ts">
import {
ClipboardCheck,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, useId, watch } from 'vue';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
canIndentAcceptanceCriterion,
indentAcceptanceCriterion,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(
defineProps<{
modelValue: AcceptanceCriteriaModel;
readonly?: boolean;
disabled?: boolean;
persistToggles?: boolean;
allowEditing?: boolean;
saving?: boolean;
dirty?: boolean;
error?: string;
}>(),
{
readonly: false,
disabled: false,
persistToggles: false,
allowEditing: true,
saving: undefined,
dirty: false,
error: '',
},
);
const emit = defineEmits<{
'update:modelValue': [items: AcceptanceCriteriaModel];
toggle: [index: number, checked: boolean];
}>();
const { t } = useLocalization();
const id = useId();
const root = ref<HTMLElement>();
const editing = ref(!props.persistToggles);
const completed = computed(
() => props.modelValue.filter((item) => item.checked).length,
);
const locked = computed(() => props.readonly || props.disabled);
const update = (items: AcceptanceCriteriaModel): void => {
if (!locked.value) {
emit('update:modelValue', items);
}
};
const focusRow = async (index: number): Promise<void> => {
await nextTick();
const inputs =
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
if (inputs?.[index]) {
inputs[index].focus();
} else {
root.value
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
?.focus();
}
};
const add = (): void => {
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
void focusRow(props.modelValue.length);
};
const remove = (index: number): void => {
update(removeAcceptanceCriterion(props.modelValue, index));
void focusRow(Math.max(0, index - 1));
};
watch(
() => props.modelValue,
async () => {
await nextTick();
root.value
?.querySelectorAll<HTMLTextAreaElement>('textarea')
.forEach((input) => {
input.setCustomValidity(
input.value.trim() === '' ? t('criteria.required') : '',
);
});
},
);
const changeText = (index: number, event: Event): void => {
const text = (event.target as HTMLTextAreaElement).value;
update(
props.modelValue.map((item, position) =>
position === index ? { ...item, text } : item,
),
);
};
const toggle = (index: number, event: Event): void => {
const target = event.target as HTMLInputElement;
const checked = target.checked;
target.checked = props.modelValue[index].checked;
if (locked.value) {
return;
}
if (props.persistToggles && !editing.value && !props.dirty) {
emit('toggle', index, checked);
} else {
update(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
checked,
),
);
}
};
const onKeydown = (index: number, event: KeyboardEvent): void => {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
event.preventDefault();
update(
indentAcceptanceCriterion(
props.modelValue,
index,
event.key === 'ArrowRight' ? 1 : -1,
),
);
};
</script>
<template>
<section
ref="root"
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
:aria-labelledby="`${id}-title`"
:aria-busy="disabled"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3
:id="`${id}-title`"
class="flex items-center gap-2 font-semibold"
>
<ClipboardCheck
class="text-primary size-5"
aria-hidden="true"
/>
{{ t('criteria.title') }}
</h3>
<span class="text-muted-foreground text-sm" aria-live="polite">{{
t('criteria.progress', { completed, total: modelValue.length })
}}</span>
<Button
v-if="persistToggles && allowEditing && !readonly"
type="button"
variant="outline"
:disabled="
disabled ||
(editing &&
modelValue.some((item) => item.text.trim() === ''))
"
:aria-pressed="editing"
@click="editing = !editing"
>
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
</Button>
</div>
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
{{ t('criteria.empty') }}
</p>
<ol v-else class="space-y-3">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `new-${index}`"
:data-indent="item.indent"
class="border-border min-w-0"
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
>
<div class="flex min-w-0 items-start gap-2">
<label
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
:class="{ 'cursor-not-allowed opacity-60': locked }"
>
<input
:checked="item.checked"
type="checkbox"
:disabled="locked"
:aria-label="
t('criteria.complete', {
number: index + 1,
text: item.text,
})
"
class="border-input accent-primary size-5 rounded"
@change="toggle(index, $event)"
/>
</label>
<div class="min-w-0 flex-1">
<textarea
v-if="editing && !readonly"
:value="item.text"
:aria-label="
t('criteria.item', { number: index + 1 })
"
:aria-describedby="`${id}-hint ${id}-error`"
:disabled="disabled"
:aria-invalid="Boolean(error)"
required
rows="2"
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
@input="changeText(index, $event)"
@keydown="onKeydown(index, $event)"
/>
<p
v-else
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
:class="{
'text-muted-foreground line-through':
item.checked,
}"
>
{{ item.text }}
</p>
<div
v-if="editing && !readonly"
class="flex flex-wrap gap-1"
>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled || item.indent === 0"
:aria-label="
t('criteria.outdent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
-1,
),
)
"
><IndentDecrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
disabled ||
!canIndentAcceptanceCriterion(
modelValue,
index,
)
"
:aria-label="
t('criteria.indent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
1,
),
)
"
><IndentIncrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled"
:aria-label="
t('criteria.remove', { number: index + 1 })
"
@click="remove(index)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</div>
</div>
</li>
</ol>
<template v-if="editing && !readonly">
<Button
data-add-criterion
type="button"
variant="outline"
:disabled="disabled"
@click="add"
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{ t('criteria.hint') }}
</p>
</template>
<p
v-if="persistToggles && dirty"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.unsaved') }}
</p>
<p
v-if="saving ?? disabled"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.saving') }}
</p>
<InputError :id="`${id}-error`" :message="error" role="alert" />
</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,43 +3,54 @@ import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref } from 'vue';
import {
computed,
ref,
toRef,
unref,
useTemplateRef,
nextTick,
watch,
} from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailContentProps>();
const { t } = useLocalization();
const editor = useTemplateRef<HTMLDetailsElement>('editor');
async function editParent(): Promise<void> {
if (!props.canEditTicket || !editor.value) {
return;
}
editor.value.open = true;
await nextTick();
editor.value
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
?.focus();
}
defineExpose({ editParent });
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
`${draftKey}:criteria`,
);
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
@@ -47,8 +58,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
acceptance_criteria: normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
@@ -77,14 +88,20 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
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(
() =>
acceptanceCriteriaChecklist.value !==
formatChecklistLines(savedAcceptanceCriteria.value),
JSON.stringify(acceptanceCriteriaItems.value) !==
JSON.stringify(savedAcceptanceCriteria.value),
);
const isDirty = computed(
@@ -103,9 +120,10 @@ const reset = (): void => {
}
form.reset();
form.clearErrors();
acceptanceCriteriaChecklist.value = formatChecklistLines(
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
@@ -119,19 +137,6 @@ const acceptanceCriteriaError = computed(() => {
);
});
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
return draftItems.map((item, index) => ({
...item,
...(savedAcceptanceCriteria.value[index]?.id !== undefined
? { id: savedAcceptanceCriteria.value[index].id }
: {}),
}));
};
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
@@ -171,10 +176,6 @@ const persistAcceptanceCriteriaToggle = async (
index,
checked,
);
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
@@ -209,34 +210,18 @@ const persistAcceptanceCriteriaToggle = async (
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) {
acceptanceCriteriaChecklist.value = nextChecklist;
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
}
} catch (_error) {
acceptanceCriteriaToggleError.value = t(
'ticketDetail.checklistSaveFailed',
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
nextAcceptanceCriteria,
);
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError');
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const onAcceptanceCriteriaCheckboxChange = async (
index: number,
event: Event,
): Promise<void> => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
await persistAcceptanceCriteriaToggle(index, target.checked);
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
};
const submit = (): void => {
if (
!props.canEditTicket ||
@@ -246,7 +231,7 @@ const submit = (): void => {
return;
}
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit(
ProjectTicketController.update({
@@ -259,7 +244,7 @@ const submit = (): void => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
@@ -301,85 +286,29 @@ const submit = (): void => {
</CardContent>
</Card>
</section>
<section
id="ticket-checklist"
aria-labelledby="ticket-checklist-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-checklist-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.checklist') }}
</h2></CardHeader
>
<CardContent class="space-y-3">
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
class="border-border rounded-xl border p-4 text-sm"
>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<input
:id="`acceptance-criteria-${item.id ?? index}`"
:checked="item.checked"
type="checkbox"
:disabled="
!canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
>
{{ item.text }}
</label>
</li>
</ul>
</div>
<p v-else class="text-muted-foreground text-sm">
{{ t('ticketDetail.noChecklist') }}
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.saveChecklistFirst') }}
</p>
<p
v-if="acceptanceCriteriaToggleError"
role="alert"
class="text-destructive text-sm"
>
{{ acceptanceCriteriaToggleError }}
</p>
</CardContent>
</Card>
</section>
<div id="ticket-checklist" class="scroll-mt-6">
<AcceptanceCriteriaChecklist
:model-value="savedAcceptanceCriteria"
:readonly="!canEditTicket"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
:saving="
form.processing || isPersistingAcceptanceCriteriaToggle
"
:allow-editing="false"
persist-toggles
:dirty="hasUnsavedAcceptanceCriteriaDraft"
:error="acceptanceCriteriaToggleError"
@toggle="persistAcceptanceCriteriaToggle"
/>
</div>
<details
v-if="canEditTicket"
id="ticket-edit"
ref="editor"
class="bg-card group rounded-xl border shadow-sm"
>
<summary
@@ -416,20 +345,13 @@ const submit = (): void => {
"
>
<template #content>
<TicketFormField
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
<AcceptanceCriteriaChecklist
v-model="acceptanceCriteriaItems"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
:error="acceptanceCriteriaError"
/>
</template>
<template #metadata>
@@ -459,44 +381,6 @@ const submit = (): void => {
/>
</template>
</TicketFormFields>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketForm.blocker') }}
</legend>
<div class="min-h-control flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="is_blocked-error"
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
/>
<Label for="is_blocked">{{
t('ticketDetail.blocked')
}}</Label>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:required="form.is_blocked"
/>
</fieldset>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
@@ -1,273 +1,12 @@
<script setup lang="ts">
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { TicketDetailGitProps } from '@/types/ticket-detail';
defineProps<TicketDetailGitProps>();
import { Link, useForm, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type {
TicketDetailGitProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailGitProps>();
const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() =>
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
(
[isOpen, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchProtection.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
</script>
<template>
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
<h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }}
</h2>
<div class="grid min-w-0 gap-6">
<Card class="h-fit">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.branchesDescription')
}}</CardDescription>
</div>
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />{{
t('ticketDetail.createBranch')
}}</Button
>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="branchProtection.state.value"
@retry="submitBranchCreation"
/>
<div class="grid min-w-0 gap-2">
<Label for="repository_connection_id">{{
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(repositoryConnection.id)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="branchCreationForm.branch_name"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div class="flex items-start gap-3">
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !== null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t(
'ticketDetail.checkingBranch',
)
: (branchNameValidation?.message ??
t(
'ticketDetail.selectRepository',
))
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="border-border rounded-xl border border-dashed px-4 py-4"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="updateBranchDialogOpen(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>{{
t('ticketDetail.createNow')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryBranches.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="repositoryBranch.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryBranch.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectBranches') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.pullRequestsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryPullRequests.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="
repositoryPullRequest.repository_connection.id
"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryPullRequest.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectPullRequests') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 rounded-xl border px-4 py-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="text-foreground hover:text-primary font-medium transition"
>
{{ pullRequest.title }}
</a>
</div>
<p
class="text-muted-foreground mt-2 text-sm"
>
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<TicketGitBranches
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
:repository-branches="repositoryBranches"
:suggested-branch-name="suggestedBranchName"
/>
<TicketGitPullRequests
:repository-pull-requests="repositoryPullRequests"
/>
</div>
</section>
</template>
@@ -15,6 +15,7 @@ const sections = [
'description',
'checklist',
'metadata',
'blocker',
'relationships',
'history',
'git',
@@ -8,11 +8,21 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
import type { TranslationKey } from '@/types/localization';
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization();
const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
created: 'ticketDetail.historyType.created',
link_removed: 'ticketDetail.historyType.linkRemoved',
link_updated: 'ticketDetail.historyType.linkUpdated',
status_changed: 'ticketDetail.historyType.statusChanged',
updated: 'ticketDetail.historyType.updated',
};
</script>
<template>
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
{{ t('ticketDetail.noHistory') }}
</div>
<div v-else class="flex flex-col gap-4">
<div
<ol v-else class="relative grid gap-0" role="list">
<li
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4"
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span class="text-foreground text-sm font-medium">
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
<div
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
>
<UserAvatar
v-if="worklogEntry.actor"
:user="worklogEntry.actor"
size="lg"
/>
<span
v-else
aria-hidden="true"
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
>
S
</span>
</div>
<p class="text-foreground mt-3 text-sm">
{{ worklogEntry.summary }}
</p>
<p class="text-muted-foreground mt-2 text-xs">
{{ formatDateTime(worklogEntry.created_at) }}
</p>
</div>
</div>
<article
class="bg-muted/20 min-w-0 rounded-xl border p-4"
>
<div
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
>
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<span
class="text-foreground text-sm font-semibold"
>
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
</span>
<Badge variant="outline">
{{
t(
entryTypeLabels[
worklogEntry.entry_type
],
)
}}
</Badge>
</div>
<time
:datetime="worklogEntry.created_at"
class="text-muted-foreground shrink-0 text-xs"
>
{{
formatDateTime(worklogEntry.created_at)
}}
</time>
</div>
<p class="text-foreground mt-3 text-sm leading-6">
{{ worklogEntry.summary }}
</p>
</article>
</li>
</ol>
</CardContent>
</Card>
</section>
@@ -1,33 +1,76 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Form, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import InputError from '@/components/InputError.vue';
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
import { computed, nextTick, useTemplateRef } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>();
const { t } = useLocalization();
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
const currentParentTicket = computed(
() =>
props.ticket.parent_ticket ??
props.parentTicketOptions.find(
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
(parent) => parent.id === props.ticket.parent_ticket_id,
) ??
null,
);
const canAddLink = computed(
() =>
props.canEditTicket &&
props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0,
);
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
linked_ticket_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty,
});
const selectedType = computed(() =>
props.ticketLinkTypes.find(
(type) => String(type.id) === String(form.ticket_link_type_id),
),
);
const selectedTicket = computed(() =>
props.ticketLinkOptions.find(
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
),
);
async function focusLinks(): Promise<void> {
await nextTick();
linksHeading.value?.focus();
}
function submit(): void {
if (!canAddLink.value) {
return;
}
protection.submit(
store({ project: props.project.id, ticket: props.ticket.id }),
{
preserveScroll: true,
onSuccess: () => {
form.linked_ticket_id = '';
form.clearErrors();
void focusLinks();
},
},
);
}
</script>
<template>
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
{{ t('ticketDetail.relationships') }}
</h2>
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.childrenDescription')
}}</CardDescription>
</CardHeader>
<CardContent>
<div
v-if="childTicketTree.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader
class="flex flex-wrap items-center justify-between gap-3"
>
<h3 class="flex items-center gap-2 font-semibold">
<GitFork class="text-primary size-5" aria-hidden="true" />{{
t('ticketRelationships.hierarchy')
}}
</h3>
<Button
v-if="canEditTicket"
type="button"
variant="outline"
size="sm"
@click="$emit('editParent')"
>{{ t('ticketRelationships.editParent') }}</Button
>
{{ t('ticketDetail.noChildren') }}
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<div class="grid min-w-0 gap-2">
<h4
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
>
{{ t('ticketDetail.parent') }}
</h4>
<Link
v-if="currentParentTicket"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
>
<span
class="text-primary font-mono text-xs font-semibold"
>{{ currentParentTicket.ticket_key }}</span
>
<span
class="group-hover:text-primary text-sm font-medium"
>{{ currentParentTicket.title }}</span
>
</Link>
<p
v-else
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noParent') }}
</p>
<p
v-if="canEditTicket"
class="text-muted-foreground text-xs"
>
{{ t('ticketRelationships.parentHelp') }}
</p>
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</CardContent>
</Card>
<Card v-else-if="currentParentTicket !== null" class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.parentDescription')
}}</CardDescription>
</CardHeader>
<CardContent>
<Link
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
>
<div class="font-medium">
{{ currentParentTicket.ticket_key }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ currentParentTicket.title }}
</div>
</Link>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.linkedTicketsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
project: project.id,
ticket: ticket.id,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid min-w-0 gap-2">
<Label for="ticket_link_type_id">{{
t('ticketDetail.linkType')
}}</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_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]"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError :message="errors.ticket_link_type_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="linked_ticket_id">{{
t('ticketDetail.linkedTicket')
}}</Label>
<select
id="linked_ticket_id"
name="linked_ticket_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]"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError :message="errors.linked_ticket_id" />
</div>
<Button type="submit" :disabled="processing">{{
t('ticketDetail.saveLink')
}}</Button>
</Form>
<div
v-if="ticket.ticket_type?.is_parent"
class="grid min-w-0 gap-3 border-t pt-5"
>
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold">
{{ t('ticketDetail.children') }}
</h4>
<Badge variant="secondary">{{
childTicketTree.length
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.childrenHelp') }}
</p>
<p
v-if="childTicketTree.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noChildren') }}
</p>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</div>
</CardContent>
</Card>
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader>
<h3
ref="linksHeading"
tabindex="-1"
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
>
<Link2 class="text-primary size-5" aria-hidden="true" />{{
t('ticketDetail.linkedTickets')
}}<Badge variant="secondary">{{
linkedTickets.length
}}</Badge>
</h3>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.linkedTicketsDescription') }}
</p>
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<p
v-if="linkedTickets.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noLinks') }}
</div>
<div v-else class="space-y-3">
<div
v-for="linkedTicket in linkedTickets"
:key="linkedTicket.id"
class="border-border rounded-xl border px-4 py-4"
</p>
<ul v-else class="grid min-w-0 gap-3">
<TicketRelationshipLink
v-for="link in linkedTickets"
:key="link.id"
:link="link"
:project-id="project.id"
:ticket-id="ticket.id"
:ticket-key="ticket.ticket_key"
:can-edit="canEditTicket"
@removed="focusLinks"
/>
</ul>
<form
v-if="canAddLink"
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
@submit.prevent="submit"
>
<h4 class="flex items-center gap-2 text-sm font-semibold">
<Plus class="size-4" aria-hidden="true" />{{
t('ticketRelationships.addLink')
}}
</h4>
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{ linkedTicket.ticket_link_type.label }}
</Badge>
<Link
:href="
showTicket({
project: project.id,
ticket: linkedTicket.linked_ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ linkedTicket.linked_ticket.ticket_key }}
</Link>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form({
project: project.id,
ticket: ticket.id,
ticketLink: linkedTicket.id,
})
<TicketFormField
id="ticket_link_type_id"
v-model="form.ticket_link_type_id"
kind="select"
:label="t('ticketDetail.linkType')"
:options="
ticketLinkTypes.map((type) => ({
value: type.id,
label: type.label,
}))
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="linked_ticket_id"
v-model="form.linked_ticket_id"
kind="select"
:label="t('ticketDetail.linkedTicket')"
:options="[
{
value: '',
label: t(
'ticketRelationships.selectTicket',
),
},
...ticketLinkOptions.map((ticket) => ({
value: ticket.id,
label: `${ticket.ticket_key} — ${ticket.title}`,
})),
]"
:error="form.errors.linked_ticket_id"
:disabled="form.processing"
required
/>
</fieldset>
<p
v-if="selectedType && selectedTicket"
aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-mono text-xs">{{
ticket.ticket_key
}}</span
><span>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
>
<Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>{{ t('ticketDetail.removeLink') }}</Button
>
</Form>
</div>
</div>
</p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button
type="submit"
:disabled="form.processing || !selectedTicket"
class="sm:justify-self-start"
>{{
form.processing
? t('forms.saving')
: t('ticketDetail.saveLink')
}}</Button
>
</form>
<p
v-else-if="canEditTicket"
class="text-muted-foreground text-sm"
>
{{
t(
ticketLinkTypes.length === 0
? 'ticketRelationships.noTypes'
: 'ticketRelationships.noTargets',
)
}}
</p>
</CardContent>
</Card>
</section>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ExternalLink, GitBranch } from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
const props = defineProps<TicketGitBranchesProps>();
const { t } = useLocalization();
const visibleRepositories = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranch = computed(
() => props.canEditTicket && repositoryConnections.value.length > 0,
);
</script>
<template>
<Card class="min-w-0 gap-0 overflow-hidden">
<CardHeader
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-1.5">
<CardTitle class="flex items-center gap-2">
<GitBranch
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
{{ t('ticketDetail.branches') }}
</CardTitle>
<CardDescription
>{ t('ticketDetail.branchesDescription') }}</CardDescription
>
</div>
<TicketBranchCreateDialog
v-if="canCreateBranch"
:ticket="ticket"
:project="project"
:repository-connections="repositoryConnections"
:suggested-branch-name="suggestedBranchName"
/>
</CardHeader>
<CardContent class="grid gap-4 pt-6">
<FeedbackState
v-if="repositoryBranches.length === 0"
state="empty"
:title="t('ticketDetail.noLinkedRepositories')"
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
/>
<FeedbackState
v-else-if="visibleRepositories.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<article
v-for="repositoryBranch in visibleRepositories"
v-else
:key="repositoryBranch.repository_connection.id"
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
>
<header class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<a
:href="
repositoryBranch.repository_connection
.repository_url
"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openRepositoryExternal', {
repository:
repositoryBranch.repository_connection
.repository_full_name,
provider:
repositoryBranch.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
repositoryBranch.repository_connection
.repository_full_name
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{ repositoryBranch.repository_connection.base_url }}
</p>
</header>
<FeedbackState
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
state="empty"
:title="t('ticketDetail.gitAccessRequired')"
:description="t('ticketDetail.inspectBranches')"
>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</FeedbackState>
<FeedbackState
v-else-if="repositoryBranch.error"
state="error"
:title="t('ticketDetail.branchesUnavailable')"
:description="repositoryBranch.error"
/>
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<li
v-for="branch in repositoryBranch.branches"
:key="branch.name"
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
>
<GitBranch
class="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="wrap-anywhere font-mono text-xs">{{
branch.name
}}</span>
</li>
</ul>
</article>
</CardContent>
</Card>
</template>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
const props = defineProps<TicketGitPullRequestsProps>();
const { t } = useLocalization();
const visibleRepositories = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
</script>
<template>
<Card class="min-w-0 gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle class="flex items-center gap-2">
<GitPullRequest
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
{{ t('ticketDetail.pullRequests') }}
</CardTitle>
<CardDescription
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
>
</CardHeader>
<CardContent class="grid gap-4 pt-6">
<FeedbackState
v-if="repositoryPullRequests.length === 0"
state="empty"
:title="t('ticketDetail.noLinkedRepositories')"
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
/>
<FeedbackState
v-else-if="visibleRepositories.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<article
v-for="repositoryPullRequest in visibleRepositories"
v-else
:key="repositoryPullRequest.repository_connection.id"
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
>
<header class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest.repository_connection
.provider_label
}}
</Badge>
<a
:href="
repositoryPullRequest.repository_connection
.repository_url
"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openRepositoryExternal', {
repository:
repositoryPullRequest
.repository_connection
.repository_full_name,
provider:
repositoryPullRequest
.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
repositoryPullRequest.repository_connection
.repository_full_name
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{
repositoryPullRequest.repository_connection.base_url
}}
</p>
</header>
<FeedbackState
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
state="empty"
:title="t('ticketDetail.gitAccessRequired')"
:description="t('ticketDetail.inspectPullRequests')"
>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</FeedbackState>
<FeedbackState
v-else-if="repositoryPullRequest.error"
state="error"
:title="t('ticketDetail.pullRequestsUnavailable')"
:description="repositoryPullRequest.error"
/>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openPullRequestExternal', {
number: pullRequest.number,
provider:
repositoryPullRequest
.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
pullRequest.title
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</li>
</ul>
</article>
</CardContent>
</Card>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show } from '@/routes/projects/tickets';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
const props = defineProps<{
link: TicketLinkModel;
projectId: number;
ticketId: number;
ticketKey: string;
canEdit: boolean;
}>();
const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({});
const protection = useFormProtection(form);
function remove(): void {
if (!props.canEdit) {
return;
}
protection.submit(
destroy({
project: props.projectId,
ticket: props.ticketId,
ticketLink: props.link.id,
}),
{
preserveScroll: true,
onSuccess: () => emit('removed'),
},
);
}
</script>
<template>
<li
class="grid min-w-0 gap-3 rounded-xl border p-4"
:data-ticket-link="link.id"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{
ticketKey
}}</span>
<span class="text-sm font-medium">{{
link.ticket_link_type.label
}}</span>
</div>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<Link
:href="
show({ project: projectId, ticket: link.linked_ticket.id })
"
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
>
<span class="text-primary font-mono text-xs font-semibold">{{
link.linked_ticket.ticket_key
}}</span>
<span class="wrap-anywhere text-sm font-medium">{{
link.linked_ticket.title
}}</span>
</Link>
<form v-if="canEdit" @submit.prevent="remove">
<Button
type="submit"
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-destructive"
:aria-label="
t('ticketRelationships.removeLink', {
key: link.linked_ticket.ticket_key,
})
"
:disabled="form.processing"
><Unlink class="size-4" aria-hidden="true" />{{
t('ticketDetail.removeLink')
}}</Button
>
</form>
</div>
<FormFeedback
v-if="canEdit"
:state="protection.state.value"
@retry="remove"
/>
</li>
</template>
+87 -100
View File
@@ -1,120 +1,107 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { computed, onScopeDispose, ref } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
/** Keep secrets within the consuming component and discard late responses after hiding them. */
export function useTwoFactorAuth() {
const { t } = useLocalization();
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const loading = ref(false);
let pending: AbortController | null = null;
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal,
});
if (!response.ok || response.redirected) {
throw new Error(String(response.status));
}
return response.json();
}
return response.json();
};
function clearErrors(): void {
errors.value = [];
}
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const hasSetupData = computed<boolean>(
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
);
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const fetchQrCode = async (): Promise<void> => {
try {
const { svg } = await fetchJson<{ svg: string; url: string }>(
qrCode.url(),
);
qrCodeSvg.value = svg;
} catch {
errors.value.push('Failed to fetch QR code');
qrCodeSvg.value = null;
}
};
const fetchSetupKey = async (): Promise<void> => {
try {
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
secretKey.url(),
);
manualSetupKey.value = key;
} catch {
errors.value.push('Failed to fetch a setup key');
manualSetupKey.value = null;
}
};
const clearSetupData = (): void => {
function clearTwoFactorAuthData(): void {
pending?.abort();
pending = null;
loading.value = false;
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = [];
};
clearErrors();
}
const fetchRecoveryCodes = async (): Promise<void> => {
async function load(kind: 'setup' | 'recovery'): Promise<void> {
clearTwoFactorAuthData();
const request = new AbortController();
pending = request;
loading.value = true;
try {
clearErrors();
recoveryCodesList.value = await fetchJson<string[]>(
recoveryCodes.url(),
);
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
if (kind === 'setup') {
const [qr, key] = await Promise.all([
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
fetchJson<{ secretKey: string }>(
secretKey.url(),
request.signal,
),
]);
if (pending !== request) {
return;
}
qrCodeSvg.value = qr.svg;
manualSetupKey.value = key.secretKey;
} else {
const codes = await fetchJson<string[]>(
recoveryCodes.url(),
request.signal,
);
if (pending === request) {
recoveryCodesList.value = codes;
}
}
} catch (error) {
if (pending === request && !request.signal.aborted) {
const status = error instanceof Error ? error.message : '';
errors.value = [
t(
status === '423' || status === '401' || status === '419'
? 'security.confirmAgain'
: kind === 'setup'
? 'security.setupError'
: 'security.recoveryError',
),
];
}
} finally {
if (pending === request) {
pending = null;
loading.value = false;
}
}
};
const fetchSetupData = async (): Promise<void> => {
try {
clearErrors();
await Promise.all([fetchQrCode(), fetchSetupKey()]);
} catch {
qrCodeSvg.value = null;
manualSetupKey.value = null;
}
};
}
onScopeDispose(clearTwoFactorAuthData);
return {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
loading,
hasSetupData: computed(() =>
Boolean(qrCodeSvg.value && manualSetupKey.value),
),
clearSetupData: clearTwoFactorAuthData,
clearRecoveryCodes: clearTwoFactorAuthData,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
clearErrors,
fetchSetupData: () => load('setup'),
fetchRecoveryCodes: () => load('recovery'),
};
};
}
@@ -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">
import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{
title: t('navigation.profile'),
href: editProfile(),
icon: UserRound,
},
{
title: t('navigation.security'),
href: editSecurity(),
icon: ShieldCheck,
},
{
title: t('navigation.gitConnections'),
href: editGitConnections(),
icon: GitBranch,
},
{
title: t('navigation.applications'),
href: editApplications(),
icon: KeyRound,
},
{
title: t('navigation.appearance'),
href: editAppearance(),
icon: Palette,
},
]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script>
<template>
<div class="px-4 py-6">
<Heading
:title="t('navigation.settings')"
:description="t('navigation.settingsDescription')"
/>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
>
<header class="gap-stack grid min-w-0">
<div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground uppercase">
{{ t('settings.eyebrow') }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.settings') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('navigation.settingsDescription') }}
</p>
</header>
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<div
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-1 overflow-x-auto lg:flex-col"
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.settings')"
>
<Button
<Link
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href) ? 'page' : undefined
"
:class="[
'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
isCurrentOrParentUrl(item.href)
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
]"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<Separator class="lg:hidden" />
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
<slot />
</section>
</div>
+70
View File
@@ -5,6 +5,76 @@ import type {
const MAX_INDENT_LEVEL = 8;
export const canIndentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): boolean => {
const item = items[index];
const previous = items[index - 1];
if (!item || !previous || item.indent > previous.indent) {
return false;
}
return items
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
};
const acceptanceCriteriaSubtreeEnd = (
items: AcceptanceCriteriaModel,
index: number,
): number => {
let end = index + 1;
while (end < items.length && items[end].indent > items[index].indent) {
end++;
}
return end;
};
export const indentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
direction: 1 | -1,
): AcceptanceCriteriaModel => {
if (
!items[index] ||
(direction === 1
? !canIndentAcceptanceCriterion(items, index)
: items[index].indent === 0)
) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.map((item, position) =>
position >= index && position < end
? { ...item, indent: item.indent + direction }
: item,
);
};
/** Remove only the selected criterion, promoting its children without losing them. */
export const removeAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): AcceptanceCriteriaModel => {
if (!items[index]) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.flatMap((item, position) =>
position === index
? []
: [
{
...item,
indent:
position > index && position < end
? item.indent - 1
: item.indent,
},
],
);
};
export type AcceptanceCriteriaEditorState = {
checklist: string;
};
+326
View File
@@ -1,4 +1,113 @@
export const en = {
'security.title': 'Security settings',
'security.passwordTitle': 'Update password',
'security.passwordDescription':
'Use a long, unique password to keep your account secure.',
'security.currentPassword': 'Current password',
'security.newPassword': 'New password',
'security.confirmPassword': 'Confirm password',
'security.savePassword': 'Save password',
'security.savingPassword': 'Saving password…',
'security.passwordSaved': 'Password saved.',
'security.twoFactorTitle': 'Two-factor authentication',
'security.twoFactorDescription':
'Add a second layer of protection with a code from your authenticator app.',
'security.enabledDescription':
'Your authenticator app provides a six-digit code when you sign in. Keep your recovery codes somewhere safe.',
'security.enabled': 'Enabled',
'security.disabled': 'Disabled',
'security.pending': 'Awaiting confirmation',
'security.enable': 'Enable 2FA',
'security.disable': 'Disable 2FA',
'security.continueSetup': 'Continue setup',
'security.disableTitle': 'Disable two-factor authentication?',
'security.disableDescription':
'You will no longer need an authenticator code to sign in. Your existing recovery codes will be removed.',
'security.setupTitle': 'Set up your authenticator',
'security.setupDescription':
'Scan this QR code with a TOTP authenticator app, or enter the setup key manually.',
'security.setupEnabled': 'Two-factor authentication enabled',
'security.setupEnabledDescription':
'Finish adding this account to your authenticator app and save your recovery codes.',
'security.verifyTitle': 'Verify authentication code',
'security.verifyDescription':
'Enter the six-digit code from your authenticator app to finish enabling two-factor authentication.',
'security.authenticationCode': 'Authentication code',
'security.manualKey': 'Manual setup key',
'security.copyKey': 'Copy key',
'security.copied': 'Copied',
'security.copyError':
'Copying failed. Select the key and copy it manually.',
'security.confirm': 'Confirm',
'security.continue': 'Continue',
'security.loadingSetup': 'Loading authenticator setup…',
'security.setupPendingDescription':
'Protection starts after you confirm a code. Closing this dialog keeps setup unfinished; you can continue from this page.',
'security.confirmAgain':
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
'security.setupError':
'Authenticator setup could not be loaded. Please try again.',
'security.recoveryError':
'Recovery codes could not be loaded. Please try again.',
'security.recoveryTitle': '2FA recovery codes',
'security.recoveryDescription':
'Recovery codes let you regain access if you lose your authenticator. Store them in a secure password manager.',
'security.hideRecovery': 'Hide recovery codes',
'security.viewRecovery': 'View recovery codes',
'security.loadingRecovery': 'Loading recovery codes…',
'security.regenerate': 'Regenerate codes',
'security.recoveryOnce':
'Each recovery code can be used once. Hiding this section removes the codes from this page.',
'security.regenerateTitle': 'Regenerate recovery codes?',
'security.regenerateDescription':
'All previous recovery codes will stop working. Save the new codes in a secure place.',
'applications.title': 'Applications',
'applications.description':
'Create API keys for tools that use KanbanGit with your account permissions. Delete an application to revoke its access.',
'applications.saveKey': 'Save this API key now.',
'applications.keyOnce':
'This key is shown only after creation. It will not be shown again after you leave this page.',
'applications.apiKey': 'API key',
'applications.create': 'Create application',
'applications.nameDescription':
'Name the tool that will receive an API key.',
'applications.name': 'Application name',
'applications.namePlaceholder': 'For example, Claude Desktop',
'applications.creating': 'Creating application…',
'applications.saved': 'Saved applications',
'applications.hiddenKeys':
'Existing keys stay hidden. Create a new application when another key is needed.',
'applications.empty': 'No applications have been created yet.',
'applications.created': 'Created:',
'applications.lastUsed': 'Last used:',
'applications.never': 'Never',
'applications.delete': 'Delete',
'applications.deleteNamed': 'Delete {name}',
'applications.deleteTitle': 'Delete application?',
'applications.deleteDescription':
'{name} will lose access immediately. This cannot be undone.',
'common.back': 'Back',
'common.done': 'Done',
'criteria.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.',
'criteria.required': 'Enter a criterion or remove this row.',
'criteria.edit': 'Edit checklist',
'criteria.preview': 'Preview checklist',
'criteria.item': 'Criterion {number}',
'criteria.complete': 'Complete criterion {number}: {text}',
'criteria.indent': 'Indent criterion {number}',
'criteria.outdent': 'Outdent criterion {number}',
'criteria.remove': 'Remove criterion {number}',
'criteria.add': 'Add criterion',
'criteria.hint':
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
'criteria.saving': 'Saving checklist…',
'criteria.saveError':
'Checklist status could not be saved. Please try again.',
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
@@ -340,6 +449,7 @@ export const en = {
'navigation.appearance': 'Appearance',
'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.',
'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.open': 'Open navigation',
@@ -354,8 +464,58 @@ export const en = {
'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme',
'appearance.light': 'Light',
'appearance.lightDescription':
'Use a bright workspace with dark text and clear borders.',
'appearance.dark': 'Dark',
'appearance.darkDescription':
'Reduce glare with a darker workspace and balanced contrast.',
'appearance.system': 'System',
'appearance.systemDescription':
'Follow the light or dark preference set on this device.',
'appearance.title': 'Appearance',
'appearance.description': 'Choose how KanbanGit looks on this device.',
'appearance.persistenceHint':
'Your choice is saved on this device and restored when you open KanbanGit again.',
'profile.title': 'Profile',
'profile.description':
'Keep your personal details and profile picture up to date.',
'profile.information': 'Profile information',
'profile.picture': 'Profile picture',
'profile.pictureDescription':
'Help teammates recognize you throughout the workspace.',
'profile.picturePreview': 'Profile picture preview',
'profile.choosePicture': 'Choose picture',
'profile.changePicture': 'Choose another',
'profile.discardPicture': 'Discard selection',
'profile.removePicture': 'Remove picture',
'profile.removingPicture': 'Removing picture…',
'profile.selectedPicture': 'Selected: {name}',
'profile.pictureHelp':
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
'profile.name': 'Name',
'profile.namePlaceholder': 'Full name',
'profile.email': 'Email address',
'profile.emailPlaceholder': 'Email address',
'profile.emailUnverified': 'Your email address is unverified.',
'profile.resendVerification': 'Resend the verification email',
'profile.verificationSent':
'A new verification link has been sent to your email address.',
'profile.save': 'Save profile',
'profile.saving': 'Saving profile…',
'profile.unsaved': 'You have unsaved profile changes.',
'profile.delete.title': 'Delete account',
'profile.delete.description':
'Permanently delete your account and all of its resources.',
'profile.delete.warning': 'This action cannot be undone',
'profile.delete.warningDescription':
'Your account and associated data will be permanently removed.',
'profile.delete.action': 'Delete account',
'profile.delete.confirmTitle':
'Are you sure you want to delete your account?',
'profile.delete.confirmDescription':
'Enter your password to confirm that you want to permanently delete your account and its data.',
'profile.delete.password': 'Password',
'profile.delete.confirmAction': 'Permanently delete account',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.retry': 'Try again',
@@ -437,9 +597,24 @@ export const en = {
'ticketDetail.checklistPlaceholder':
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
'ticketDetail.blocked': 'Mark ticket as blocked',
'ticketDetail.blocker': 'Blocker',
'ticketDetail.blockerDescription':
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
'ticketDetail.blockerActive': 'Blocked',
'ticketDetail.blockerInactive': 'Not blocked',
'ticketDetail.blockerActiveDescription':
'A blocker reason is required while this ticket is blocked.',
'ticketDetail.blockerInactiveDescription':
'This ticket currently has no active blocker.',
'ticketDetail.blockerComment': 'Blocker comment',
'ticketDetail.blockerCommentHelp':
'Explain the concrete dependency or decision preventing progress.',
'ticketDetail.blockerPlaceholder':
'Describe what currently blocks progress on this ticket.',
'ticketDetail.saveBlocker': 'Save blocker',
'ticketDetail.unblockTicket': 'Unblock ticket',
'ticketDetail.resetBlocker': 'Discard blocker changes',
'ticketDetail.savingBlocker': 'Saving blocker…',
'ticketDetail.customFields': 'Custom fields',
'ticketDetail.customFieldsDescription':
'Project-configured fields can be tracked directly on the ticket.',
@@ -457,8 +632,20 @@ export const en = {
'ticketDetail.branchName': 'Branch name',
'ticketDetail.branchAccess':
'You can only create branches in repositories that match one of your personal Git connections.',
'ticketDetail.gitAccessRequired': 'Personal Git access required',
'ticketDetail.personalAccessRequired': 'personal Git access required',
'ticketDetail.openGitConnections': 'Open Git connections',
'ticketDetail.createNow': 'Create now',
'ticketDetail.creatingBranch': 'Creating branch…',
'ticketDetail.noLinkedRepositories': 'No linked repositories',
'ticketDetail.noLinkedRepositoriesDescription':
'A project administrator needs to link a repository before Git activity can appear here.',
'ticketDetail.branchesUnavailable': 'Branches unavailable',
'ticketDetail.pullRequestsUnavailable': 'Pull requests unavailable',
'ticketDetail.openRepositoryExternal':
'Open {repository} on {provider} (opens in a new tab)',
'ticketDetail.openPullRequestExternal':
'Open pull request #{number} on {provider} (opens in a new tab)',
'ticketDetail.inspectBranches':
'Connect a matching Git account in your user settings to inspect branches for this repository.',
'ticketDetail.pullRequests': 'Pull requests',
@@ -493,10 +680,32 @@ export const en = {
'ticketDetail.noLinks':
'No ticket links are configured for this ticket yet.',
'ticketDetail.removeLink': 'Remove',
'ticketRelationships.hierarchy': 'Hierarchy',
'ticketRelationships.editParent': 'Change parent',
'ticketRelationships.parentHelp':
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
'ticketRelationships.childrenHelp':
'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes':
'No link types are configured for this project.',
'ticketRelationships.noTargets':
'No other tickets are available to link in this project.',
'ticketDetail.historyDescription':
'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System',
'ticketDetail.historyType.created': 'Ticket created',
'ticketDetail.historyType.updated': 'Ticket updated',
'ticketDetail.historyType.statusChanged': 'Status changed',
'ticketDetail.historyType.linkUpdated': 'Link added',
'ticketDetail.historyType.linkRemoved': 'Link removed',
'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket',
@@ -522,4 +731,121 @@ export const en = {
'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.',
'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.',
} as const;
+12 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
@@ -14,6 +15,7 @@ import { show as showTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const { t } = useLocalization();
const ticketContent = useTemplateRef('ticketContent');
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
@@ -49,6 +51,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
>
<TicketDetailContent
ref="ticketContent"
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
@@ -62,7 +65,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
/>
<aside
:aria-label="t('ticketDetail.metadata')"
class="min-w-0 xl:col-start-2 xl:row-span-4 xl:row-start-1"
class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
>
<TicketDetailMetadata
:ticket="ticket"
@@ -71,6 +74,12 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:ticket-field-definitions="ticketFieldDefinitions"
/>
</aside>
<TicketDetailBlocker
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
class="xl:col-start-1"
/>
<TicketDetailRelationships
:ticket="ticket"
:project="project"
@@ -80,6 +89,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:linked-tickets="linkedTickets"
:ticket-link-types="ticketLinkTypes"
:ticket-link-options="ticketLinkOptions"
@edit-parent="ticketContent?.editParent()"
class="xl:col-start-1"
/>
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
+19 -71
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { useSessionStorage } from '@vueuse/core';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue';
@@ -17,10 +17,8 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import {
buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
@@ -71,19 +69,9 @@ const hasTicketMetadata = computed(
);
const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
acceptance_criteria: [] as AcceptanceCriteriaModel,
assignee_user_id: '',
description: '',
parent_ticket_id: '',
@@ -96,24 +84,17 @@ const form = useForm(draftKey, {
title: '',
});
const isDirty = computed(
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
);
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const isDirty = computed(() => form.isDirty);
const protection = useFormProtection(form);
const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaChecklist.value = '';
protection.clearFeedback();
};
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
@@ -128,17 +109,19 @@ const submit = (): void => {
if (!hasTicketMetadata.value || form.processing) {
return;
}
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true,
onSuccess: () => {
// The source page is unmounted before this callback, so its storage watcher has stopped.
window.sessionStorage.setItem(
`${draftKey}:generation`,
String(draftGeneration.value + 1),
);
draftGeneration.value++;
form.reset();
acceptanceCriteriaChecklist.value = '';
},
},
);
@@ -224,46 +207,11 @@ const submit = (): void => {
autofocus
>
<template #content>
<TicketFormField
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
<AcceptanceCriteriaChecklist
v-model="form.acceptance_criteria"
:disabled="form.processing"
:error="acceptanceCriteriaError"
/>
<div
v-if="acceptanceCriteriaPreviewItems.length"
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
{{ t('ticketForm.checklistPreview') }}
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<span aria-hidden="true">{{
item.checked ? '☑' : '☐'
}}</span>
<span
class="wrap-anywhere min-w-0"
>{{ item.text }}</span
>
</li>
</ul>
</div>
</template>
</TicketFormFields>
<div
+393 -311
View File
@@ -1,281 +1,316 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { Layers3, Trash2 } from 'lucide-vue-next';
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 TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects/index';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { general } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps<ProjectGeneralSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'General settings',
href: generalSettings(props.project.id),
},
];
const switchTemplateForm = useForm({
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.general'), href: general(props.project.id) },
]);
const switchTemplateForm = useForm<{
confirmation_key: string;
source_template_id: number | undefined;
}>({
confirmation_key: '',
source_template_id: props.sourceTemplates.find(
(template) => template.id !== props.project.project_template_id,
)?.id,
source_template_id: undefined,
});
const deleteForm = useForm({
confirmation_key: '',
});
const deleteForm = useForm({ confirmation_key: '' });
const switchProtection = useFormProtection(switchTemplateForm);
const deleteProtection = useFormProtection(deleteForm);
const processing = computed(
() => switchTemplateForm.processing || deleteForm.processing,
);
const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id,
),
);
const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed(
() =>
isSwitchConfirmationValid.value &&
switchTemplateForm.source_template_id !== undefined,
switchTemplateForm.confirmation_key === props.project.key &&
availableSwitchTemplates.value.some(
(template) => template.id === switchTemplateForm.source_template_id,
),
);
const isDeleteConfirmationValid = computed(
() => 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 => {
if (!canSwitchTemplate.value) {
function submitSwitchTemplate(): void {
if (!canSwitchTemplate.value || processing.value) {
return;
}
switchProtection.submit(switchTemplate(props.project.id), {
preserveScroll: true,
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
});
}
switchTemplateForm.post(
ProjectController.switchTemplate.url(props.project.id),
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) {
function submitDelete(): void {
if (!isDeleteConfirmationValid.value || processing.value) {
return;
}
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
deleteProtection.submit(destroy(props.project.id), {
preserveScroll: true,
});
};
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} general settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<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
<Head
:title="t('projectSettings.pageTitle', { project: project.name })"
/>
<SettingsLayout :project-name="project.name" :project-key="project.key">
<FeedbackState
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 }}
</div>
<Card>
<CardHeader>
<CardTitle>Project identity</CardTitle>
<CardDescription>
These values define the project across tickets, boards,
and repository matching.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('navigation.general') }}</h2>
<p class="text-muted-foreground">
{{ t('projectSettings.generalDescription') }}
</p>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Project name
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.name }}
</p>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('projectSettings.identity') }}
</h3>
<Badge variant="secondary">{{
t('projectSettings.local')
}}</Badge>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
<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.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>
<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>
</Card>
<Card>
<CardHeader>
<CardTitle>Template baseline</CardTitle>
<CardDescription>
Switch this project to another published template. Use
the inheritance panels to reset individual settings to
the currently linked template.
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
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>
<TemplateInheritancePanel
:project-id="project.id"
:areas="inheritanceAreas"
:template-state="templateState"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.template') }}
</h2>
</div>
<div class="grid gap-6">
<form
class="space-y-4 rounded-xl border p-4"
@submit.prevent="submitSwitchTemplate"
<p class="text-muted-foreground">
{{ t('projectSettings.templateDescription') }}
</p>
</CardHeader>
<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">
<h3 class="text-sm font-semibold">
Switch to another template
</h3>
<p class="text-muted-foreground text-sm">
Applying a different template replaces the
copied areas with that template's current
baseline.
</p>
</div>
{{ t('projectSettings.standaloneDescription') }}
</p>
</div>
<FeedbackState
v-if="availableSwitchTemplates.length === 0"
state="empty"
:title="t('projectSettings.noAlternatives')"
:description="
t('projectSettings.noAlternativesDescription')
"
/>
<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">
<Label for="source_template_id"
>Published template</Label
>
<Label for="source_template_id">{{
t('projectSettings.publishedTemplate')
}}</Label>
<select
id="source_template_id"
v-model="
switchTemplateForm.source_template_id
"
:disabled="
switchTemplateForm.processing ||
availableSwitchTemplates.length === 0
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="
!!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>
Select a template
{{
t('projectSettings.selectTemplate')
}}
</option>
<option
v-for="template in availableSwitchTemplates"
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
</option>
</select>
<InputError
id="source-template-error"
:message="
switchTemplateForm.errors
.source_template_id
"
/>
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key"
>Confirmation key</Label
>
<Label for="switch_confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="switch_confirmation_key"
v-model="
switchTemplateForm.confirmation_key
"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
autocomplete="off"
: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
id="switch-confirm-error"
:message="
switchTemplateForm.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">
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
type="submit"
variant="destructive"
:disabled="
deleteForm.processing ||
!isDeleteConfirmationValid
"
variant="outline"
class="justify-self-start"
:disabled="!canSwitchTemplate"
>{{
t(
switchTemplateForm.processing
? 'projectSettings.switching'
: 'projectSettings.switch',
)
}}</Button
>
Delete project
</Button>
</div>
</fieldset>
</form>
</CardContent>
</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>
</template>
+22 -15
View File
@@ -1,35 +1,42 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance/index';
import type { BreadcrumbItem } from '@/types';
import { computed } from 'vue';
const breadcrumbItems: BreadcrumbItem[] = [
const { t } = useLocalization();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{
title: 'Appearance settings',
title: t('navigation.appearance'),
href: edit(),
},
];
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" />
<h1 class="sr-only">Appearance settings</h1>
<Head :title="t('appearance.title')" />
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update your account's appearance settings"
/>
<AppearanceTabs />
</div>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('appearance.title') }}</h2>
<p class="text-muted-foreground">
{{ t('appearance.description') }}
</p>
</CardHeader>
<CardContent class="gap-column grid p-5 sm:p-6">
<AppearanceTabs />
<p class="text-caption text-muted-foreground">
{{ t('appearance.persistenceHint') }}
</p>
</CardContent>
</Card>
</SettingsLayout>
</AppLayout>
</template>
+290 -170
View File
@@ -1,198 +1,318 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Head, useForm } from '@inertiajs/vue3';
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
destroy,
store,
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editApplications } from '@/routes/applications/index';
import { edit } from '@/routes/applications/index';
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
defineProps<SettingsApplicationsPageProps>();
const props = defineProps<SettingsApplicationsPageProps>();
const { t, locale } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.applications'), href: edit() },
]);
const form = useForm({ name: '' });
const protection = useFormProtection(form);
const deleteForm = useForm({});
const deleteProtection = useFormProtection(deleteForm);
const selected = ref<
SettingsApplicationsPageProps['applications'][number] | null
>(null);
const copied = ref(false);
const copyError = ref(false);
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Applications',
href: editApplications(),
},
];
const formatDate = (value: string | null): string => {
if (value === null) {
return 'Never';
function formatDate(value: string | null): string {
if (!value) {
return t('applications.never');
}
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
}
function createApplication(): void {
protection.submit(store(), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
}
function closeDelete(open: boolean): void {
if (!open && !deleteForm.processing) {
selected.value = null;
deleteProtection.clearFeedback();
}
}
function deleteApplication(): void {
if (!selected.value) {
return;
}
deleteProtection.submit(destroy(selected.value.id), {
preserveScroll: true,
onSuccess: () => {
selected.value = null;
},
});
}
async function copyKey(): Promise<void> {
const key = props.applicationKey;
if (!key) {
return;
}
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
if (props.applicationKey === key) {
copied.value = true;
}
} catch {
if (props.applicationKey === key) {
copyError.value = true;
}
}
}
watch(
() => props.applicationKey,
() => {
copied.value = false;
copyError.value = false;
},
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Applications" />
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('applications.title')" />
<SettingsLayout>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Heading
variant="small"
title="Applications"
description="Create API keys for tools that should use KanbanGit with your account permissions."
/>
<p class="text-muted-foreground text-sm">
Keys are shown once. Delete an application when a tool
should lose access.
</p>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<div
v-if="applicationKey"
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
>
<p class="font-medium">Save this API key now.</p>
<p class="mt-1">
It will not be shown again after you leave this page.
</p>
<div class="gap-stack grid">
<h2 class="text-title">{{ t('applications.title') }}</h2>
<p class="text-muted-foreground">
{{ t('applications.description') }}
</p>
</div>
<p
v-if="status"
role="status"
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
>
{{ status }}
</p>
<section
v-if="applicationKey"
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
aria-labelledby="application-key-title"
>
<h3 id="application-key-title" class="font-semibold">
{{ t('applications.saveKey') }}
</h3>
<p class="text-body text-muted-foreground">
{{ t('applications.keyOnce') }}
</p>
<Label for="application-key" class="sr-only">{{
t('applications.apiKey')
}}</Label>
<div class="flex min-w-0 flex-wrap gap-2">
<Input
class="mt-3 font-mono text-xs"
id="application-key"
:model-value="applicationKey"
readonly
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
/>
<Button variant="outline" @click="copyKey"
><Check v-if="copied" /><Copy v-else />{{
t(copied ? 'security.copied' : 'security.copyKey')
}}</Button
>
</div>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Create application</CardTitle>
<CardDescription>
Name the tool that will receive an API key.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="UserApplicationController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
<p
v-if="copied"
role="status"
class="text-caption text-success-foreground"
>
{{ t('security.copied') }}
</p>
<InputError
v-if="copyError"
:message="t('security.copyError')"
/>
</section>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h3 class="flex items-center gap-2 font-semibold">
<KeyRound class="text-primary size-5" />{{
t('applications.create')
}}
</h3>
<p class="text-muted-foreground">
{{ t('applications.nameDescription') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid"
:aria-busy="form.processing"
@submit.prevent="createApplication"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('applications.creating')"
@retry="createApplication"
/>
<div class="grid gap-2">
<Label for="application-name">{{
t('applications.name')
}}</Label>
<Input
id="application-name"
v-model="form.name"
name="name"
required
maxlength="255"
autocomplete="off"
:placeholder="t('applications.namePlaceholder')"
:disabled="form.processing"
:aria-invalid="!!form.errors.name"
aria-describedby="application-name-error"
/>
<InputError
id="application-name-error"
:message="form.errors.name"
/>
</div>
<Button
type="submit"
class="justify-self-start"
:loading="form.processing"
:loading-label="t('applications.creating')"
>{{ t('applications.create') }}</Button
>
</form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
<p class="text-muted-foreground">
{{ t('applications.hiddenKeys') }}
</p>
</CardHeader>
<CardContent class="gap-stack grid p-5 sm:p-6">
<p
v-if="!applications.length"
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
>
{{ t('applications.empty') }}
</p>
<article
v-for="application in applications"
:key="application.id"
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
>
<div class="gap-stack grid min-w-0 flex-1 basis-40">
<h4 class="break-words font-semibold">
{{ application.name }}
</h4>
<dl
class="gap-stack text-caption text-muted-foreground grid"
>
<div class="grid gap-2">
<Label for="name">Application name</Label>
<Input
id="name"
name="name"
type="text"
required
autofocus
placeholder="Claude Desktop"
/>
<InputError :message="errors.name" />
<div>
<dt class="inline font-medium">
{{ t('applications.created') }}
</dt>
<dd class="inline">
{{ formatDate(application.created_at) }}
</dd>
</div>
<Button type="submit" :disabled="processing">
Create application
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Saved applications</CardTitle>
<CardDescription>
Existing keys stay hidden. Create a new
application when another key is needed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="applications.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No applications have been created yet.
</div>
<div
v-for="application in applications"
:key="application.id"
class="border-border rounded-xl border p-4"
>
<div
class="flex flex-wrap items-start justify-between gap-4"
>
<div class="space-y-2">
<div
class="flex flex-wrap items-center gap-2"
>
<Badge>API key</Badge>
<span class="text-sm font-medium">
{{ application.name }}
</span>
</div>
<p
class="text-muted-foreground text-xs"
>
Created:
{{
formatDate(
application.created_at,
)
}}
</p>
<p
class="text-muted-foreground text-xs"
>
Last used:
{{
formatDate(
application.last_used_at,
)
}}
</p>
</div>
<Form
v-bind="
UserApplicationController.destroy.form(
application.id,
)
"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Delete
</Button>
</Form>
<div>
<dt class="inline font-medium">
{{ t('applications.lastUsed') }}
</dt>
<dd class="inline">
{{
formatDate(application.last_used_at)
}}
</dd>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</dl>
</div>
<Button
variant="outline"
class="text-destructive"
:aria-label="
t('applications.deleteNamed', {
name: application.name,
})
"
@click="selected = application"
><Trash2 />{{ t('applications.delete') }}</Button
>
</article>
</CardContent>
</Card>
<Dialog :open="!!selected" @update:open="closeDelete">
<DialogContent
:show-close-button="!deleteForm.processing"
@escape-key-down="
deleteForm.processing && $event.preventDefault()
"
@interact-outside="
deleteForm.processing && $event.preventDefault()
"
>
<DialogHeader>
<DialogTitle>{{
t('applications.deleteTitle')
}}</DialogTitle>
<DialogDescription>{{
t('applications.deleteDescription', {
name: selected?.name ?? '',
})
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="deleteProtection.state.value"
:response-status="deleteProtection.responseStatus.value"
@retry="deleteApplication"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="deleteForm.processing"
@click="closeDelete(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="deleteForm.processing"
@click="deleteApplication"
>{{ t('applications.delete') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsLayout>
</AppLayout>
</template>
+464 -238
View File
@@ -1,267 +1,493 @@
<script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import Heading from '@/components/Heading.vue';
import { Head, useForm } from '@inertiajs/vue3';
import {
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { edit } from '@/routes/git-connections/index';
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();
const appName = String(page.props.name);
function resetForms(): void {
connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
connectionForm.resetAndClearErrors();
connectionProtection.clearFeedback();
removalForm.resetAndClearErrors();
removalProtection.clearFeedback();
}
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Git connections',
href: editGitConnections(),
},
];
function openDialog(
nextMode: typeof mode.value,
connection: UserGitConnectionModel | null = null,
): 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>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Git connections" />
<Head :title="t('navigation.gitConnections')" />
<SettingsLayout>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Heading
variant="small"
title="Git connections"
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"
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
>
{{ status }}
</div>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Add Gitea connection</CardTitle>
<CardDescription>
Connect a Gitea account by storing the instance
base URL and a personal application token.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="GitConnectionController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
>
<input
name="provider"
type="hidden"
value="gitea"
/>
<div class="grid gap-2">
<Label for="base_url">Gitea base URL</Label>
<Input
id="base_url"
name="base_url"
type="url"
required
autofocus
placeholder="https://git.example.com"
/>
<InputError :message="errors.base_url" />
</div>
<div class="grid gap-2">
<Label for="api_token"
>Application token</Label
>
<Input
id="api_token"
name="api_token"
type="password"
required
placeholder="Paste your Gitea application key"
/>
<InputError :message="errors.api_token" />
</div>
<Button type="submit" :disabled="processing">
Add connection
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Saved connections</CardTitle>
<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
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('navigation.gitConnections') }}
</h2>
<p class="text-muted-foreground">
{{ t('gitConnections.description') }}
</p>
</div>
<Button
type="button"
class="shrink-0 self-start"
@click="openDialog('create')"
>
<Plus aria-hidden="true" />{{
t('gitConnections.add')
}}
</Button>
</div>
<div
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
>
<LockKeyhole
class="text-primary mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>{{ t('gitConnections.privacy') }}</p>
</div>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<FeedbackState
v-if="status"
state="success"
:title="successTitle"
/>
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('gitConnections.saved') }}
</h3>
<Badge variant="secondary">{{
gitConnections.length
}}</Badge>
</div>
<FeedbackState
v-if="gitConnections.length === 0"
state="empty"
:title="t('gitConnections.empty')"
:description="t('gitConnections.emptyDescription')"
/>
<ul
v-else
class="gap-column grid min-w-0"
:aria-label="t('gitConnections.saved')"
>
<li
v-for="connection in gitConnections"
:key="connection.id"
class="gap-column grid min-w-0 rounded-lg border p-4"
>
<div class="flex min-w-0 items-start gap-3">
<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">
<div
class="flex flex-wrap items-center gap-2"
<GitBranch
class="size-5"
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>{{
gitConnection.provider_label
}}</Badge>
<span class="text-sm font-medium">
{{
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>
{{ connection.account_login }}
</h4>
<Badge variant="secondary">{{
connection.provider_label
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
Remote user ID:
{{ gitConnection.provider_user_id }}
<p
class="text-caption text-muted-foreground break-words"
>
{{
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>
</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>
</CardContent>
</Card>
</div>
</div>
<div
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
>
<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>
</AppLayout>
</template>
+330 -154
View File
@@ -1,209 +1,385 @@
<script setup lang="ts">
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref } from 'vue';
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification';
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { UserModel } from '@/types/models/user-model';
defineProps<SettingsProfilePageProps>();
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Profile settings',
href: edit(),
},
];
const { t } = useLocalization();
const page = usePage();
const user = computed(() => page.props.auth.user);
const avatarPreview = ref<string | null>(null);
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{
title: t('navigation.profile'),
href: edit(),
},
]);
const profileForm = useForm('SettingsProfile', {
name: user.value.name,
email: user.value.email,
avatar: null as File | null,
});
const profileProtection = useFormProtection(profileForm);
const avatarDeleteForm = useForm({});
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
const avatarPreview = ref<string | null>(null);
const displayedAvatar = computed(
() => avatarPreview.value ?? user.value.avatar,
);
const displayedUser = computed<UserModel>(() => ({
...user.value,
avatar: displayedAvatar.value,
}));
const canRemoveAvatar = computed(
() => user.value.avatar !== null && !user.value.avatar_is_generated,
() =>
avatarPreview.value === null &&
user.value.avatar !== null &&
!user.value.avatar_is_generated,
);
const profileUpdateForm = ProfileController.update.form();
const profileUpdateTarget = {
url: profileUpdateForm.action,
method: profileUpdateForm.method,
};
const clearAvatarPreview = (): void => {
function clearAvatarPreview(): void {
if (avatarPreview.value) {
URL.revokeObjectURL(avatarPreview.value);
avatarPreview.value = null;
}
};
}
const handleAvatarChange = (event: Event) => {
function clearAvatarSelection(): void {
clearAvatarPreview();
profileForm.avatar = null;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (avatarInput.value) {
avatarInput.value.value = '';
}
}
function handleAvatarChange(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const file = input.files?.[0] ?? null;
clearAvatarPreview();
profileForm.avatar = file;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (file) {
avatarPreview.value = URL.createObjectURL(file);
}
};
}
const removeAvatar = (): void => {
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
function submitProfile(): void {
profileProtection.submit(profileUpdateTarget, {
forceFormData: true,
preserveScroll: true,
onSuccess: clearAvatarPreview,
onSuccess: () => {
profileForm.defaults({
name: profileForm.name,
email: profileForm.email,
avatar: null,
});
clearAvatarSelection();
},
});
};
}
onBeforeUnmount(() => {
clearAvatarPreview();
});
function removeAvatar(): void {
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
preserveScroll: true,
onSuccess: clearAvatarSelection,
});
}
onBeforeUnmount(clearAvatarPreview);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" />
<h1 class="sr-only">Profile settings</h1>
<Head :title="t('profile.title')" />
<SettingsLayout>
<div class="flex flex-col space-y-6">
<Heading
variant="small"
title="Profile information"
description="Update your name, email address, and profile picture"
/>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('profile.title') }}</h2>
<p class="text-muted-foreground">
{{ t('profile.description') }}
</p>
</CardHeader>
<Form
v-bind="ProfileController.update.form()"
class="space-y-6"
v-slot="{
errors,
processing,
progress,
recentlySuccessful,
}"
>
<div class="grid gap-3">
<Label for="avatar">Profile picture</Label>
<div class="flex items-center gap-4">
<UserAvatar class="size-16" />
<div class="grid flex-1 gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<Input
id="avatar"
name="avatar"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="handleAvatarChange"
/>
<Button
v-if="canRemoveAvatar"
type="button"
variant="outline"
:disabled="avatarDeleteForm.processing"
data-test="delete-profile-picture-button"
@click="removeAvatar"
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid min-w-0"
:aria-busy="profileForm.processing"
@submit.prevent="submitProfile"
>
<FormFeedback
:state="profileProtection.state.value"
:title="
profileForm.processing
? t('profile.saving')
: undefined
"
@retry="submitProfile"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="profileForm.processing"
>
<legend class="sr-only">
{{ t('profile.information') }}
</legend>
<section
aria-labelledby="profile-picture-title"
class="gap-column grid min-w-0 border-b pb-6"
>
<div class="gap-stack grid">
<h3
id="profile-picture-title"
class="font-semibold"
>
<Trash2 class="size-4" />
Remove
</Button>
{{ t('profile.picture') }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureDescription') }}
</p>
</div>
<p class="text-muted-foreground text-sm">
PNG, JPG, or WebP up to 2 MB.
<div
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
>
<UserAvatar
:user="displayedUser"
size="xl"
class="ring-border shrink-0 ring-1"
:label="t('profile.picturePreview')"
/>
<div class="gap-stack grid min-w-0 flex-1">
<div class="flex flex-wrap gap-2">
<Button as-child variant="outline">
<label
for="avatar"
class="cursor-pointer"
>
<Camera
aria-hidden="true"
/>
{{
avatarPreview
? t(
'profile.changePicture',
)
: t(
'profile.choosePicture',
)
}}
</label>
</Button>
<Button
v-if="avatarPreview"
type="button"
variant="ghost"
@click="clearAvatarSelection"
>
<RotateCcw aria-hidden="true" />
{{
t('profile.discardPicture')
}}
</Button>
<Button
v-else-if="canRemoveAvatar"
type="button"
variant="ghost"
:disabled="
avatarDeleteForm.processing
"
data-test="delete-profile-picture-button"
@click="removeAvatar"
>
<Trash2 aria-hidden="true" />
{{ t('profile.removePicture') }}
</Button>
</div>
<input
id="avatar"
ref="avatarInput"
name="avatar"
type="file"
accept="image/png,image/jpeg,image/webp"
class="sr-only"
aria-describedby="avatar-help avatar-error"
:aria-invalid="
!!profileForm.errors.avatar
"
@change="handleAvatarChange"
/>
<p
v-if="profileForm.avatar"
class="text-caption min-w-0 break-all font-medium"
>
{{
t('profile.selectedPicture', {
name: profileForm.avatar
.name,
})
}}
</p>
<p
id="avatar-help"
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureHelp') }}
</p>
<progress
v-if="profileForm.progress"
class="h-1 w-full overflow-hidden rounded-full"
:value="
profileForm.progress.percentage
"
max="100"
>
{{
profileForm.progress.percentage
}}%
</progress>
<InputError
id="avatar-error"
:message="profileForm.errors.avatar"
/>
<FormFeedback
:state="
avatarDeleteProtection.state
.value
"
:title="
avatarDeleteForm.processing
? t(
'profile.removingPicture',
)
: undefined
"
@retry="removeAvatar"
/>
</div>
</div>
</section>
<div class="grid gap-2">
<Label for="name">{{
t('profile.name')
}}</Label>
<Input
id="name"
v-model="profileForm.name"
name="name"
required
autocomplete="name"
:placeholder="t('profile.namePlaceholder')"
:aria-invalid="!!profileForm.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="profileForm.errors.name"
/>
</div>
<div class="grid gap-2">
<Label for="email">{{
t('profile.email')
}}</Label>
<Input
id="email"
v-model="profileForm.email"
type="email"
name="email"
required
autocomplete="username"
:placeholder="t('profile.emailPlaceholder')"
:aria-invalid="!!profileForm.errors.email"
aria-describedby="email-error"
/>
<InputError
id="email-error"
:message="profileForm.errors.email"
/>
</div>
<div
v-if="
mustVerifyEmail && !user.email_verified_at
"
class="bg-muted/55 rounded-lg border p-4"
>
<p class="text-body text-muted-foreground">
{{ t('profile.emailUnverified') }}
<Link
:href="send()"
as="button"
class="text-primary font-medium underline underline-offset-4"
>
{{ t('profile.resendVerification') }}
</Link>
</p>
<p
v-if="status === 'verification-link-sent'"
role="status"
class="text-success-foreground mt-2 text-sm font-medium"
>
{{ t('profile.verificationSent') }}
</p>
</div>
</div>
<progress
v-if="progress"
class="h-1 w-full overflow-hidden rounded-full"
:value="progress.percentage"
max="100"
>
{{ progress.percentage }}%
</progress>
<InputError class="mt-1" :message="errors.avatar" />
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
class="mt-1 block w-full"
name="name"
:default-value="user.name"
required
autocomplete="name"
placeholder="Full name"
/>
<InputError class="mt-2" :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
class="mt-1 block w-full"
name="email"
:default-value="user.email"
required
autocomplete="username"
placeholder="Email address"
/>
<InputError class="mt-2" :message="errors.email" />
</div>
<div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="text-muted-foreground -mt-4 text-sm">
Your email address is unverified.
<Link
:href="send()"
as="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
>
Click here to resend the verification email.
</Link>
</p>
<div
v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email
address.
</div>
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-profile-button"
>Save</Button
>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button
type="submit"
:disabled="profileForm.processing"
data-test="update-profile-button"
>
{{
profileForm.processing
? t('profile.saving')
: t('profile.save')
}}
</Button>
<p
v-if="profileForm.isDirty"
class="text-caption text-muted-foreground"
>
{{ t('profile.unsaved') }}
</p>
</div>
</fieldset>
</form>
</CardContent>
</Card>
<DeleteUser />
</SettingsLayout>
+309 -191
View File
@@ -1,222 +1,340 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted, ref } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/Heading.vue';
import { computed, onUnmounted, ref } from 'vue';
import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/security/index';
import { disable, enable } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types';
type Props = {
canManageTwoFactor?: boolean;
mustChangePassword?: boolean;
requiresConfirmation?: boolean;
status?: string | null;
twoFactorEnabled?: boolean;
};
withDefaults(defineProps<Props>(), {
canManageTwoFactor: false,
mustChangePassword: false,
requiresConfirmation: false,
status: null,
twoFactorEnabled: false,
});
const breadcrumbs: BreadcrumbItem[] = [
const props = withDefaults(
defineProps<{
canManageTwoFactor?: boolean;
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
}>(),
{
title: 'Security settings',
href: edit(),
canManageTwoFactor: false,
requiresConfirmation: false,
twoFactorEnabled: false,
},
];
);
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.security'), href: edit() },
]);
const passwordForm = useForm({
current_password: '',
password: '',
password_confirmation: '',
}).dontRemember('current_password', 'password', 'password_confirmation');
const passwordProtection = useFormProtection(passwordForm, {
isDirty: () => false,
});
const twoFactorForm = useForm({});
const twoFactorProtection = useFormProtection(twoFactorForm);
const showSetupModal = ref(false);
const showDisableDialog = ref(false);
const setupStarted = ref(false);
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
const showSetupModal = ref<boolean>(false);
onUnmounted(() => clearTwoFactorAuthData());
function savePassword(): void {
passwordProtection.submit(update(), {
preserveScroll: true,
onFinish: () => passwordForm.reset(),
});
}
function startSetup(): void {
if (setupStarted.value) {
showSetupModal.value = true;
return;
}
twoFactorProtection.submit(enable(), {
preserveScroll: true,
onSuccess: () => {
setupStarted.value = true;
showSetupModal.value = true;
},
});
}
function disableTwoFactor(): void {
twoFactorProtection.submit(disable(), {
preserveScroll: true,
onSuccess: () => {
showDisableDialog.value = false;
setupStarted.value = false;
},
});
}
function updateDisableDialog(open: boolean): void {
if (!twoFactorForm.processing) {
showDisableDialog.value = open;
twoFactorProtection.clearFeedback();
}
}
onUnmounted(() => passwordForm.reset());
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Security settings" />
<h1 class="sr-only">Security settings</h1>
<Head :title="t('security.title')" />
<SettingsLayout>
<div
v-if="mustChangePassword || status"
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{
mustChangePassword
? 'You must change your password before continuing anywhere else in the app.'
: status
}}
</div>
<div class="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<Form
v-bind="SecurityController.update.form()"
:options="{
preserveScroll: true,
}"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="current_password">Current password</Label>
<PasswordInput
id="current_password"
name="current_password"
class="mt-1 block w-full"
autocomplete="current-password"
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label for="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm password</Label
>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-password-button"
>
Save password
</Button>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
<div v-if="canManageTwoFactor" class="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
/>
<div
v-if="!twoFactorEnabled"
class="flex flex-col items-start justify-start space-y-4"
>
<p class="text-muted-foreground text-sm">
When you enable two-factor authentication, you will be
prompted for a secure pin during login. This pin can be
retrieved from a TOTP-supported application on your
phone.
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">
{{ t('security.passwordTitle') }}
</h2>
<p class="text-muted-foreground">
{{ t('security.passwordDescription') }}
</p>
<div>
<Button
v-if="hasSetupData"
@click="showSetupModal = true"
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid"
:aria-busy="passwordForm.processing"
@submit.prevent="savePassword"
>
<AuthFormFeedback
:state="passwordProtection.state.value"
:response-status="
passwordProtection.responseStatus.value
"
:loading-title="t('security.savingPassword')"
:success-title="t('security.passwordSaved')"
@retry="savePassword"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="passwordForm.processing"
>
<ShieldCheck />Continue setup
</Button>
<Form
v-else
v-bind="enable.form()"
@success="showSetupModal = true"
#default="{ processing }"
>
<Button type="submit" :disabled="processing">
Enable 2FA
</Button>
</Form>
</div>
</div>
<div
v-else
class="flex flex-col items-start justify-start space-y-4"
>
<p class="text-muted-foreground text-sm">
You will be prompted for a secure, random pin during
login, which you can retrieve from the TOTP-supported
application on your phone.
</p>
<div class="relative inline">
<Form v-bind="disable.form()" #default="{ processing }">
<legend class="sr-only">
{{ t('security.passwordTitle') }}
</legend>
<div class="grid gap-2">
<Label for="current_password">{{
t('security.currentPassword')
}}</Label>
<PasswordInput
id="current_password"
v-model="passwordForm.current_password"
name="current_password"
required
autocomplete="current-password"
:disabled="passwordForm.processing"
:aria-invalid="
!!passwordForm.errors.current_password
"
aria-describedby="current-password-error"
/>
<InputError
id="current-password-error"
:message="
passwordForm.errors.current_password
"
/>
</div>
<div class="grid gap-2">
<Label for="password">{{
t('security.newPassword')
}}</Label>
<PasswordInput
id="password"
v-model="passwordForm.password"
name="password"
required
autocomplete="new-password"
:disabled="passwordForm.processing"
:aria-invalid="
!!passwordForm.errors.password
"
aria-describedby="new-password-error"
/>
<InputError
id="new-password-error"
:message="passwordForm.errors.password"
/>
</div>
<div class="grid gap-2">
<Label for="password_confirmation">{{
t('security.confirmPassword')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="passwordForm.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="passwordForm.processing"
:aria-invalid="
!!passwordForm.errors
.password_confirmation
"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="
passwordForm.errors
.password_confirmation
"
/>
</div>
<Button
variant="destructive"
type="submit"
:disabled="processing"
class="justify-self-start"
:loading="passwordForm.processing"
:loading-label="t('security.savingPassword')"
data-test="update-password-button"
>{{ t('security.savePassword') }}</Button
>
Disable 2FA
</Button>
</Form>
</fieldset>
</form>
</CardContent>
</Card>
<Card
v-if="canManageTwoFactor"
class="gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<div
class="flex flex-wrap items-center justify-between gap-3"
>
<h2 class="text-title flex items-center gap-2">
<ShieldCheck class="text-primary size-5" />{{
t('security.twoFactorTitle')
}}
</h2>
<span
class="bg-muted rounded-full px-3 py-1 text-xs font-semibold"
role="status"
>{{
t(
props.twoFactorEnabled
? 'security.enabled'
: setupStarted
? 'security.pending'
: 'security.disabled',
)
}}</span
>
</div>
<TwoFactorRecoveryCodes />
</div>
<TwoFactorSetupModal
v-model:isOpen="showSetupModal"
:requiresConfirmation="requiresConfirmation"
:twoFactorEnabled="twoFactorEnabled"
/>
</div>
<p class="text-muted-foreground">
{{
t(
props.twoFactorEnabled
? 'security.enabledDescription'
: 'security.twoFactorDescription',
)
}}
</p>
</CardHeader>
<CardContent class="gap-column grid p-5 sm:p-6">
<AuthFormFeedback
v-if="!showDisableDialog"
:state="twoFactorProtection.state.value"
:response-status="
twoFactorProtection.responseStatus.value
"
@retry="
props.twoFactorEnabled
? (showDisableDialog = true)
: startSetup()
"
/>
<Button
v-if="!twoFactorEnabled"
class="justify-self-start"
:loading="twoFactorForm.processing"
@click="startSetup"
>{{
t(
setupStarted
? 'security.continueSetup'
: 'security.enable',
)
}}</Button
>
<template v-else>
<Button
variant="outline"
class="text-destructive justify-self-start"
@click="showDisableDialog = true"
>{{ t('security.disable') }}</Button
>
<TwoFactorRecoveryCodes />
</template>
</CardContent>
</Card>
<TwoFactorSetupModal
v-if="canManageTwoFactor"
v-model:is-open="showSetupModal"
:requires-confirmation="requiresConfirmation"
:two-factor-enabled="twoFactorEnabled"
/>
<Dialog
:open="showDisableDialog"
@update:open="updateDisableDialog"
>
<DialogContent
:show-close-button="!twoFactorForm.processing"
@escape-key-down="
twoFactorForm.processing && $event.preventDefault()
"
@interact-outside="
twoFactorForm.processing && $event.preventDefault()
"
>
<DialogHeader>
<DialogTitle>{{
t('security.disableTitle')
}}</DialogTitle>
<DialogDescription>{{
t('security.disableDescription')
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="twoFactorProtection.state.value"
:response-status="
twoFactorProtection.responseStatus.value
"
@retry="disableTwoFactor"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="twoFactorForm.processing"
@click="updateDisableDialog(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="twoFactorForm.processing"
@click="disableTwoFactor"
>{{ t('security.disable') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsLayout>
</AppLayout>
</template>
+23
View File
@@ -1,4 +1,5 @@
import type { ProjectTicketPageProps } from '@/types/page-props';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
export type TicketDetailHeaderProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
@@ -15,6 +16,10 @@ export type TicketDetailContentProps = Pick<
| 'ticketTypes'
| 'users'
>;
export type TicketDetailBlockerProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailMetadataProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
@@ -28,6 +33,24 @@ export type TicketDetailGitProps = Pick<
| 'repositoryPullRequests'
| 'suggestedBranchName'
>;
export type TicketGitBranchesProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'repositoryBranches'
| 'suggestedBranchName'
>;
export type TicketGitPullRequestsProps = Pick<
ProjectTicketPageProps,
'repositoryPullRequests'
>;
export type TicketBranchCreateDialogProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'suggestedBranchName'
> & {
repositoryConnections: RepositoryConnectionModel[];
};
export type TicketDetailRelationshipsProps = Pick<
ProjectTicketPageProps,
| 'ticket'
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class AcceptanceCriteriaChecklistTest extends TestCase
{
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
{
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Acceptance criteria behavior 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());
}
}
+61 -1
View File
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
'base_url' => 'https://git.example.test',
]);
UserGitConnection::factory()->create();
$this->actingAs($user)
->get(route('git-connections.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections')
->has('gitConnections', 1)
->missing('gitConnections.0.api_token')
->where('gitConnections.0.account_login', 'nico')
->where('gitConnections.0.base_url', 'https://git.example.test')
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
'api_token' => 'wrong-token',
])
->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,
]);
}
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\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProfileUpdateTest extends TestCase
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
$response->assertSessionHasErrors('avatar');
}
public function test_profile_and_appearance_settings_render_the_shared_localized_interface(): void
{
$process = new Process(['node', 'tests/Fixtures/settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Settings rendering and localization checks passed.', $process->getOutput());
}
public function test_user_can_delete_their_account()
{
Storage::fake('public');
+52
View File
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
$this->assertTrue(Hash::check('password', $user->fresh()->password));
}
public function test_two_factor_setup_requires_a_valid_code_before_protection_is_enabled(): void
{
$user = User::factory()->create();
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()])
->from(route('security.edit'))
->post(route('two-factor.enable'))->assertSessionHasNoErrors();
$this->assertNotNull($user->refresh()->two_factor_secret);
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
$this->getJson(route('two-factor.secret-key'))->assertOk()->assertJsonStructure(['secretKey']);
$this->getJson(route('two-factor.qr-code'))->assertOk()->assertJsonStructure(['svg']);
$this->mock(TwoFactorAuthenticationProvider::class, function ($mock): void {
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '000000')->once()->andReturn(false);
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '123456')->once()->andReturn(true);
});
$this->post(route('two-factor.confirm'), ['code' => '000000'])
->assertSessionHasErrors('code', errorBag: 'confirmTwoFactorAuthentication');
$this->assertFalse($user->refresh()->hasEnabledTwoFactorAuthentication());
$this->post(route('two-factor.confirm'), ['code' => '123456'])->assertSessionHasNoErrors();
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
}
public function test_recovery_codes_can_be_retrieved_regenerated_and_removed_with_two_factor(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
$oldCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
$this->assertSame($user->recoveryCodes(), $oldCodes);
$this->post(route('two-factor.regenerate-recovery-codes'))->assertSessionHasNoErrors();
$newCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
$this->assertCount(8, $newCodes);
$this->assertEmpty(array_intersect($oldCodes, $newCodes));
$this->delete(route('two-factor.disable'))->assertSessionHasNoErrors();
$this->assertNull($user->refresh()->two_factor_secret);
$this->assertNull($user->two_factor_recovery_codes);
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
}
public function test_sensitive_two_factor_endpoints_reject_expired_password_confirmation(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->actingAs($user);
foreach (['two-factor.qr-code', 'two-factor.secret-key', 'two-factor.recovery-codes'] as $route) {
$this->getJson(route($route))->assertStatus(423);
}
$this->deleteJson(route('two-factor.disable'))->assertStatus(423);
$this->postJson(route('two-factor.regenerate-recovery-codes'))->assertStatus(423);
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
}
}
+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
->component('settings/Applications')
->where('applications.0.name', 'Claude Desktop')
->where('applicationKey', null),
->where('applicationKey', null)
->missing('applications.0.key_hash')
->missing('applications.0.plain_text_key'),
);
}
@@ -100,4 +102,26 @@ class UserApplicationTest extends TestCase
'id' => $application->id,
]);
}
public function test_application_list_only_contains_the_current_users_keys(): void
{
$user = User::factory()->create();
UserApplication::factory()->for($user)->create(['name' => 'Own tool']);
UserApplication::factory()->create(['name' => 'Foreign tool']);
$this->actingAs($user)->get(route('applications.edit'))
->assertInertia(fn (Assert $page) => $page
->has('applications', 1)
->where('applications.0.name', 'Own tool')
->where('applicationKey', null));
}
public function test_invalid_application_name_does_not_issue_a_key(): void
{
$user = User::factory()->create();
$this->actingAs($user)->from(route('applications.edit'))
->post(route('applications.store'), ['name' => ''])
->assertSessionHasErrors('name')
->assertSessionMissing('applicationKey');
$this->assertDatabaseCount('user_applications', 0);
}
}
@@ -0,0 +1,182 @@
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:4176';
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
hasTouch: true,
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on('dialog', (dialog) => dialog.accept());
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const checklist = () =>
page
.locator('form')
.first()
.getByRole('region', { name: 'Acceptance criteria' });
const savedChecklist = () => page.locator('#ticket-checklist');
const field = (number) =>
page.getByRole('textbox', { name: `Criterion ${number}`, exact: true });
const button = (name) => page.getByRole('button', { name, exact: true });
const setResult = (value) =>
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
const waitState = (value) =>
page.locator(`[data-form-state="${value}"]`).first().waitFor();
const save = async () => {
await button('Save changes').click();
await waitState('success');
};
try {
await page.goto(`${base}/projects/7/tickets/31`);
await savedChecklist().waitFor();
const boxes = savedChecklist().getByRole('checkbox');
await setResult('server');
await boxes.first().focus();
await page.keyboard.press('Space');
await page
.getByText('Checklist status could not be saved. Please try again.')
.waitFor();
assert.equal(
await boxes.first().isChecked(),
false,
'Failure must restore saved checkbox',
);
await setResult('success');
let payload;
page.on('request', (request) => {
if (request.method() === 'PATCH') {
payload = request.postDataJSON();
}
});
await boxes.first().tap();
await page.waitForFunction(
() =>
document.querySelectorAll('section[aria-busy="true"]').length === 0,
);
await savedChecklist().getByText('3 of 4 complete').waitFor();
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.checked),
[true, true, true, false],
);
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.id),
[11, 12, 13, 14],
);
await page.locator('#ticket-edit > summary').click();
await field(2).fill('Edited child');
await field(2).press('Alt+ArrowLeft');
assert.deepEqual(
await checklist()
.locator('li')
.evaluateAll((items) =>
items.map((item) => Number(item.dataset.indent)),
),
[0, 0, 1, 0],
);
await button('Indent criterion 2').tap();
await button('Remove criterion 1').tap();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(
await field(1).evaluate((el) => el === document.activeElement),
true,
);
await button('Add criterion').focus();
await page.keyboard.press('Enter');
assert.equal(
await field(4).evaluate((el) => el === document.activeElement),
true,
);
await field(4).fill('New criterion');
await button('Indent criterion 4').click();
await setResult('validation');
await button('Save changes').click();
await waitState('validation');
assert.equal(await field(1).inputValue(), 'Edited child');
await setResult('success');
await save();
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.id),
[12, 13, 14, undefined],
);
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.indent),
[0, 1, 0, 1],
);
await page.reload();
await page.locator('#ticket-edit > summary').click();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(await field(4).inputValue(), 'New criterion');
// Blank/whitespace text cannot silently delete a persisted criterion.
await field(1).fill(' ');
assert.equal(await field(1).evaluate((el) => el.checkValidity()), false);
const beforeInvalidSave = payload;
await button('Save changes').click();
assert.equal(payload, beforeInvalidSave);
await button('Discard changes').click();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(await field(1).evaluate((el) => el.checkValidity()), true);
for (const width of [1280, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await page.evaluate(
(theme) =>
document.documentElement.classList.toggle(
'dark',
theme === 'dark',
),
theme,
);
assert.equal(
await checklist().evaluate(
(el) => el.scrollWidth <= el.clientWidth,
),
true,
);
await checklist().screenshot({
path: `/private/tmp/kan32-checklist-${width}-${theme}.png`,
});
await button('Outdent criterion 4').tap();
await button('Indent criterion 4').tap();
}
}
await page.goto(`${base}/projects/7/tickets/31?readonly=1`);
await savedChecklist().waitFor();
assert.equal(await savedChecklist().getByRole('textbox').count(), 0);
assert.equal(await savedChecklist().getByRole('button').count(), 0);
assert.equal(
await savedChecklist().getByRole('checkbox').first().isDisabled(),
true,
);
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('Created with criteria');
for (const text of ['First', 'Second']) {
await button('Add criterion').click();
await page.locator('textarea').last().fill(text);
}
await button('Indent criterion 2').tap();
await checklist().getByRole('checkbox').first().focus();
await page.keyboard.press('Space');
const request = page.waitForRequest(
(request) =>
request.method() === 'POST' && request.url().endsWith('/tickets'),
);
await button('Create ticket').click();
const created = (await request).postDataJSON();
assert.deepEqual(created.acceptance_criteria, [
{ text: 'First', checked: true, indent: 0 },
{ text: 'Second', checked: true, indent: 1 },
]);
await page.getByRole('heading', { name: 'Destination' }).waitFor();
assert.deepEqual(errors, []);
console.log(
'Acceptance criteria browser checks passed: persistence, errors, keyboard, touch, readonly, creation, mobile and themes.',
);
} finally {
await browser.close();
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/acceptance-criteria-fixture',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
});
try {
const {
canIndentAcceptanceCriterion: canIndent,
indentAcceptanceCriterion: indent,
removeAcceptanceCriterion: remove,
syncAcceptanceCriteriaDescendantCheckedState: toggle,
normalizeAcceptanceCriteria: normalize,
} = await server.ssrLoadModule('/resources/js/lib/acceptance-criteria.ts');
const items = [
{ id: 11, text: 'Parent', indent: 0, checked: false },
{ id: 12, text: 'Child', indent: 1, checked: true },
{ id: 13, text: 'Grandchild', indent: 2, checked: false },
{ id: 14, text: 'Next', indent: 0, checked: false },
];
const original = structuredClone(items);
assert.equal(canIndent(items, 0), false);
assert.equal(canIndent(items, 1), false);
assert.equal(canIndent(items, 3), true);
assert.deepEqual(
indent(items, 1, -1).map((item) => item.indent),
[0, 0, 1, 0],
);
assert.deepEqual(indent(indent(items, 1, -1), 1, 1), items);
assert.deepEqual(indent(items, 0, -1), items);
assert.deepEqual(indent(items, -1, 1), items);
assert.deepEqual(remove(items, 0), [
{ ...items[1], indent: 0 },
{ ...items[2], indent: 1 },
items[3],
]);
assert.deepEqual(remove(items, 1), [
items[0],
{ ...items[2], indent: 1 },
items[3],
]);
assert.deepEqual(remove(items, 99), items);
assert.deepEqual(
toggle(items, 0, true).map((item) => item.checked),
[true, true, true, false],
);
assert.deepEqual(
toggle(items, 1, false).map((item) => item.checked),
[false, false, false, false],
);
const deep = Array.from({ length: 9 }, (_, indent) => ({
text: 'Deep',
checked: false,
indent,
}));
assert.equal(
canIndent([{ text: 'Before', indent: 0, checked: false }, ...deep], 1),
false,
);
assert.deepEqual(
normalize(remove(items, 0)).map((item) => item.id),
[12, 13, 14],
);
assert.deepEqual(items, original, 'Editing must not mutate saved criteria');
const { default: Checklist } = await server.ssrLoadModule(
'/resources/js/components/tickets/AcceptanceCriteriaChecklist.vue',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const render = (props) =>
renderToString(
createSSRApp({ render: () => h(Checklist, props) }).use(
createLocalization(),
),
);
const editable = await render({ modelValue: items });
assert.equal((editable.match(/<textarea/g) ?? []).length, 4);
assert.match(editable, /Indent criterion 4/);
assert.match(editable, /1 of 4 complete/);
const readonly = await render({ modelValue: items, readonly: true });
assert.doesNotMatch(readonly, /<textarea|Add criterion|Edit checklist/);
assert.equal((readonly.match(/type="checkbox" disabled/g) ?? []).length, 4);
assert.match(
await render({ modelValue: [], error: 'Server validation' }),
/Server validation/,
);
console.log('Acceptance criteria behavior checks passed.');
} finally {
await server.close();
}
+5 -9
View File
@@ -84,15 +84,11 @@ try {
renderTheme('light'),
renderTheme('dark'),
]);
const selectedButton = (html) =>
html.match(
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
)[1];
assert.match(selectedButton(light), />Light</);
assert.match(selectedButton(dark), />Dark</);
assert.match(light, /role="group" aria-label="Color theme"/);
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
assert.match(light, /value="light"[^>]*checked/);
assert.match(dark, /value="dark"[^>]*checked/);
assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
assert.equal((dark.match(/ checked/g) ?? []).length, 1);
assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8');
+18 -10
View File
@@ -320,15 +320,18 @@ try {
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
);
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click();
@@ -349,8 +352,10 @@ try {
await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
);
await setResult('success');
await page.locator('form button[type="submit"]').first().click();
@@ -359,9 +364,10 @@ try {
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#ticket-edit > summary').click();
await page.locator('#title').fill('Updated ticket draft');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Edited checklist');
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('Edited checklist');
await setResult('server');
await page.locator('form button[type="submit"]').first().click();
await state('server');
@@ -370,8 +376,10 @@ try {
'Updated ticket draft',
);
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Edited checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Edited checklist',
);
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
+15 -2
View File
@@ -1,4 +1,5 @@
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
@@ -36,7 +37,19 @@ let ticket = {
ticket_status: status,
is_blocked: false,
story_points: 0,
acceptance_criteria: [],
acceptance_criteria: process.env.CRITERIA_FIXTURE
? [
{ id: 11, text: 'Parent criterion', indent: 0, checked: false },
{ id: 12, text: 'Nested criterion', indent: 1, checked: true },
{ id: 13, text: 'Deep criterion', indent: 2, checked: false },
{
id: 14,
text: 'Independent criterion',
indent: 0,
checked: false,
},
]
: [],
field_values: [],
worklog_entries: [],
created_at: '2026-09-10T12:00:00Z',
@@ -125,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
ticketStatusOptions: [status],
users: [],
parentTicketOptions: [],
canEditTicket: true,
canEditTicket: !url.searchParams.has('readonly'),
canManageProject: true,
childTicketTree: [],
linkedTickets: [],
+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: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();
}
}
+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();
}
}
+178 -18
View File
@@ -101,26 +101,130 @@ try {
}
await page.setViewportSize({ width: 390, height: 1000 });
await visit();
const blocker = content().locator('#ticket-blocker');
const blockerToggle = blocker.getByRole('checkbox', {
name: 'Mark ticket as blocked',
exact: true,
});
const blockerComment = blocker.getByLabel('Blocker comment', {
exact: true,
});
assert.equal(await blockerToggle.isChecked(), true);
assert.equal(
await blocker.getByText('Alex Morgan', { exact: true }).count(),
0,
);
assert.equal(
await content()
.locator('#ticket-history')
.getByText('Alex Morgan', { exact: true })
.count(),
1,
);
assert.equal(
await content()
.locator('#ticket-history')
.getByText('Status changed', { exact: true })
.count(),
1,
);
await blockerComment.fill('');
assert.equal(
await blockerComment.evaluate(
(element) => element.validity.valueMissing,
),
true,
);
await blockerComment.fill('Waiting for a production decision.');
await blocker.getByRole('button', { name: 'Save blocker' }).click();
await page.getByText('Ticket updated.', { exact: true }).waitFor();
assert.equal(
await blockerComment.inputValue(),
'Waiting for a production decision.',
);
for (const label of ['Assignee', 'Reviewer']) {
const field = content()
.locator('#ticket-metadata dl > div')
.filter({ has: page.getByText(label, { exact: true }) });
assert.match(await field.locator('dd').innerText(), /Alex Morgan/);
}
await blockerToggle.uncheck();
await blocker.getByRole('button', { name: 'Unblock ticket' }).click();
await blocker
.getByText('This ticket currently has no active blocker.', {
exact: true,
})
.waitFor();
assert.equal(await blocker.getByLabel('Blocker comment').count(), 0);
await content().getByRole('button', { name: 'Change parent' }).click();
await page.waitForFunction(
() => document.activeElement?.id === 'parent_ticket_id',
);
const saveTicket = content().getByRole('button', {
name: 'Save changes',
exact: true,
});
assert.equal(
await content()
.getByRole('button', { name: 'Discard changes', exact: true })
.isDisabled(),
true,
'Saving the blocker must also update the ticket form defaults.',
);
await page
.getByLabel('Title', { exact: true })
.fill('Updated title after unblocking');
const ticketUpdate = page.waitForRequest(
(request) =>
request.method() === 'PATCH' &&
new URL(request.url()).pathname === '/projects/7/tickets/42',
);
await saveTicket.click();
const savedTicket = (await ticketUpdate).postDataJSON();
assert.equal(savedTicket.is_blocked, false);
assert.equal(savedTicket.blocked_comment, '');
assert.equal(Number(savedTicket.parent_ticket_id), 40);
assert.deepEqual(savedTicket.acceptance_criteria, [
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
{
id: 11,
text: 'Invalidate when records change',
checked: false,
indent: 1,
},
]);
await page
.getByRole('heading', { name: 'Updated title after unblocking' })
.waitFor();
await visit();
await tabTo(edit());
await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.check();
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page.waitForFunction(
() => document.querySelector('#acceptance-criteria-11').checked,
() =>
document.querySelectorAll(
'#ticket-checklist input[type=checkbox]',
)[1].checked,
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
await page
.getByLabel('Acceptance criteria checklist', { exact: true })
.fill('[ ] A draft checklist item');
.getByLabel('Criterion 1', { exact: true })
.fill('A draft checklist item');
assert.equal(
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Cache requests safely',
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.isDisabled(),
@@ -155,10 +259,8 @@ try {
'Unsaved title draft',
);
assert.equal(
await page
.getByLabel('Acceptance criteria checklist', { exact: true })
.inputValue(),
'[ ] A draft checklist item',
await page.getByLabel('Criterion 1', { exact: true }).inputValue(),
'A draft checklist item',
);
await visit('?reject');
await edit().click();
@@ -196,16 +298,21 @@ try {
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?rejectToggle');
await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.check();
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page
.getByRole('alert')
.filter({ hasText: 'Checklist status could not be saved' })
.waitFor();
assert.equal(
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Cache requests safely',
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.isChecked(),
@@ -235,20 +342,68 @@ try {
.waitFor();
await page.getByRole('button', { name: 'Create now', exact: true }).click();
await dialog.waitFor({ state: 'hidden' });
await visit('?slowBranch&serverError');
await page
.getByRole('button', { name: 'Create branch', exact: true })
.click();
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page
.getByLabel('Branch name', { exact: true })
.fill('feature/ENG-42-preserve-this-name');
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
const failedBranchCreation = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/branches') &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Create now', exact: true }).click();
await page.getByText('Creating branch…', { exact: true }).waitFor();
assert.equal(
await page
.locator('button[type="submit"]')
.last()
.getAttribute('aria-busy'),
'true',
);
assert.equal((await failedBranchCreation).status(), 503);
await page
.getByText('The request could not be completed', { exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Branch name', { exact: true }).inputValue(),
'feature/ENG-42-preserve-this-name',
);
assert.equal(await page.getByRole('dialog').isVisible(), true);
await page.getByLabel('Branch name', { exact: true }).fill('eng-42-cache');
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
await visit();
const linkSaved = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/links') &&
response.request().method() === 'POST',
);
await page.getByLabel('Linked ticket', { exact: true }).selectOption('41');
await page.getByRole('button', { name: 'Save link', exact: true }).click();
assert.equal((await linkSaved).status(), 303);
await page.getByRole('button', { name: 'Remove', exact: true }).waitFor();
await page
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
.waitFor();
const linkRemoved = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/links/3?_method=DELETE') &&
response.request().method() === 'POST',
response.url().includes('/tickets/42/links/3') &&
response.request().method() === 'DELETE',
);
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await page
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
.click();
assert.equal((await linkRemoved).status(), 303);
await visit('?readonly&hideAuthor&hidePoints');
assert.equal(await content().locator('form').count(), 0);
@@ -262,7 +417,12 @@ try {
.count(),
0,
);
await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
await tabTo(
content().getByRole('link', {
name: 'ENG-41 Related infrastructure',
exact: true,
}),
);
await page.keyboard.press('Enter');
await page.waitForURL('**/tickets/41');
await page
+138 -49
View File
@@ -42,6 +42,7 @@ const repository = {
provider_label: 'Gitea',
base_url: 'https://git.example.test',
repository_full_name: 'Engineering/platform',
repository_url: 'https://git.example.test/Engineering/platform',
has_personal_access: true,
};
const parent = {
@@ -108,9 +109,18 @@ const ticket = {
},
],
worklog_entries: [
{
id: 2,
actor: null,
actor_id: null,
entry_type: 'status_changed',
summary: 'Moved from Backlog to Delivery / In progress.',
created_at: '2026-09-11T13:00:00Z',
},
{
id: 1,
actor: user,
actor_id: user.id,
entry_type: 'created',
summary: 'Ticket created in the project backlog.',
created_at: '2026-09-11T12:00:00Z',
@@ -182,30 +192,35 @@ function makePage(url) {
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: params.has('rejected')
errors: params.has('linkRejected')
? {
title: 'This title is not allowed.',
...(params.has('allErrors')
? {
description: 'Invalid description.',
ticket_type_id: 'Invalid type.',
ticket_priority_id: 'Invalid priority.',
ticket_status_id: 'Invalid status.',
assignee_user_id: 'Invalid assignee.',
author_user_id: 'Invalid author.',
parent_ticket_id: 'Invalid parent.',
story_points: 'Invalid points.',
'acceptance_criteria.0.text':
'Invalid checklist.',
blocked_comment: 'Invalid blocker.',
'field_values.0.value_number':
'Invalid number.',
'field_values.1.value_user_id':
'Invalid reviewer.',
}
: {}),
ticket_link_type_id: 'Invalid relationship type.',
linked_ticket_id: 'Choose another ticket.',
}
: {},
: params.has('rejected')
? {
title: 'This title is not allowed.',
...(params.has('allErrors')
? {
description: 'Invalid description.',
ticket_type_id: 'Invalid type.',
ticket_priority_id: 'Invalid priority.',
ticket_status_id: 'Invalid status.',
assignee_user_id: 'Invalid assignee.',
author_user_id: 'Invalid author.',
parent_ticket_id: 'Invalid parent.',
story_points: 'Invalid points.',
'acceptance_criteria.0.text':
'Invalid checklist.',
blocked_comment: 'Invalid blocker.',
'field_values.0.value_number':
'Invalid number.',
'field_values.1.value_user_id':
'Invalid reviewer.',
}
: {}),
}
: {},
sidebarOpen: true,
auth: {
user,
@@ -250,25 +265,45 @@ function makePage(url) {
? [
{
...linked,
ticket_type: { is_parent: false },
children: [],
ticket_type_name: 'Epic',
children: [
{
id: 43,
ticket_key: 'ENG-43',
title: 'Nested child with a long title that must wrap on mobile',
ticket_type_name: 'Task',
children: [],
},
],
children_count: 0,
},
]
: [],
linkedTickets: params.has('empty')
linkedTickets:
params.has('empty') || params.has('noLinks')
? []
: [
{
id: 3,
source_ticket_id: params.has('incoming')
? 41
: 42,
target_ticket_id: params.has('incoming')
? 42
: 41,
ticket_link_type: {
id: 1,
label: params.has('incoming')
? 'Blocked by'
: 'Blocks',
},
linked_ticket: linked,
},
],
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
ticketLinkTypes: params.has('noTypes')
? []
: [
{
id: 3,
ticket_link_type: { id: 1, label: 'Blocks' },
linked_ticket: linked,
},
],
ticketLinkOptions: [linked],
ticketLinkTypes: [
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
],
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }],
repositoryBranches: params.has('empty')
? []
: [
@@ -317,9 +352,12 @@ if (serve) {
for await (const chunk of request) {
body += chunk;
}
const data = JSON.parse(body);
const data = body ? JSON.parse(body) : {};
const previous = new URL(request.headers.referer);
const params = previous.searchParams;
if (request.url.includes('/branches') && params.has('slowBranch')) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (params.has('serverError')) {
response.writeHead(503);
response.end('Simulated failure');
@@ -350,8 +388,17 @@ if (serve) {
} else if (request.url.includes('/links')) {
assert.ok(
request.url.endsWith('/links/3?_method=DELETE') ||
request.method === 'DELETE' ||
Number(data.linked_ticket_id) === 41,
);
if (params.has('rejectLink')) {
params.set('linkRejected', '1');
} else if (request.method === 'DELETE') {
params.set('noLinks', '1');
} else {
params.delete('noLinks');
params.delete('linkRejected');
}
} else {
assert.ok(
(request.method === 'POST' || 'field_values' in data) &&
@@ -361,7 +408,19 @@ if (serve) {
if (params.has('reject')) {
params.set('rejected', '1');
} else {
savedForm = data;
savedForm = {
...data,
blocked_comment: data.is_blocked
? data.blocked_comment
: null,
field_values: data.field_values.map((fieldValue) => ({
...fieldValue,
user_value:
Number(fieldValue.value_user_id) === user.id
? user
: null,
})),
};
params.set('saved', data.title);
}
}
@@ -442,6 +501,9 @@ if (serve) {
'feature/ENG-42-cache',
'Cache implementation',
'Ticket created in the project backlog.',
'Ticket created',
'Status changed',
'System',
]) {
assert.ok(
html.includes(text),
@@ -452,6 +514,7 @@ if (serve) {
'description',
'checklist',
'metadata',
'blocker',
'relationships',
'history',
'git',
@@ -478,7 +541,6 @@ if (serve) {
'story_points',
'parent_ticket_id',
'description',
'acceptance_criteria_checklist',
'is_blocked',
'blocked_comment',
'field-1',
@@ -489,8 +551,27 @@ if (serve) {
`Missing editor: ${field}`,
);
}
assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/);
assert.match(html, /Create branch/);
assert.match(
html,
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(
html,
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(html, /Save link/);
assert.match(html, /Outgoing/);
const incoming = await render('?incoming');
assert.match(incoming, /Incoming/);
assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/);
assert.match(
await render('?noTargets'),
/No other tickets are available/,
);
assert.match(html, /Remove/);
const readOnly = await render('?readonly');
assert.doesNotMatch(
@@ -505,23 +586,28 @@ if (serve) {
const empty = await render('?empty');
for (const text of [
'No description has been added yet.',
'No acceptance criteria have been added yet.',
'No acceptance criteria yet.',
'No status',
'Unassigned',
'No author',
'Not set',
'No matching branches',
'No matching pull requests',
'No linked repositories',
'No worklog entries exist',
]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
}
assert.match(await render('?parent'), /Child tickets/);
assert.match(await render('?noAccess'), /Open Git connections/);
assert.match(
await render('?gitError'),
/Repository temporarily unavailable/,
);
const hierarchy = await render('?parent');
assert.match(hierarchy, /Child tickets/);
assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/);
const noAccess = await render('?noAccess');
assert.match(noAccess, /Personal Git access required/);
assert.match(noAccess, /Open Git connections/);
const gitError = await render('?gitError');
assert.match(gitError, /Branches unavailable/);
assert.match(gitError, /Pull requests unavailable/);
assert.match(gitError, /Repository temporarily unavailable/);
const create = await render('?source=board', true);
assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/);
@@ -540,6 +626,10 @@ if (serve) {
assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [
'title',
'description',
@@ -548,7 +638,6 @@ if (serve) {
'assignee_user_id',
'parent_ticket_id',
'story_points',
'acceptance_criteria_checklist',
]) {
assert.ok(invalid.includes(`id="${id}-error"`));
}
+19 -9
View File
@@ -10,7 +10,7 @@ page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets';
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const field = (label) => page.getByLabel(label, { exact: true });
const field = (label) => form().getByLabel(label, { exact: true });
const form = () => page.locator('form').first();
const state = (value) =>
form().locator(`[data-form-state="${value}"]`).waitFor();
@@ -29,9 +29,19 @@ async function fill(create) {
await field('Assignee').selectOption('2');
await field('Parent ticket').selectOption('40');
await field('Estimated story points').fill('0');
await field('Acceptance criteria checklist').fill(
'[ ] Main criterion\n [x] Nested criterion',
);
if (create) {
await button('Add criterion').click();
await button('Add criterion').click();
await button('Indent criterion 2').click();
}
await field('Criterion 1').fill('Main criterion');
await field('Criterion 2').fill('Nested criterion');
await form()
.getByRole('checkbox', {
name: 'Complete criterion 2: Nested criterion',
exact: true,
})
.check();
if (create) {
await field('Status').selectOption('4');
} else {
@@ -154,7 +164,7 @@ try {
'Assignee',
'Parent ticket',
'Estimated story points',
'Acceptance criteria checklist',
'Criterion 1',
...(create
? ['Status']
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
@@ -231,12 +241,12 @@ try {
.waitFor();
if (create) {
await page.goBack();
await page
.getByRole('heading', { name: 'Create ticket', exact: true })
.waitFor();
await field('Title').waitFor();
assert.equal(await field('Title').inputValue(), '');
assert.equal(
await field('Acceptance criteria checklist').inputValue(),
'',
);
assert.equal(await field('Criterion 1').count(), 0);
} else {
await form()
.getByText('All changes saved', { exact: true })
@@ -0,0 +1,223 @@
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 page = await browser.newPage();
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
const section = () => page.locator('#ticket-relationships');
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
async function visit(query = '') {
await page.goto(base + query);
await section().waitFor();
}
const control = (label) => section().getByLabel(label, { exact: true });
const save = () =>
section().getByRole('button', { name: 'Save link', exact: true });
const remove = () =>
section().getByRole('button', {
name: 'Remove link to ENG-41',
exact: true,
});
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,
reducedMotion: 'reduce',
});
await visit('?parent&incoming');
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
for (const text of [
'Platform delivery',
'Child tickets',
'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) {
assert.equal(
await section()
.getByText(text, { exact: true })
.isVisible(),
true,
text,
);
}
const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41',
});
await collapse.focus();
await page.keyboard.press('Enter');
assert.equal(
await section()
.getByText(
'Nested child with a long title that must wrap on mobile',
{ exact: true },
)
.count(),
0,
);
await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus();
await page.keyboard.press('Tab');
assert.equal(
await control('Linked ticket').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await control('Linked ticket').selectOption('41');
await section()
.locator('form')
.last()
.getByText('ENG-42', { exact: true })
.waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
`Overflow ${width}/${theme}`,
);
await section().screenshot({
path: `/private/tmp/kan33-relationships-${width}-${theme}.png`,
});
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.click();
assert.equal(
await page
.locator('#parent_ticket_id')
.evaluate((el) => el === document.activeElement),
true,
);
assert.equal(
await page.locator('#ticket-edit').evaluate((el) => el.open),
true,
);
}
}
await visit('?noLinks');
assert.equal(await save().isDisabled(), true);
await control('Linked ticket').selectOption('41');
let request = page.waitForRequest(
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
);
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
linked_ticket_id: 41,
});
await section().getByText('Outgoing', { exact: true }).waitFor();
await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '',
);
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Linked tickets'),
);
request = page.waitForRequest(
(r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'),
);
await remove().click();
await request;
await section()
.getByText('No ticket links are configured for this ticket yet.', {
exact: true,
})
.waitFor();
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Linked tickets'),
);
await visit('?rejectLink');
await control('Linked ticket').selectOption('41');
await save().click();
await section()
.getByText('Choose another ticket.', { exact: true })
.waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Link type', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
}
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
await save().click();
await section().locator('[data-form-state="network"]').waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
await page.unrouteAll();
await visit('?serverError');
await remove().click();
await section().locator('[data-form-state="server"]').waitFor();
assert.equal(await section().locator('[data-ticket-link]').count(), 1);
let pending;
await page.route('**/tickets/42/links/3', (route) => {
pending = route;
});
await section()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await section().locator('[data-form-state="loading"]').waitFor();
assert.equal(await remove().isDisabled(), true);
await pending.abort();
await section().locator('[data-form-state="network"]').waitFor();
await page.unrouteAll();
await visit();
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.click();
await page.getByLabel('Parent ticket', { exact: true }).selectOption('');
request = page.waitForRequest(
(r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'),
);
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
assert.equal((await request).postDataJSON().parent_ticket_id, '');
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?readonly&parent&incoming');
assert.equal(await section().locator('form').count(), 0);
assert.equal(
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.count(),
0,
);
assert.equal(
await section().getByText('Blocked by', { exact: true }).isVisible(),
true,
);
for (const [query, message] of [
['?noTypes', 'No link types are configured for this project.'],
[
'?noTargets',
'No other tickets are available to link in this project.',
],
]) {
await visit(query);
assert.equal(
await section().getByText(message, { exact: true }).isVisible(),
true,
);
assert.equal(await save().count(), 0);
}
assert.deepEqual(errors, []);
console.log(
'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.',
);
} finally {
await browser.close();
}