Compare commits
3
Commits
v0.2.4
...
a028eacec2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a028eacec2 | ||
|
|
d9de375b59 | ||
|
|
7e483d0b58 |
+1
-1
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->dontFlash(['api_token']);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -1,123 +1,169 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
import { ref } from 'vue';
|
||||||
import AlertError from '@/components/AlertError.vue';
|
import AlertError from '@/components/AlertError.vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||||
|
|
||||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
const { t } = useLocalization();
|
||||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
const {
|
||||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
recoveryCodesList,
|
||||||
|
fetchRecoveryCodes,
|
||||||
|
clearRecoveryCodes,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
} = useTwoFactorAuth();
|
||||||
|
const visible = ref(false);
|
||||||
|
const showRegenerate = ref(false);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
|
||||||
const toggleRecoveryCodesVisibility = async () => {
|
function hide(): void {
|
||||||
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
|
visible.value = false;
|
||||||
await fetchRecoveryCodes();
|
clearRecoveryCodes();
|
||||||
|
}
|
||||||
|
function toggle(): void {
|
||||||
|
if (visible.value) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
visible.value = true;
|
||||||
|
void fetchRecoveryCodes();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
|
function closeRegenerate(open: boolean): void {
|
||||||
|
if (!form.processing) {
|
||||||
if (isRecoveryCodesVisible.value) {
|
showRegenerate.value = open;
|
||||||
await nextTick();
|
protection.clearFeedback();
|
||||||
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
function regenerate(): void {
|
||||||
onMounted(async () => {
|
protection.submit(regenerateRecoveryCodes(), {
|
||||||
if (!recoveryCodesList.value.length) {
|
preserveScroll: true,
|
||||||
await fetchRecoveryCodes();
|
onBefore: hide,
|
||||||
}
|
onSuccess: () => {
|
||||||
});
|
showRegenerate.value = false;
|
||||||
|
visible.value = true;
|
||||||
|
return fetchRecoveryCodes();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="w-full">
|
<section
|
||||||
<CardHeader>
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
<CardTitle class="flex gap-3">
|
aria-labelledby="recovery-title"
|
||||||
<LockKeyhole class="size-4" />2FA recovery codes
|
>
|
||||||
</CardTitle>
|
<div class="gap-stack grid">
|
||||||
<CardDescription>
|
<h3
|
||||||
Recovery codes let you regain access if you lose your 2FA
|
id="recovery-title"
|
||||||
device. Store them in a secure password manager.
|
class="flex items-center gap-2 font-semibold"
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
>
|
||||||
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
|
<LockKeyhole class="text-primary size-4" />{{
|
||||||
<component
|
t('security.recoveryTitle')
|
||||||
:is="isRecoveryCodesVisible ? EyeOff : Eye"
|
}}
|
||||||
class="size-4"
|
</h3>
|
||||||
/>
|
<p class="text-body text-muted-foreground">
|
||||||
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
|
{{ t('security.recoveryDescription') }}
|
||||||
codes
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
<Form
|
<Button
|
||||||
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
|
variant="outline"
|
||||||
v-bind="regenerateRecoveryCodes.form()"
|
:aria-expanded="visible"
|
||||||
method="post"
|
aria-controls="recovery-codes"
|
||||||
:options="{ preserveScroll: true }"
|
:disabled="form.processing"
|
||||||
@success="fetchRecoveryCodes"
|
@click="toggle"
|
||||||
#default="{ processing }"
|
><component :is="visible ? EyeOff : Eye" />{{
|
||||||
|
t(
|
||||||
|
visible
|
||||||
|
? 'security.hideRecovery'
|
||||||
|
: 'security.viewRecovery',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="visible && recoveryCodesList.length"
|
||||||
|
variant="secondary"
|
||||||
|
@click="showRegenerate = true"
|
||||||
|
><RefreshCw />{{ t('security.regenerate') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
|
||||||
|
<p
|
||||||
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
class="bg-muted animate-pulse rounded-lg p-4"
|
||||||
|
>
|
||||||
|
{{ t('security.loadingRecovery') }}
|
||||||
|
</p>
|
||||||
|
<template v-else-if="errors.length">
|
||||||
|
<AlertError :errors="errors" />
|
||||||
|
<Button
|
||||||
|
class="justify-self-start"
|
||||||
|
variant="outline"
|
||||||
|
@click="fetchRecoveryCodes"
|
||||||
|
>{{ t('common.retry') }}</Button
|
||||||
>
|
>
|
||||||
<Button
|
</template>
|
||||||
variant="secondary"
|
<template v-else>
|
||||||
type="submit"
|
<ul
|
||||||
:disabled="processing"
|
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
|
||||||
>
|
:aria-label="t('security.recoveryTitle')"
|
||||||
<RefreshCw /> Regenerate codes
|
>
|
||||||
</Button>
|
<li v-for="code in recoveryCodesList" :key="code">
|
||||||
</Form>
|
{{ code }}
|
||||||
</div>
|
</li>
|
||||||
<div
|
</ul>
|
||||||
:class="[
|
<p class="text-caption text-muted-foreground">
|
||||||
'relative overflow-hidden transition-all duration-300',
|
{{ t('security.recoveryOnce') }}
|
||||||
isRecoveryCodesVisible
|
</p>
|
||||||
? 'h-auto opacity-100'
|
</template>
|
||||||
: 'h-0 opacity-0',
|
</div>
|
||||||
]"
|
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
>
|
>
|
||||||
<div v-if="errors?.length" class="mt-6">
|
<DialogHeader>
|
||||||
<AlertError :errors="errors" />
|
<DialogTitle>{{
|
||||||
</div>
|
t('security.regenerateTitle')
|
||||||
<div v-else class="mt-3 space-y-3">
|
}}</DialogTitle>
|
||||||
<div
|
<DialogDescription>{{
|
||||||
ref="recoveryCodeSectionRef"
|
t('security.regenerateDescription')
|
||||||
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
|
@retry="regenerate"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="closeRegenerate(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
>
|
>
|
||||||
<div v-if="!recoveryCodesList.length" class="space-y-2">
|
<Button :loading="form.processing" @click="regenerate">{{
|
||||||
<div
|
t('security.regenerate')
|
||||||
v-for="n in 8"
|
}}</Button>
|
||||||
:key="n"
|
</DialogFooter>
|
||||||
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
|
</DialogContent>
|
||||||
></div>
|
</Dialog>
|
||||||
</div>
|
</section>
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
v-for="(code, index) in recoveryCodesList"
|
|
||||||
:key="index"
|
|
||||||
>
|
|
||||||
{{ code }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted-foreground select-none text-xs">
|
|
||||||
Each recovery code can be used once to access your
|
|
||||||
account and will be removed after use. If you need more,
|
|
||||||
click
|
|
||||||
<span class="font-bold">Regenerate codes</span> above.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,297 +1,261 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { useClipboard } from '@vueuse/core';
|
|
||||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||||
import AlertError from '@/components/AlertError.vue';
|
import AlertError from '@/components/AlertError.vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
InputOTP,
|
import { Label } from '@/components/ui/label';
|
||||||
InputOTPGroup,
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
InputOTPSlot,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
} from '@/components/ui/input-otp';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
import { confirm } from '@/routes/two-factor';
|
import { confirm } from '@/routes/two-factor';
|
||||||
import type { TwoFactorConfigContent } from '@/types';
|
|
||||||
|
|
||||||
type Props = {
|
const props = defineProps<{
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
};
|
}>();
|
||||||
|
|
||||||
const { resolvedAppearance } = useAppearance();
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
const isOpen = defineModel<boolean>('isOpen');
|
const isOpen = defineModel<boolean>('isOpen');
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const {
|
||||||
|
qrCodeSvg,
|
||||||
|
manualSetupKey,
|
||||||
|
hasSetupData,
|
||||||
|
clearSetupData,
|
||||||
|
fetchSetupData,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
} = useTwoFactorAuth();
|
||||||
|
const form = useForm({ code: '' }).dontRemember('code');
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
|
const verification = ref(false);
|
||||||
|
const copied = ref(false);
|
||||||
|
const copyError = ref(false);
|
||||||
|
const codeInput = useTemplateRef('codeInput');
|
||||||
|
const title = computed(() =>
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.setupEnabled'
|
||||||
|
: verification.value
|
||||||
|
? 'security.verifyTitle'
|
||||||
|
: 'security.setupTitle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const description = computed(() =>
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.setupEnabledDescription'
|
||||||
|
: verification.value
|
||||||
|
? 'security.verifyDescription'
|
||||||
|
: 'security.setupDescription',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const { copy, copied } = useClipboard();
|
function close(open: boolean): void {
|
||||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
if (!form.processing) {
|
||||||
useTwoFactorAuth();
|
isOpen.value = open;
|
||||||
|
|
||||||
const showVerificationStep = ref(false);
|
|
||||||
const code = ref<string>('');
|
|
||||||
|
|
||||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
|
||||||
|
|
||||||
const modalConfig = computed<TwoFactorConfigContent>(() => {
|
|
||||||
if (props.twoFactorEnabled) {
|
|
||||||
return {
|
|
||||||
title: 'Two-factor authentication enabled',
|
|
||||||
description:
|
|
||||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
|
||||||
buttonText: 'Close',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (showVerificationStep.value) {
|
async function next(): Promise<void> {
|
||||||
return {
|
if (props.twoFactorEnabled || !props.requiresConfirmation) {
|
||||||
title: 'Verify authentication code',
|
close(false);
|
||||||
description: 'Enter the 6-digit code from your authenticator app',
|
|
||||||
buttonText: 'Continue',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: 'Enable two-factor authentication',
|
|
||||||
description:
|
|
||||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
|
||||||
buttonText: 'Continue',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleModalNextStep = () => {
|
|
||||||
if (props.requiresConfirmation) {
|
|
||||||
showVerificationStep.value = true;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
pinInputContainerRef.value?.querySelector('input')?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
verification.value = true;
|
||||||
clearSetupData();
|
await nextTick();
|
||||||
isOpen.value = false;
|
codeInput.value?.$el?.focus();
|
||||||
};
|
}
|
||||||
|
function submit(): void {
|
||||||
const resetModalState = () => {
|
protection.submit(confirm(), {
|
||||||
if (props.twoFactorEnabled) {
|
preserveScroll: true,
|
||||||
clearSetupData();
|
errorBag: 'confirmTwoFactorAuthentication',
|
||||||
|
onSuccess: () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
const key = manualSetupKey.value;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
copyError.value = false;
|
||||||
showVerificationStep.value = false;
|
try {
|
||||||
code.value = '';
|
await navigator.clipboard.writeText(key);
|
||||||
};
|
if (isOpen.value && manualSetupKey.value === key) {
|
||||||
|
copied.value = true;
|
||||||
watch(
|
|
||||||
() => isOpen.value,
|
|
||||||
async (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
resetModalState();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
if (!qrCodeSvg.value) {
|
if (isOpen.value) {
|
||||||
await fetchSetupData();
|
copyError.value = true;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
}
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
verification.value = false;
|
||||||
|
copied.value = false;
|
||||||
|
copyError.value = false;
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
clearSetupData();
|
||||||
|
if (open) {
|
||||||
|
void fetchSetupData();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="isOpen" @update:open="isOpen = $event">
|
<Dialog :open="isOpen" @update:open="close">
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent
|
||||||
<DialogHeader class="flex items-center justify-center">
|
class="sm:max-w-md"
|
||||||
<div
|
:show-close-button="!form.processing"
|
||||||
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<ScanLine class="text-primary size-8" />
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ description }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<template v-if="!verification">
|
||||||
|
<p
|
||||||
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
class="bg-muted animate-pulse rounded-lg p-6 text-center"
|
||||||
>
|
>
|
||||||
|
{{ t('security.loadingSetup') }}
|
||||||
|
</p>
|
||||||
|
<div v-else-if="errors.length" class="gap-stack grid">
|
||||||
|
<AlertError :errors="errors" />
|
||||||
|
<Button variant="outline" @click="fetchSetupData">{{
|
||||||
|
t('common.retry')
|
||||||
|
}}</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
|
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
|
||||||
>
|
v-html="qrCodeSvg"
|
||||||
<div
|
/>
|
||||||
class="absolute inset-0 grid grid-cols-5 opacity-50"
|
<div class="grid min-w-0 gap-2">
|
||||||
>
|
<Label for="setup-key">{{
|
||||||
<div
|
t('security.manualKey')
|
||||||
v-for="i in 5"
|
}}</Label>
|
||||||
:key="`col-${i}`"
|
<div class="flex min-w-0 gap-2">
|
||||||
class="border-border border-r last:border-r-0"
|
<Input
|
||||||
|
id="setup-key"
|
||||||
|
:model-value="manualSetupKey"
|
||||||
|
readonly
|
||||||
|
class="min-w-0 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
copied
|
||||||
|
? 'security.copied'
|
||||||
|
: 'security.copyKey',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="copyKey"
|
||||||
|
><Check v-if="copied" /><Copy v-else
|
||||||
|
/></Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<p
|
||||||
class="absolute inset-0 grid grid-rows-5 opacity-50"
|
v-if="copied"
|
||||||
|
role="status"
|
||||||
|
class="text-caption text-success-foreground"
|
||||||
>
|
>
|
||||||
<div
|
{{ t('security.copied') }}
|
||||||
v-for="i in 5"
|
</p>
|
||||||
:key="`row-${i}`"
|
<InputError
|
||||||
class="border-border border-b last:border-b-0"
|
v-if="copyError"
|
||||||
/>
|
:message="t('security.copyError')"
|
||||||
</div>
|
|
||||||
<ScanLine
|
|
||||||
class="text-foreground relative z-20 size-6"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
|
<p
|
||||||
<DialogDescription class="text-center">
|
v-if="requiresConfirmation && !twoFactorEnabled"
|
||||||
{{ modalConfig.description }}
|
class="text-caption text-muted-foreground"
|
||||||
</DialogDescription>
|
>
|
||||||
</DialogHeader>
|
{{ t('security.setupPendingDescription') }}
|
||||||
|
</p>
|
||||||
<div
|
<DialogFooter>
|
||||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
<Button variant="outline" @click="close(false)">{{
|
||||||
>
|
t('common.close')
|
||||||
<template v-if="!showVerificationStep">
|
}}</Button>
|
||||||
<AlertError v-if="errors?.length" :errors="errors" />
|
<Button v-if="hasSetupData" @click="next">{{
|
||||||
<template v-else>
|
t(
|
||||||
<div
|
twoFactorEnabled || !requiresConfirmation
|
||||||
class="relative mx-auto flex max-w-md items-center overflow-hidden"
|
? 'common.done'
|
||||||
>
|
: 'security.continue',
|
||||||
<div
|
)
|
||||||
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
|
}}</Button>
|
||||||
>
|
</DialogFooter>
|
||||||
<div
|
</template>
|
||||||
v-if="!qrCodeSvg"
|
<form v-else class="gap-column grid" @submit.prevent="submit">
|
||||||
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
|
<AuthFormFeedback
|
||||||
>
|
:state="protection.state.value"
|
||||||
<Spinner class="size-6" />
|
:response-status="protection.responseStatus.value"
|
||||||
</div>
|
@retry="submit"
|
||||||
<div
|
/>
|
||||||
v-else
|
<div class="grid gap-2">
|
||||||
class="relative z-10 overflow-hidden border p-5"
|
<Label for="authentication-code">{{
|
||||||
>
|
t('security.authenticationCode')
|
||||||
<div
|
}}</Label>
|
||||||
v-html="qrCodeSvg"
|
<Input
|
||||||
class="flex aspect-square size-full items-center justify-center"
|
id="authentication-code"
|
||||||
:style="{
|
ref="codeInput"
|
||||||
filter:
|
v-model="form.code"
|
||||||
resolvedAppearance === 'dark'
|
name="code"
|
||||||
? 'invert(1) brightness(1.5)'
|
type="text"
|
||||||
: undefined,
|
inputmode="numeric"
|
||||||
}"
|
autocomplete="one-time-code"
|
||||||
/>
|
pattern="[0-9]{6}"
|
||||||
</div>
|
maxlength="6"
|
||||||
</div>
|
required
|
||||||
</div>
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="!!form.errors.code"
|
||||||
<div class="flex w-full items-center space-x-5">
|
aria-describedby="authentication-code-error"
|
||||||
<Button class="w-full" @click="handleModalNextStep">
|
class="font-mono text-lg tracking-widest"
|
||||||
{{ modalConfig.buttonText }}
|
/>
|
||||||
</Button>
|
<InputError
|
||||||
</div>
|
id="authentication-code-error"
|
||||||
|
:message="form.errors.code"
|
||||||
<div
|
/>
|
||||||
class="relative flex w-full items-center justify-center"
|
</div>
|
||||||
>
|
<DialogFooter>
|
||||||
<div
|
<Button
|
||||||
class="bg-border absolute inset-0 top-1/2 h-px w-full"
|
type="button"
|
||||||
/>
|
variant="outline"
|
||||||
<span class="bg-card relative px-2 py-1"
|
:disabled="form.processing"
|
||||||
>or, enter the code manually</span
|
@click="
|
||||||
>
|
verification = false;
|
||||||
</div>
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
<div
|
"
|
||||||
class="flex w-full items-center justify-center space-x-2"
|
>{{ t('common.back') }}</Button
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!manualSetupKey"
|
|
||||||
class="bg-muted flex h-full w-full items-center justify-center p-3"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readonly
|
|
||||||
:value="manualSetupKey"
|
|
||||||
class="bg-background text-foreground h-full w-full p-3"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="copy(manualSetupKey || '')"
|
|
||||||
class="border-border hover:bg-muted relative block h-auto border-l px-3"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
v-if="copied"
|
|
||||||
class="w-4 text-green-500"
|
|
||||||
/>
|
|
||||||
<Copy v-else class="w-4" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<Form
|
|
||||||
v-bind="confirm.form()"
|
|
||||||
error-bag="confirmTwoFactorAuthentication"
|
|
||||||
reset-on-error
|
|
||||||
@finish="code = ''"
|
|
||||||
@success="isOpen = false"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="code" :value="code" />
|
<Button
|
||||||
<div
|
type="submit"
|
||||||
ref="pinInputContainerRef"
|
:disabled="form.code.length !== 6"
|
||||||
class="relative w-full space-y-3"
|
:loading="form.processing"
|
||||||
>
|
>{{ t('security.confirm') }}</Button
|
||||||
<div
|
>
|
||||||
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
|
</DialogFooter>
|
||||||
>
|
</form>
|
||||||
<InputOTP
|
|
||||||
id="otp"
|
|
||||||
v-model="code"
|
|
||||||
:maxlength="6"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot
|
|
||||||
v-for="index in 6"
|
|
||||||
:key="index"
|
|
||||||
:index="index - 1"
|
|
||||||
/>
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
<InputError :message="errors?.code" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full items-center space-x-5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
class="w-auto flex-1"
|
|
||||||
@click="showVerificationStep = false"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
class="w-auto flex-1"
|
|
||||||
:disabled="processing || code.length < 6"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { Layers3, RotateCcw } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
areas: string[];
|
areas: string[];
|
||||||
projectId: number;
|
projectId: number;
|
||||||
templateState?: ProjectTemplateState | null;
|
templateState?: ProjectTemplateState | null;
|
||||||
}>();
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
areas: string[];
|
areas: string[];
|
||||||
status_replacements: Record<string, number | undefined>;
|
status_replacements: Record<string, number | undefined>;
|
||||||
@@ -28,51 +34,82 @@ const form = useForm<{
|
|||||||
areas: [],
|
areas: [],
|
||||||
status_replacements: {},
|
status_replacements: {},
|
||||||
});
|
});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
const activeArea = computed(() => form.areas[0]);
|
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
|
||||||
|
|
||||||
const inheritedAreas = computed(
|
const inheritedAreas = computed(
|
||||||
() =>
|
() =>
|
||||||
props.templateState?.inheritedAreas.filter((area) =>
|
props.templateState?.inheritedAreas.filter((area) =>
|
||||||
props.areas.includes(area.value),
|
props.areas.includes(area.value),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overriddenAreas = computed(
|
const overriddenAreas = computed(
|
||||||
() =>
|
() =>
|
||||||
props.templateState?.overriddenAreas.filter((area) =>
|
props.templateState?.overriddenAreas.filter((area) =>
|
||||||
props.areas.includes(area.value),
|
props.areas.includes(area.value),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
const statusRequirement = computed(() =>
|
||||||
const statusRequirement = computed(
|
pendingArea.value?.value === 'ticket_statuses'
|
||||||
() => props.templateState?.resetRequirements?.ticket_statuses,
|
? props.templateState?.resetRequirements?.ticket_statuses
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
const canReset = computed(
|
||||||
const canReset = (area: ProjectTemplateStateArea): boolean => {
|
() =>
|
||||||
if (area.value !== 'ticket_statuses') {
|
statusRequirement.value?.removedStatuses.every((status) =>
|
||||||
return true;
|
statusRequirement.value?.replacementOptions.some(
|
||||||
}
|
(option) =>
|
||||||
|
option.id === form.status_replacements[String(status.id)],
|
||||||
return (
|
),
|
||||||
statusRequirement.value?.removedStatuses.every(
|
) ?? true,
|
||||||
(status) =>
|
);
|
||||||
form.status_replacements[String(status.id)] !== undefined,
|
const areaKeys = {
|
||||||
) ?? true
|
project_settings: 'inheritance.area.projectSettings',
|
||||||
);
|
board_columns: 'inheritance.area.boardColumns',
|
||||||
};
|
ticket_statuses: 'inheritance.area.ticketStatuses',
|
||||||
|
status_transitions: 'inheritance.area.statusTransitions',
|
||||||
const resetArea = (area: ProjectTemplateStateArea): void => {
|
ticket_types: 'inheritance.area.ticketTypes',
|
||||||
if (!canReset(area)) {
|
ticket_priorities: 'inheritance.area.ticketPriorities',
|
||||||
|
ticket_fields: 'inheritance.area.ticketFields',
|
||||||
|
ticket_link_types: 'inheritance.area.ticketLinkTypes',
|
||||||
|
repository_connections: 'inheritance.area.repositoryConnections',
|
||||||
|
notification_actions: 'inheritance.area.notificationActions',
|
||||||
|
} as const;
|
||||||
|
function areaLabel(area: ProjectTemplateStateArea): string {
|
||||||
|
const key = areaKeys[area.value as keyof typeof areaKeys];
|
||||||
|
return key ? t(key) : area.label;
|
||||||
|
}
|
||||||
|
function openReset(area: ProjectTemplateStateArea): void {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
pendingArea.value = area;
|
||||||
|
}
|
||||||
|
function closeReset(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
pendingArea.value = null;
|
||||||
form.areas = [area.value];
|
form.resetAndClearErrors();
|
||||||
form.post(ProjectController.resetTemplate.url(props.projectId), {
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
function resetArea(): void {
|
||||||
|
if (
|
||||||
|
!pendingArea.value ||
|
||||||
|
!canReset.value ||
|
||||||
|
!overriddenAreas.value.some(
|
||||||
|
(area) => area.value === pendingArea.value?.value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.areas = [pendingArea.value.value];
|
||||||
|
protection.submit(resetTemplate(props.projectId), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => form.reset(),
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
pendingArea.value = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
|||||||
templateState?.inheritsFromTemplate &&
|
templateState?.inheritsFromTemplate &&
|
||||||
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
||||||
"
|
"
|
||||||
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
<CardTitle>
|
<div class="flex items-center gap-2">
|
||||||
Template inheritance: {{ templateState.templateName }}
|
<Layers3
|
||||||
</CardTitle>
|
class="text-primary size-5 shrink-0"
|
||||||
<CardDescription>
|
aria-hidden="true"
|
||||||
Every setting area inherits independently. Local changes stay
|
/>
|
||||||
unchanged when the template is updated until that area is reset.
|
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
|
||||||
</CardDescription>
|
</div>
|
||||||
|
<p class="text-muted-foreground break-words">
|
||||||
|
{{
|
||||||
|
t('inheritance.description', {
|
||||||
|
template: templateState.templateName,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<div class="flex flex-wrap gap-2">
|
<p class="text-caption text-muted-foreground">
|
||||||
<Badge
|
{{ t('inheritance.help') }}
|
||||||
v-for="area in inheritedAreas"
|
</p>
|
||||||
:key="`inherited-${area.value}`"
|
<ul
|
||||||
variant="outline"
|
class="grid min-w-0 divide-y"
|
||||||
>
|
:aria-label="t('inheritance.areas')"
|
||||||
{{ area.label }} · inherited
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="area in overriddenAreas"
|
|
||||||
:key="`overridden-${area.value}`"
|
|
||||||
class="space-y-3 rounded-lg border p-4"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<li
|
||||||
<Badge>{{ area.label }} · template overridden</Badge>
|
v-for="area in inheritedAreas"
|
||||||
<Button
|
:key="area.value"
|
||||||
type="button"
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="form.processing || !canReset(area)"
|
|
||||||
@click="resetArea(area)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
activeArea === area.value && form.processing
|
|
||||||
? 'Resetting…'
|
|
||||||
: 'Reset to template'
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="area.value === 'ticket_statuses' && statusRequirement"
|
|
||||||
class="space-y-3"
|
|
||||||
>
|
>
|
||||||
<p class="text-muted-foreground text-sm">
|
<span class="min-w-0 break-words font-medium">{{
|
||||||
Choose where tickets in local statuses should move
|
areaLabel(area)
|
||||||
before those statuses are removed.
|
}}</span>
|
||||||
</p>
|
<Badge
|
||||||
<div
|
variant="outline"
|
||||||
v-for="status in statusRequirement.removedStatuses"
|
class="border-primary/25 text-primary"
|
||||||
:key="status.id"
|
>{{ t('projectSettings.inherited') }}</Badge
|
||||||
class="grid gap-2 sm:grid-cols-2 sm:items-center"
|
|
||||||
>
|
>
|
||||||
<Label :for="`replacement-${status.id}`">
|
</li>
|
||||||
{{ status.name }} ({{ status.ticketCount }} tickets)
|
<li
|
||||||
</Label>
|
v-for="area in overriddenAreas"
|
||||||
<div class="grid gap-1">
|
:key="area.value"
|
||||||
<select
|
class="gap-stack grid min-w-0 py-3"
|
||||||
:id="`replacement-${status.id}`"
|
>
|
||||||
v-model="
|
<div
|
||||||
form.status_replacements[String(status.id)]
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
|
||||||
"
|
>
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
|
<span class="min-w-0 break-words font-medium">{{
|
||||||
>
|
areaLabel(area)
|
||||||
<option :value="undefined" disabled>
|
}}</span>
|
||||||
Select replacement status
|
<Badge variant="secondary">{{
|
||||||
</option>
|
t('projectSettings.override')
|
||||||
<option
|
}}</Badge>
|
||||||
v-for="option in statusRequirement.replacementOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:value="option.id"
|
|
||||||
>
|
|
||||||
{{ option.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
form.errors[
|
|
||||||
`status_replacements.${status.id}`
|
|
||||||
]
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
|
||||||
<InputError :message="form.errors.areas" />
|
>
|
||||||
</div>
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.overrideHelp') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-label="
|
||||||
|
t('inheritance.resetArea', {
|
||||||
|
area: areaLabel(area),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openReset(area)"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />{{
|
||||||
|
t('inheritance.reset')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<Dialog :open="pendingArea !== null" @update:open="closeReset">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('inheritance.confirmTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('inheritance.confirmDescription', {
|
||||||
|
area: pendingArea ? areaLabel(pendingArea) : '',
|
||||||
|
template: templateState.templateName,
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="resetArea"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
form.processing
|
||||||
|
? t('inheritance.resetting')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="resetArea"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.areas ?? form.errors['areas.0']"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('inheritance.confirmTitle') }}
|
||||||
|
</legend>
|
||||||
|
<div
|
||||||
|
v-if="statusRequirement"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.statusHelp') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="status in statusRequirement.removedStatuses"
|
||||||
|
:key="status.id"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label :for="'replacement-' + status.id">{{
|
||||||
|
t('inheritance.statusLabel', {
|
||||||
|
status: status.name,
|
||||||
|
count: status.ticketCount,
|
||||||
|
})
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
:id="'replacement-' + status.id"
|
||||||
|
v-model="
|
||||||
|
form.status_replacements[
|
||||||
|
String(status.id)
|
||||||
|
]
|
||||||
|
"
|
||||||
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors[
|
||||||
|
`status_replacements.${status.id}`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
'replacement-error-' + status.id
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option :value="undefined" disabled>
|
||||||
|
{{ t('inheritance.selectStatus') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="option in statusRequirement.replacementOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:value="option.id"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:id="'replacement-error-' + status.id"
|
||||||
|
:message="
|
||||||
|
form.errors[
|
||||||
|
`status_replacements.${status.id}`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('inheritance.resetWarning') }}
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="closeReset(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" :disabled="!canReset">{{
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'inheritance.resetting'
|
||||||
|
: 'inheritance.reset',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,120 +1,107 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue';
|
import { computed, onScopeDispose, ref } from 'vue';
|
||||||
import { computed, ref } from 'vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||||
|
|
||||||
export type UseTwoFactorAuthReturn = {
|
/** Keep secrets within the consuming component and discard late responses after hiding them. */
|
||||||
qrCodeSvg: Ref<string | null>;
|
export function useTwoFactorAuth() {
|
||||||
manualSetupKey: Ref<string | null>;
|
const { t } = useLocalization();
|
||||||
recoveryCodesList: Ref<string[]>;
|
const errors = ref<string[]>([]);
|
||||||
errors: Ref<string[]>;
|
const manualSetupKey = ref<string | null>(null);
|
||||||
hasSetupData: ComputedRef<boolean>;
|
const qrCodeSvg = ref<string | null>(null);
|
||||||
clearSetupData: () => void;
|
const recoveryCodesList = ref<string[]>([]);
|
||||||
clearErrors: () => void;
|
const loading = ref(false);
|
||||||
clearTwoFactorAuthData: () => void;
|
let pending: AbortController | null = null;
|
||||||
fetchQrCode: () => Promise<void>;
|
|
||||||
fetchSetupKey: () => Promise<void>;
|
|
||||||
fetchSetupData: () => Promise<void>;
|
|
||||||
fetchRecoveryCodes: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
if (!response.ok) {
|
});
|
||||||
throw new Error(`Failed to fetch: ${response.status}`);
|
if (!response.ok || response.redirected) {
|
||||||
|
throw new Error(String(response.status));
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
function clearErrors(): void {
|
||||||
};
|
errors.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
const errors = ref<string[]>([]);
|
function clearTwoFactorAuthData(): void {
|
||||||
const manualSetupKey = ref<string | null>(null);
|
pending?.abort();
|
||||||
const qrCodeSvg = ref<string | null>(null);
|
pending = null;
|
||||||
const recoveryCodesList = ref<string[]>([]);
|
loading.value = false;
|
||||||
|
|
||||||
const hasSetupData = computed<boolean>(
|
|
||||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
|
||||||
const fetchQrCode = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
|
||||||
qrCode.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
qrCodeSvg.value = svg;
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch QR code');
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSetupKey = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
|
||||||
secretKey.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
manualSetupKey.value = key;
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch a setup key');
|
|
||||||
manualSetupKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSetupData = (): void => {
|
|
||||||
manualSetupKey.value = null;
|
manualSetupKey.value = null;
|
||||||
qrCodeSvg.value = null;
|
qrCodeSvg.value = null;
|
||||||
clearErrors();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearErrors = (): void => {
|
|
||||||
errors.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTwoFactorAuthData = (): void => {
|
|
||||||
clearSetupData();
|
|
||||||
clearErrors();
|
|
||||||
recoveryCodesList.value = [];
|
recoveryCodesList.value = [];
|
||||||
};
|
clearErrors();
|
||||||
|
}
|
||||||
|
|
||||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
async function load(kind: 'setup' | 'recovery'): Promise<void> {
|
||||||
|
clearTwoFactorAuthData();
|
||||||
|
const request = new AbortController();
|
||||||
|
pending = request;
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
clearErrors();
|
if (kind === 'setup') {
|
||||||
recoveryCodesList.value = await fetchJson<string[]>(
|
const [qr, key] = await Promise.all([
|
||||||
recoveryCodes.url(),
|
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
|
||||||
);
|
fetchJson<{ secretKey: string }>(
|
||||||
} catch {
|
secretKey.url(),
|
||||||
errors.value.push('Failed to fetch recovery codes');
|
request.signal,
|
||||||
recoveryCodesList.value = [];
|
),
|
||||||
|
]);
|
||||||
|
if (pending !== request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qrCodeSvg.value = qr.svg;
|
||||||
|
manualSetupKey.value = key.secretKey;
|
||||||
|
} else {
|
||||||
|
const codes = await fetchJson<string[]>(
|
||||||
|
recoveryCodes.url(),
|
||||||
|
request.signal,
|
||||||
|
);
|
||||||
|
if (pending === request) {
|
||||||
|
recoveryCodesList.value = codes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (pending === request && !request.signal.aborted) {
|
||||||
|
const status = error instanceof Error ? error.message : '';
|
||||||
|
errors.value = [
|
||||||
|
t(
|
||||||
|
status === '423' || status === '401' || status === '419'
|
||||||
|
? 'security.confirmAgain'
|
||||||
|
: kind === 'setup'
|
||||||
|
? 'security.setupError'
|
||||||
|
: 'security.recoveryError',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pending === request) {
|
||||||
|
pending = null;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const fetchSetupData = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
clearErrors();
|
|
||||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
|
||||||
} catch {
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
manualSetupKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
onScopeDispose(clearTwoFactorAuthData);
|
||||||
return {
|
return {
|
||||||
qrCodeSvg,
|
qrCodeSvg,
|
||||||
manualSetupKey,
|
manualSetupKey,
|
||||||
recoveryCodesList,
|
recoveryCodesList,
|
||||||
errors,
|
errors,
|
||||||
hasSetupData,
|
loading,
|
||||||
clearSetupData,
|
hasSetupData: computed(() =>
|
||||||
clearErrors,
|
Boolean(qrCodeSvg.value && manualSetupKey.value),
|
||||||
|
),
|
||||||
|
clearSetupData: clearTwoFactorAuthData,
|
||||||
|
clearRecoveryCodes: clearTwoFactorAuthData,
|
||||||
clearTwoFactorAuthData,
|
clearTwoFactorAuthData,
|
||||||
fetchQrCode,
|
clearErrors,
|
||||||
fetchSetupKey,
|
fetchSetupData: () => load('setup'),
|
||||||
fetchSetupData,
|
fetchRecoveryCodes: () => load('recovery'),
|
||||||
fetchRecoveryCodes,
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { Settings2 } from 'lucide-vue-next';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { toUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
defineProps<{ projectName: string; projectKey: string }>();
|
||||||
|
const { projectSettingsItems } = useAppNavigation();
|
||||||
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
|
||||||
|
>
|
||||||
|
<header class="gap-stack grid min-w-0">
|
||||||
|
<div class="text-primary flex items-center gap-2">
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<p class="text-label text-muted-foreground break-all uppercase">
|
||||||
|
{{ projectKey }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
|
{{ t('navigation.projectSettings') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground break-words">
|
||||||
|
{{ t('projectSettings.description', { project: projectName }) }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||||
|
:aria-label="t('navigation.projectSettings')"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-for="item in projectSettingsItems"
|
||||||
|
:key="toUrl(item.href)"
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentUrl(item.href) ? 'page' : undefined
|
||||||
|
"
|
||||||
|
:class="[
|
||||||
|
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||||
|
isCurrentUrl(item.href)
|
||||||
|
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
|
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -71,7 +71,7 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-card grid min-w-0 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
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
|
<aside
|
||||||
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
@@ -105,7 +105,7 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
<section class="mx-auto grid max-w-3xl gap-8">
|
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,94 @@
|
|||||||
export const en = {
|
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.title': 'Acceptance criteria',
|
||||||
'criteria.progress': '{completed} of {total} complete',
|
'criteria.progress': '{completed} of {total} complete',
|
||||||
'criteria.empty': 'No acceptance criteria yet.',
|
'criteria.empty': 'No acceptance criteria yet.',
|
||||||
@@ -641,4 +731,121 @@ export const en = {
|
|||||||
'ticketForm.saved': 'All changes saved',
|
'ticketForm.saved': 'All changes saved',
|
||||||
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||||
'ticketForm.blocker': 'Blocker',
|
'ticketForm.blocker': 'Blocker',
|
||||||
|
'projectSettings.pageTitle': '{project} general settings',
|
||||||
|
'projectSettings.description':
|
||||||
|
'Manage settings and template inheritance for {project}.',
|
||||||
|
'projectSettings.generalDescription':
|
||||||
|
'Review project identity, setting sources, and administrative actions.',
|
||||||
|
'projectSettings.identity': 'Project identity',
|
||||||
|
'projectSettings.identityDescription':
|
||||||
|
'The name and key belong to this project and are never inherited from a template.',
|
||||||
|
'projectSettings.name': 'Project name',
|
||||||
|
'projectSettings.key': 'Project key',
|
||||||
|
'projectSettings.local': 'Local',
|
||||||
|
'projectSettings.inherited': 'Inherited',
|
||||||
|
'projectSettings.override': 'Local override',
|
||||||
|
'projectSettings.display': 'Ticket display',
|
||||||
|
'projectSettings.showAuthor': 'Show author',
|
||||||
|
'projectSettings.showStoryPoints': 'Show story points',
|
||||||
|
'projectSettings.enabled': 'Enabled',
|
||||||
|
'projectSettings.disabled': 'Disabled',
|
||||||
|
'projectSettings.displayDescription':
|
||||||
|
'Manage these display options in Ticket settings.',
|
||||||
|
'projectSettings.template': 'Template baseline',
|
||||||
|
'projectSettings.templateDescription':
|
||||||
|
'Manage the linked template or reset individual areas using the inheritance panel.',
|
||||||
|
'projectSettings.currentTemplate': 'Current linked template',
|
||||||
|
'projectSettings.noTemplate': 'No linked template',
|
||||||
|
'projectSettings.linked': 'Template inheritance active',
|
||||||
|
'projectSettings.standalone': 'Standalone',
|
||||||
|
'projectSettings.standaloneDescription':
|
||||||
|
'This project manages its settings locally. Published template updates are not applied.',
|
||||||
|
'projectSettings.noAlternatives': 'No other published templates',
|
||||||
|
'projectSettings.noAlternativesDescription':
|
||||||
|
'A different published template is required to switch this project.',
|
||||||
|
'projectSettings.switch': 'Switch template',
|
||||||
|
'projectSettings.switching': 'Switching template…',
|
||||||
|
'projectSettings.switchWarning':
|
||||||
|
'Switching templates can replace or remove project configuration in copied areas, including workflows, ticket metadata, repositories, and notifications.',
|
||||||
|
'projectSettings.publishedTemplate': 'Published template',
|
||||||
|
'projectSettings.selectTemplate': 'Select a template',
|
||||||
|
'projectSettings.confirmation': 'Confirmation key',
|
||||||
|
'projectSettings.confirmPlaceholder': 'Type {key} exactly',
|
||||||
|
'projectSettings.confirmHelp':
|
||||||
|
'Enter the exact project key {key} to confirm this action. The key is case sensitive.',
|
||||||
|
'projectSettings.danger': 'Danger zone',
|
||||||
|
'projectSettings.deleteDescription':
|
||||||
|
'Permanently delete this project and all of its tickets, board columns, ticket metadata, repository connections, and project permissions.',
|
||||||
|
'projectSettings.irreversible': 'This action cannot be undone.',
|
||||||
|
'projectSettings.delete': 'Delete project',
|
||||||
|
'projectSettings.deleting': 'Deleting project…',
|
||||||
|
'projectSettings.updated': 'Project settings updated.',
|
||||||
|
'inheritance.title': 'Template inheritance',
|
||||||
|
'inheritance.description': 'Settings linked to {template}.',
|
||||||
|
'inheritance.help':
|
||||||
|
'Each area inherits independently. Settings outside the template are managed locally.',
|
||||||
|
'inheritance.areas': 'Setting sources',
|
||||||
|
'inheritance.overrideHelp':
|
||||||
|
'Local changes stay in place when the template is updated.',
|
||||||
|
'inheritance.reset': 'Reset to template',
|
||||||
|
'inheritance.resetArea': 'Reset {area} to template',
|
||||||
|
'inheritance.resetting': 'Resetting…',
|
||||||
|
'inheritance.confirmTitle': 'Reset this setting area?',
|
||||||
|
'inheritance.confirmDescription':
|
||||||
|
'Replace local changes in {area} with the current baseline from {template}. Other setting areas keep their current values.',
|
||||||
|
'inheritance.resetWarning':
|
||||||
|
'Local configuration in this area may be replaced or removed.',
|
||||||
|
'inheritance.statusHelp':
|
||||||
|
'Choose where tickets in local statuses should move before those statuses are removed.',
|
||||||
|
'inheritance.statusLabel': '{status} ({count} tickets)',
|
||||||
|
'inheritance.selectStatus': 'Select replacement status',
|
||||||
|
'inheritance.area.projectSettings': 'Project settings',
|
||||||
|
'inheritance.area.boardColumns': 'Board columns',
|
||||||
|
'inheritance.area.ticketStatuses': 'Ticket statuses',
|
||||||
|
'inheritance.area.statusTransitions': 'Status transitions',
|
||||||
|
'inheritance.area.ticketTypes': 'Ticket types',
|
||||||
|
'inheritance.area.ticketPriorities': 'Ticket priorities',
|
||||||
|
'inheritance.area.ticketFields': 'Custom ticket fields',
|
||||||
|
'inheritance.area.ticketLinkTypes': 'Ticket link types',
|
||||||
|
'inheritance.area.repositoryConnections': 'Repository connections',
|
||||||
|
'inheritance.area.notificationActions': 'Notification actions',
|
||||||
|
'gitConnections.description':
|
||||||
|
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
|
||||||
|
'gitConnections.privacy':
|
||||||
|
'Connections belong to your account. Saved tokens stay hidden. Without a matching connection, only repository names are available.',
|
||||||
|
'gitConnections.add': 'Add connection',
|
||||||
|
'gitConnections.editAction': 'Edit',
|
||||||
|
'gitConnections.edit': 'Edit connection',
|
||||||
|
'gitConnections.remove': 'Remove',
|
||||||
|
'gitConnections.removeTitle': 'Remove connection',
|
||||||
|
'gitConnections.saved': 'Saved connections',
|
||||||
|
'gitConnections.empty': 'No Git connections yet',
|
||||||
|
'gitConnections.emptyDescription':
|
||||||
|
'Add a Gitea connection to access your repositories and their Git activity.',
|
||||||
|
'gitConnections.unnamedAccount': 'Unnamed remote account',
|
||||||
|
'gitConnections.remoteId': 'Remote user ID: {id}',
|
||||||
|
'gitConnections.editAccount': 'Edit {account} at {server}',
|
||||||
|
'gitConnections.removeAccount': 'Remove {account} at {server}',
|
||||||
|
'gitConnections.addDescription':
|
||||||
|
'Connect a Gitea account using its server URL and a personal application token.',
|
||||||
|
'gitConnections.editDescription':
|
||||||
|
'Update the server URL and enter a fresh token to verify this connection.',
|
||||||
|
'gitConnections.removeDescription':
|
||||||
|
'Remove this personal connection? Repository links remain, but Git activity will require another matching connection.',
|
||||||
|
'gitConnections.gitea': 'Gitea',
|
||||||
|
'gitConnections.baseUrl': 'Gitea base URL',
|
||||||
|
'gitConnections.token': 'Application token',
|
||||||
|
'gitConnections.newToken': 'New application token',
|
||||||
|
'gitConnections.tokenPlaceholder': 'Paste your Gitea application token',
|
||||||
|
'gitConnections.tokenHelp':
|
||||||
|
'Your token is verified with Gitea before saving. It is never shown in the saved connection.',
|
||||||
|
'gitConnections.verifying': 'Verifying connection…',
|
||||||
|
'gitConnections.save': 'Save changes',
|
||||||
|
'gitConnections.removing': 'Removing connection…',
|
||||||
|
'gitConnections.forbidden': 'Connection access denied',
|
||||||
|
'gitConnections.forbiddenDescription':
|
||||||
|
'You can only manage connections belonging to your account. Close this dialog and refresh the page.',
|
||||||
|
'gitConnections.added': 'Git connection added.',
|
||||||
|
'gitConnections.updated': 'Git connection updated.',
|
||||||
|
'gitConnections.removed': 'Git connection removed.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,281 +1,316 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { Layers3, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
switchTemplate,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import {
|
import { general } from '@/routes/projects/settings';
|
||||||
board as boardSettings,
|
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: index(),
|
{ title: t('navigation.general'), href: general(props.project.id) },
|
||||||
},
|
]);
|
||||||
{
|
const switchTemplateForm = useForm<{
|
||||||
title: props.project.name,
|
confirmation_key: string;
|
||||||
href: show(props.project.id),
|
source_template_id: number | undefined;
|
||||||
},
|
}>({
|
||||||
{
|
|
||||||
title: 'General settings',
|
|
||||||
href: generalSettings(props.project.id),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const switchTemplateForm = useForm({
|
|
||||||
confirmation_key: '',
|
confirmation_key: '',
|
||||||
source_template_id: props.sourceTemplates.find(
|
source_template_id: undefined,
|
||||||
(template) => template.id !== props.project.project_template_id,
|
|
||||||
)?.id,
|
|
||||||
});
|
});
|
||||||
|
const deleteForm = useForm({ confirmation_key: '' });
|
||||||
const deleteForm = useForm({
|
const switchProtection = useFormProtection(switchTemplateForm);
|
||||||
confirmation_key: '',
|
const deleteProtection = useFormProtection(deleteForm);
|
||||||
});
|
const processing = computed(
|
||||||
|
() => switchTemplateForm.processing || deleteForm.processing,
|
||||||
|
);
|
||||||
const availableSwitchTemplates = computed(() =>
|
const availableSwitchTemplates = computed(() =>
|
||||||
props.sourceTemplates.filter(
|
props.sourceTemplates.filter(
|
||||||
(template) => template.id !== props.project.project_template_id,
|
(template) => template.id !== props.project.project_template_id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTemplate = computed(() => props.project.project_template ?? null);
|
const currentTemplate = computed(() => props.project.project_template ?? null);
|
||||||
|
|
||||||
const isSwitchConfirmationValid = computed(
|
|
||||||
() => switchTemplateForm.confirmation_key === props.project.key,
|
|
||||||
);
|
|
||||||
|
|
||||||
const canSwitchTemplate = computed(
|
const canSwitchTemplate = computed(
|
||||||
() =>
|
() =>
|
||||||
isSwitchConfirmationValid.value &&
|
switchTemplateForm.confirmation_key === props.project.key &&
|
||||||
switchTemplateForm.source_template_id !== undefined,
|
availableSwitchTemplates.value.some(
|
||||||
|
(template) => template.id === switchTemplateForm.source_template_id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDeleteConfirmationValid = computed(
|
const isDeleteConfirmationValid = computed(
|
||||||
() => deleteForm.confirmation_key === props.project.key,
|
() => deleteForm.confirmation_key === props.project.key,
|
||||||
);
|
);
|
||||||
|
const inheritanceAreas = computed(() =>
|
||||||
|
[
|
||||||
|
...(props.templateState?.inheritedAreas ?? []),
|
||||||
|
...(props.templateState?.overriddenAreas ?? []),
|
||||||
|
].map((area) => area.value),
|
||||||
|
);
|
||||||
|
const settingsSource = computed(() => {
|
||||||
|
if (!props.templateState?.inheritsFromTemplate) {
|
||||||
|
return 'projectSettings.local';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.templateState.inheritedAreas.some(
|
||||||
|
(area) => area.value === 'project_settings',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'projectSettings.inherited';
|
||||||
|
}
|
||||||
|
return props.templateState.overriddenAreas.some(
|
||||||
|
(area) => area.value === 'project_settings',
|
||||||
|
)
|
||||||
|
? 'projectSettings.override'
|
||||||
|
: 'projectSettings.local';
|
||||||
|
});
|
||||||
|
|
||||||
const submitSwitchTemplate = (): void => {
|
function submitSwitchTemplate(): void {
|
||||||
if (!canSwitchTemplate.value) {
|
if (!canSwitchTemplate.value || processing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
switchProtection.submit(switchTemplate(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switchTemplateForm.post(
|
function submitDelete(): void {
|
||||||
ProjectController.switchTemplate.url(props.project.id),
|
if (!isDeleteConfirmationValid.value || processing.value) {
|
||||||
{
|
|
||||||
preserveScroll: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitDelete = (): void => {
|
|
||||||
if (!isDeleteConfirmationValid.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
deleteProtection.submit(destroy(props.project.id), {
|
||||||
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} general settings`" />
|
<Head
|
||||||
|
:title="t('projectSettings.pageTitle', { project: project.name })"
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
/>
|
||||||
<div
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
<FeedbackState
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
|
||||||
{{ project.key }}
|
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="`${project.name} general settings`"
|
|
||||||
description="Review the core project identity and manage irreversible administrative actions."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="boardSettings(project.id)">
|
|
||||||
Board settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
state="success"
|
||||||
|
:title="t('projectSettings.updated')"
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
</div>
|
<h2 class="text-title">{{ t('navigation.general') }}</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<Card>
|
{{ t('projectSettings.generalDescription') }}
|
||||||
<CardHeader>
|
</p>
|
||||||
<CardTitle>Project identity</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
These values define the project across tickets, boards,
|
|
||||||
and repository matching.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<h3 class="font-semibold">
|
||||||
<p
|
{{ t('projectSettings.identity') }}
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
</h3>
|
||||||
>
|
<Badge variant="secondary">{{
|
||||||
Project name
|
t('projectSettings.local')
|
||||||
</p>
|
}}</Badge>
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
|
||||||
{{ project.name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<div
|
||||||
<p
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.name') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words font-semibold">
|
||||||
|
{{ project.name }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.key') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-all font-semibold">
|
||||||
|
{{ project.key }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.identityDescription') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 border-t pt-4"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ t('projectSettings.display') }}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
:variant="
|
||||||
|
settingsSource === 'projectSettings.inherited'
|
||||||
|
? 'outline'
|
||||||
|
: 'secondary'
|
||||||
|
"
|
||||||
|
>{{ t(settingsSource) }}</Badge
|
||||||
>
|
>
|
||||||
Project key
|
|
||||||
</p>
|
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
|
||||||
{{ project.key }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="grid gap-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.showAuthor') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
project.show_author
|
||||||
|
? 'projectSettings.enabled'
|
||||||
|
: 'projectSettings.disabled',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.showStoryPoints') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
project.show_story_points
|
||||||
|
? 'projectSettings.enabled'
|
||||||
|
: 'projectSettings.disabled',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.displayDescription') }}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<TemplateInheritancePanel
|
||||||
<Card>
|
:project-id="project.id"
|
||||||
<CardHeader>
|
:areas="inheritanceAreas"
|
||||||
<CardTitle>Template baseline</CardTitle>
|
:template-state="templateState"
|
||||||
<CardDescription>
|
/>
|
||||||
Switch this project to another published template. Use
|
<Card
|
||||||
the inheritance panels to reset individual settings to
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
the currently linked template.
|
>
|
||||||
</CardDescription>
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
</CardHeader>
|
<div class="flex items-center gap-2">
|
||||||
|
<Layers3
|
||||||
<CardContent class="space-y-6">
|
class="text-primary size-5"
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
aria-hidden="true"
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
/>
|
||||||
<p
|
<h2 class="text-title">
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
{{ t('projectSettings.template') }}
|
||||||
>
|
</h2>
|
||||||
Current linked template
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-foreground mt-2 text-sm font-semibold"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
currentTemplate?.name ??
|
|
||||||
'No linked template'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{
|
|
||||||
templateState?.inheritsFromTemplate
|
|
||||||
? 'Inherited and overridden areas stay visible after switching.'
|
|
||||||
: 'This project currently behaves like a standalone baseline.'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
|
||||||
>
|
|
||||||
Warning
|
|
||||||
</p>
|
|
||||||
<p class="text-foreground mt-2 text-sm font-medium">
|
|
||||||
Switching templates can replace inherited board
|
|
||||||
workflow, ticket metadata, repositories, and
|
|
||||||
notifications.
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
Confirm each action with the exact project key
|
|
||||||
to avoid accidental overwrites.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<div class="grid gap-6">
|
{{ t('projectSettings.templateDescription') }}
|
||||||
<form
|
</p>
|
||||||
class="space-y-4 rounded-xl border p-4"
|
</CardHeader>
|
||||||
@submit.prevent="submitSwitchTemplate"
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
|
<div
|
||||||
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.currentTemplate') }}
|
||||||
|
</p>
|
||||||
|
<p class="break-words font-semibold">
|
||||||
|
{{
|
||||||
|
currentTemplate?.name ??
|
||||||
|
t('projectSettings.noTemplate')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" class="justify-self-start">{{
|
||||||
|
t(
|
||||||
|
templateState?.inheritsFromTemplate
|
||||||
|
? 'projectSettings.linked'
|
||||||
|
: 'projectSettings.standalone',
|
||||||
|
)
|
||||||
|
}}</Badge>
|
||||||
|
<p
|
||||||
|
v-if="!templateState?.inheritsFromTemplate"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div class="space-y-1">
|
{{ t('projectSettings.standaloneDescription') }}
|
||||||
<h3 class="text-sm font-semibold">
|
</p>
|
||||||
Switch to another template
|
</div>
|
||||||
</h3>
|
<FeedbackState
|
||||||
<p class="text-muted-foreground text-sm">
|
v-if="availableSwitchTemplates.length === 0"
|
||||||
Applying a different template replaces the
|
state="empty"
|
||||||
copied areas with that template's current
|
:title="t('projectSettings.noAlternatives')"
|
||||||
baseline.
|
:description="
|
||||||
</p>
|
t('projectSettings.noAlternativesDescription')
|
||||||
</div>
|
"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="switchTemplateForm.processing"
|
||||||
|
@submit.prevent="submitSwitchTemplate"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="switchProtection.state.value"
|
||||||
|
:title="
|
||||||
|
switchTemplateForm.processing
|
||||||
|
? t('projectSettings.switching')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submitSwitchTemplate"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
<legend class="mb-3 font-semibold">
|
||||||
|
{{ t('projectSettings.switch') }}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
{{ t('projectSettings.switchWarning') }}
|
||||||
|
</p>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="source_template_id"
|
<Label for="source_template_id">{{
|
||||||
>Published template</Label
|
t('projectSettings.publishedTemplate')
|
||||||
>
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="
|
v-model="
|
||||||
switchTemplateForm.source_template_id
|
switchTemplateForm.source_template_id
|
||||||
"
|
"
|
||||||
:disabled="
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||||
switchTemplateForm.processing ||
|
:aria-invalid="
|
||||||
availableSwitchTemplates.length === 0
|
!!switchTemplateForm.errors
|
||||||
|
.source_template_id
|
||||||
"
|
"
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
aria-describedby="source-template-error"
|
||||||
>
|
>
|
||||||
<option :value="undefined" disabled>
|
<option :value="undefined" disabled>
|
||||||
Select a template
|
{{
|
||||||
|
t('projectSettings.selectTemplate')
|
||||||
|
}}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="template in availableSwitchTemplates"
|
v-for="template in availableSwitchTemplates"
|
||||||
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-template-error"
|
||||||
:message="
|
:message="
|
||||||
switchTemplateForm.errors
|
switchTemplateForm.errors
|
||||||
.source_template_id
|
.source_template_id
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="switch_confirmation_key"
|
<Label for="switch_confirmation_key">{{
|
||||||
>Confirmation key</Label
|
t('projectSettings.confirmation')
|
||||||
>
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="switch_confirmation_key"
|
id="switch_confirmation_key"
|
||||||
v-model="
|
v-model="
|
||||||
switchTemplateForm.confirmation_key
|
switchTemplateForm.confirmation_key
|
||||||
"
|
"
|
||||||
:disabled="switchTemplateForm.processing"
|
autocomplete="off"
|
||||||
placeholder="Type the project key exactly"
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'projectSettings.confirmPlaceholder',
|
||||||
|
{ key: project.key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!switchTemplateForm.errors
|
||||||
|
.confirmation_key
|
||||||
|
"
|
||||||
|
aria-describedby="switch-confirm-help switch-confirm-error"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
id="switch-confirm-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectSettings.confirmHelp', {
|
||||||
|
key: project.key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="switch-confirm-error"
|
||||||
:message="
|
:message="
|
||||||
switchTemplateForm.errors
|
switchTemplateForm.errors
|
||||||
.confirmation_key
|
.confirmation_key
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Existing project-specific configuration in
|
|
||||||
copied areas may be replaced or removed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="
|
|
||||||
switchTemplateForm.processing ||
|
|
||||||
!canSwitchTemplate
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Switch template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-red-900 dark:text-red-100">
|
|
||||||
Danger zone
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription
|
|
||||||
class="text-red-800/80 dark:text-red-200/80"
|
|
||||||
>
|
|
||||||
Deleting this project is permanent. All tickets, board
|
|
||||||
columns, ticket metadata, repository connections, and
|
|
||||||
project permissions will be removed.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-5">
|
|
||||||
<div
|
|
||||||
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
|
|
||||||
>
|
|
||||||
<p class="text-foreground text-sm font-medium">
|
|
||||||
Confirm by typing the exact project key:
|
|
||||||
<span class="font-semibold">{{ project.key }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="submitDelete">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="confirmation_key">
|
|
||||||
Confirmation key
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="confirmation_key"
|
|
||||||
v-model="deleteForm.confirmation_key"
|
|
||||||
:disabled="deleteForm.processing"
|
|
||||||
placeholder="Type the project key exactly"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="deleteForm.errors.confirmation_key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="outline"
|
||||||
:disabled="
|
class="justify-self-start"
|
||||||
deleteForm.processing ||
|
:disabled="!canSwitchTemplate"
|
||||||
!isDeleteConfirmationValid
|
>{{
|
||||||
"
|
t(
|
||||||
|
switchTemplateForm.processing
|
||||||
|
? 'projectSettings.switching'
|
||||||
|
: 'projectSettings.switch',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Delete project
|
</fieldset>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
<Card
|
||||||
|
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
|
>
|
||||||
|
<CardHeader
|
||||||
|
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Trash2
|
||||||
|
class="text-destructive size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2 class="text-title">
|
||||||
|
{{ t('projectSettings.danger') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.deleteDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="deleteForm.processing"
|
||||||
|
@submit.prevent="submitDelete"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="deleteProtection.state.value"
|
||||||
|
:title="
|
||||||
|
deleteForm.processing
|
||||||
|
? t('projectSettings.deleting')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submitDelete"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('projectSettings.delete') }}
|
||||||
|
</legend>
|
||||||
|
<p class="text-caption font-medium">
|
||||||
|
{{ t('projectSettings.irreversible') }}
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="confirmation_key">{{
|
||||||
|
t('projectSettings.confirmation')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmation_key"
|
||||||
|
v-model="deleteForm.confirmation_key"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'projectSettings.confirmPlaceholder',
|
||||||
|
{ key: project.key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!deleteForm.errors.confirmation_key
|
||||||
|
"
|
||||||
|
aria-describedby="delete-confirm-help delete-confirm-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="delete-confirm-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectSettings.confirmHelp', {
|
||||||
|
key: project.key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="delete-confirm-error"
|
||||||
|
:message="
|
||||||
|
deleteForm.errors.confirmation_key
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
class="justify-self-start"
|
||||||
|
:disabled="!isDeleteConfirmationValid"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
deleteForm.processing
|
||||||
|
? 'projectSettings.deleting'
|
||||||
|
: 'projectSettings.delete',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,198 +1,318 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
destroy,
|
||||||
CardContent,
|
store,
|
||||||
CardDescription,
|
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||||
CardHeader,
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
CardTitle,
|
import InputError from '@/components/InputError.vue';
|
||||||
} from '@/components/ui/card';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit as editApplications } from '@/routes/applications/index';
|
import { edit } from '@/routes/applications/index';
|
||||||
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||||
|
|
||||||
defineProps<SettingsApplicationsPageProps>();
|
const props = defineProps<SettingsApplicationsPageProps>();
|
||||||
|
const { t, locale } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.applications'), href: edit() },
|
||||||
|
]);
|
||||||
|
const form = useForm({ name: '' });
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const deleteForm = useForm({});
|
||||||
|
const deleteProtection = useFormProtection(deleteForm);
|
||||||
|
const selected = ref<
|
||||||
|
SettingsApplicationsPageProps['applications'][number] | null
|
||||||
|
>(null);
|
||||||
|
const copied = ref(false);
|
||||||
|
const copyError = ref(false);
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
function formatDate(value: string | null): string {
|
||||||
{
|
if (!value) {
|
||||||
title: 'Applications',
|
return t('applications.never');
|
||||||
href: editApplications(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatDate = (value: string | null): string => {
|
|
||||||
if (value === null) {
|
|
||||||
return 'Never';
|
|
||||||
}
|
}
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
};
|
}
|
||||||
|
function createApplication(): void {
|
||||||
|
protection.submit(store(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function closeDelete(open: boolean): void {
|
||||||
|
if (!open && !deleteForm.processing) {
|
||||||
|
selected.value = null;
|
||||||
|
deleteProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deleteApplication(): void {
|
||||||
|
if (!selected.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteProtection.submit(destroy(selected.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
selected.value = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
const key = props.applicationKey;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyError.value = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
if (props.applicationKey === key) {
|
||||||
|
copied.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (props.applicationKey === key) {
|
||||||
|
copyError.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => props.applicationKey,
|
||||||
|
() => {
|
||||||
|
copied.value = false;
|
||||||
|
copyError.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Applications" />
|
<Head :title="t('applications.title')" />
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="gap-stack grid">
|
||||||
<div class="space-y-2">
|
<h2 class="text-title">{{ t('applications.title') }}</h2>
|
||||||
<Heading
|
<p class="text-muted-foreground">
|
||||||
variant="small"
|
{{ t('applications.description') }}
|
||||||
title="Applications"
|
</p>
|
||||||
description="Create API keys for tools that should use KanbanGit with your account permissions."
|
</div>
|
||||||
/>
|
<p
|
||||||
<p class="text-muted-foreground text-sm">
|
v-if="status"
|
||||||
Keys are shown once. Delete an application when a tool
|
role="status"
|
||||||
should lose access.
|
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
|
||||||
</p>
|
>
|
||||||
</div>
|
{{ status }}
|
||||||
|
</p>
|
||||||
<div
|
<section
|
||||||
v-if="status"
|
v-if="applicationKey"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
|
||||||
>
|
aria-labelledby="application-key-title"
|
||||||
{{ status }}
|
>
|
||||||
</div>
|
<h3 id="application-key-title" class="font-semibold">
|
||||||
|
{{ t('applications.saveKey') }}
|
||||||
<div
|
</h3>
|
||||||
v-if="applicationKey"
|
<p class="text-body text-muted-foreground">
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
|
{{ t('applications.keyOnce') }}
|
||||||
>
|
</p>
|
||||||
<p class="font-medium">Save this API key now.</p>
|
<Label for="application-key" class="sr-only">{{
|
||||||
<p class="mt-1">
|
t('applications.apiKey')
|
||||||
It will not be shown again after you leave this page.
|
}}</Label>
|
||||||
</p>
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
<Input
|
<Input
|
||||||
class="mt-3 font-mono text-xs"
|
id="application-key"
|
||||||
:model-value="applicationKey"
|
:model-value="applicationKey"
|
||||||
readonly
|
readonly
|
||||||
|
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
|
||||||
/>
|
/>
|
||||||
|
<Button variant="outline" @click="copyKey"
|
||||||
|
><Check v-if="copied" /><Copy v-else />{{
|
||||||
|
t(copied ? 'security.copied' : 'security.copyKey')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
v-if="copied"
|
||||||
<Card>
|
role="status"
|
||||||
<CardHeader>
|
class="text-caption text-success-foreground"
|
||||||
<CardTitle>Create application</CardTitle>
|
>
|
||||||
<CardDescription>
|
{{ t('security.copied') }}
|
||||||
Name the tool that will receive an API key.
|
</p>
|
||||||
</CardDescription>
|
<InputError
|
||||||
</CardHeader>
|
v-if="copyError"
|
||||||
|
:message="t('security.copyError')"
|
||||||
<CardContent>
|
/>
|
||||||
<Form
|
</section>
|
||||||
v-bind="UserApplicationController.store.form()"
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
class="space-y-4"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
v-slot="{ errors, processing }"
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
|
<KeyRound class="text-primary size-5" />{{
|
||||||
|
t('applications.create')
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('applications.nameDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="createApplication"
|
||||||
|
>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
|
:loading-title="t('applications.creating')"
|
||||||
|
@retry="createApplication"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="application-name">{{
|
||||||
|
t('applications.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="application-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="t('applications.namePlaceholder')"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="application-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="application-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="justify-self-start"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('applications.creating')"
|
||||||
|
>{{ t('applications.create') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('applications.hiddenKeys') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="gap-stack grid p-5 sm:p-6">
|
||||||
|
<p
|
||||||
|
v-if="!applications.length"
|
||||||
|
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
|
||||||
|
>
|
||||||
|
{{ t('applications.empty') }}
|
||||||
|
</p>
|
||||||
|
<article
|
||||||
|
v-for="application in applications"
|
||||||
|
:key="application.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1 basis-40">
|
||||||
|
<h4 class="break-words font-semibold">
|
||||||
|
{{ application.name }}
|
||||||
|
</h4>
|
||||||
|
<dl
|
||||||
|
class="gap-stack text-caption text-muted-foreground grid"
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<div>
|
||||||
<Label for="name">Application name</Label>
|
<dt class="inline font-medium">
|
||||||
<Input
|
{{ t('applications.created') }}
|
||||||
id="name"
|
</dt>
|
||||||
name="name"
|
<dd class="inline">
|
||||||
type="text"
|
{{ formatDate(application.created_at) }}
|
||||||
required
|
</dd>
|
||||||
autofocus
|
|
||||||
placeholder="Claude Desktop"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<Button type="submit" :disabled="processing">
|
<dt class="inline font-medium">
|
||||||
Create application
|
{{ t('applications.lastUsed') }}
|
||||||
</Button>
|
</dt>
|
||||||
</Form>
|
<dd class="inline">
|
||||||
</CardContent>
|
{{
|
||||||
</Card>
|
formatDate(application.last_used_at)
|
||||||
|
}}
|
||||||
<Card class="gap-0 overflow-hidden">
|
</dd>
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Saved applications</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Existing keys stay hidden. Create a new
|
|
||||||
application when another key is needed.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="applications.length === 0"
|
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
||||||
>
|
|
||||||
No applications have been created yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="application in applications"
|
|
||||||
:key="application.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-start justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge>API key</Badge>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{ application.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Created:
|
|
||||||
{{
|
|
||||||
formatDate(
|
|
||||||
application.created_at,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Last used:
|
|
||||||
{{
|
|
||||||
formatDate(
|
|
||||||
application.last_used_at,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
UserApplicationController.destroy.form(
|
|
||||||
application.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
</div>
|
class="text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('applications.deleteNamed', {
|
||||||
|
name: application.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="selected = application"
|
||||||
|
><Trash2 />{{ t('applications.delete') }}</Button
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Dialog :open="!!selected" @update:open="closeDelete">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!deleteForm.processing"
|
||||||
|
@escape-key-down="
|
||||||
|
deleteForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
@interact-outside="
|
||||||
|
deleteForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('applications.deleteTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('applications.deleteDescription', {
|
||||||
|
name: selected?.name ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="deleteProtection.state.value"
|
||||||
|
:response-status="deleteProtection.responseStatus.value"
|
||||||
|
@retry="deleteApplication"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="deleteForm.processing"
|
||||||
|
@click="closeDelete(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
:loading="deleteForm.processing"
|
||||||
|
@click="deleteApplication"
|
||||||
|
>{{ t('applications.delete') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,267 +1,493 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, usePage } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
GitBranch,
|
||||||
|
LockKeyhole,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
store,
|
||||||
|
update,
|
||||||
|
} from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
import { edit } from '@/routes/git-connections/index';
|
||||||
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
||||||
|
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
||||||
|
|
||||||
defineProps<SettingsGitConnectionsPageProps>();
|
const props = defineProps<SettingsGitConnectionsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.gitConnections'), href: edit() },
|
||||||
|
]);
|
||||||
|
const mode = ref<'create' | 'edit' | 'remove'>('create');
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const selectedConnection = ref<UserGitConnectionModel | null>(null);
|
||||||
|
// Keep provider credentials out of Inertia's remembered browser history.
|
||||||
|
const connectionForm = useForm({
|
||||||
|
provider: 'gitea',
|
||||||
|
base_url: '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
const connectionProtection = useFormProtection(connectionForm);
|
||||||
|
const removalForm = useForm({});
|
||||||
|
const removalProtection = useFormProtection(removalForm);
|
||||||
|
const processing = computed(
|
||||||
|
() => connectionForm.processing || removalForm.processing,
|
||||||
|
);
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
t(
|
||||||
|
mode.value === 'create'
|
||||||
|
? 'gitConnections.add'
|
||||||
|
: mode.value === 'edit'
|
||||||
|
? 'gitConnections.edit'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const successTitle = computed(() =>
|
||||||
|
props.status === 'Git connection was added.'
|
||||||
|
? t('gitConnections.added')
|
||||||
|
: props.status === 'Git connection was updated.'
|
||||||
|
? t('gitConnections.updated')
|
||||||
|
: t('gitConnections.removed'),
|
||||||
|
);
|
||||||
|
|
||||||
const page = usePage();
|
function resetForms(): void {
|
||||||
const appName = String(page.props.name);
|
connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
|
||||||
|
connectionForm.resetAndClearErrors();
|
||||||
|
connectionProtection.clearFeedback();
|
||||||
|
removalForm.resetAndClearErrors();
|
||||||
|
removalProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
function openDialog(
|
||||||
{
|
nextMode: typeof mode.value,
|
||||||
title: 'Git connections',
|
connection: UserGitConnectionModel | null = null,
|
||||||
href: editGitConnections(),
|
): void {
|
||||||
},
|
resetForms();
|
||||||
];
|
mode.value = nextMode;
|
||||||
|
selectedConnection.value = connection;
|
||||||
|
connectionForm.defaults({
|
||||||
|
provider: connection?.provider ?? 'gitea',
|
||||||
|
base_url: connection?.base_url ?? '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
connectionForm.reset();
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(open: boolean): void {
|
||||||
|
if (open || processing.value || !connectionProtection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogOpen.value = false;
|
||||||
|
resetForms();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): void {
|
||||||
|
resetForms();
|
||||||
|
dialogOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConnection(): void {
|
||||||
|
const target =
|
||||||
|
mode.value === 'edit' && selectedConnection.value
|
||||||
|
? update(selectedConnection.value.id)
|
||||||
|
: store();
|
||||||
|
connectionProtection.submit(target, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConnection(): void {
|
||||||
|
if (!selectedConnection.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removalProtection.submit(destroy(selectedConnection.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Git connections" />
|
<Head :title="t('navigation.gitConnections')" />
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col gap-6">
|
<Card
|
||||||
<div class="space-y-2">
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
<Heading
|
>
|
||||||
variant="small"
|
<CardHeader
|
||||||
title="Git connections"
|
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
|
||||||
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access."
|
|
||||||
/>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Each user needs their own application key. Without a
|
|
||||||
matching personal connection, {{ appName }} can only
|
|
||||||
show repository names and will link you back to this
|
|
||||||
page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
|
||||||
>
|
>
|
||||||
{{ status }}
|
<div
|
||||||
</div>
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
<div class="gap-stack grid min-w-0">
|
||||||
<Card>
|
<h2 class="text-title">
|
||||||
<CardHeader>
|
{{ t('navigation.gitConnections') }}
|
||||||
<CardTitle>Add Gitea connection</CardTitle>
|
</h2>
|
||||||
<CardDescription>
|
<p class="text-muted-foreground">
|
||||||
Connect a Gitea account by storing the instance
|
{{ t('gitConnections.description') }}
|
||||||
base URL and a personal application token.
|
</p>
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<Button
|
||||||
|
type="button"
|
||||||
<CardContent>
|
class="shrink-0 self-start"
|
||||||
<Form
|
@click="openDialog('create')"
|
||||||
v-bind="GitConnectionController.store.form()"
|
>
|
||||||
class="space-y-4"
|
<Plus aria-hidden="true" />{{
|
||||||
v-slot="{ errors, processing }"
|
t('gitConnections.add')
|
||||||
>
|
}}
|
||||||
<input
|
</Button>
|
||||||
name="provider"
|
</div>
|
||||||
type="hidden"
|
<div
|
||||||
value="gitea"
|
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
|
||||||
/>
|
>
|
||||||
|
<LockKeyhole
|
||||||
<div class="grid gap-2">
|
class="text-primary mt-0.5 size-4 shrink-0"
|
||||||
<Label for="base_url">Gitea base URL</Label>
|
aria-hidden="true"
|
||||||
<Input
|
/>
|
||||||
id="base_url"
|
<p>{{ t('gitConnections.privacy') }}</p>
|
||||||
name="base_url"
|
</div>
|
||||||
type="url"
|
</CardHeader>
|
||||||
required
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
autofocus
|
<FeedbackState
|
||||||
placeholder="https://git.example.com"
|
v-if="status"
|
||||||
/>
|
state="success"
|
||||||
<InputError :message="errors.base_url" />
|
:title="successTitle"
|
||||||
</div>
|
/>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="grid gap-2">
|
<h3 class="font-semibold">
|
||||||
<Label for="api_token"
|
{{ t('gitConnections.saved') }}
|
||||||
>Application token</Label
|
</h3>
|
||||||
>
|
<Badge variant="secondary">{{
|
||||||
<Input
|
gitConnections.length
|
||||||
id="api_token"
|
}}</Badge>
|
||||||
name="api_token"
|
</div>
|
||||||
type="password"
|
<FeedbackState
|
||||||
required
|
v-if="gitConnections.length === 0"
|
||||||
placeholder="Paste your Gitea application key"
|
state="empty"
|
||||||
/>
|
:title="t('gitConnections.empty')"
|
||||||
<InputError :message="errors.api_token" />
|
:description="t('gitConnections.emptyDescription')"
|
||||||
</div>
|
/>
|
||||||
|
<ul
|
||||||
<Button type="submit" :disabled="processing">
|
v-else
|
||||||
Add connection
|
class="gap-column grid min-w-0"
|
||||||
</Button>
|
:aria-label="t('gitConnections.saved')"
|
||||||
</Form>
|
>
|
||||||
</CardContent>
|
<li
|
||||||
</Card>
|
v-for="connection in gitConnections"
|
||||||
|
:key="connection.id"
|
||||||
<Card class="gap-0 overflow-hidden">
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
<CardHeader class="border-b">
|
>
|
||||||
<CardTitle>Saved connections</CardTitle>
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
<CardDescription>
|
|
||||||
Tokens stay hidden after save. Updating a
|
|
||||||
connection requires entering a fresh token.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="gitConnections.length === 0"
|
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
||||||
>
|
|
||||||
No Git connections have been added yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="gitConnection in gitConnections"
|
|
||||||
:key="gitConnection.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex flex-wrap items-start justify-between gap-3"
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
>
|
>
|
||||||
<div class="space-y-1">
|
<GitBranch
|
||||||
<div
|
class="size-5"
|
||||||
class="flex flex-wrap items-center gap-2"
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="min-w-0 break-all font-semibold"
|
||||||
>
|
>
|
||||||
<Badge>{{
|
{{ connection.account_login }}
|
||||||
gitConnection.provider_label
|
</h4>
|
||||||
}}</Badge>
|
<Badge variant="secondary">{{
|
||||||
<span class="text-sm font-medium">
|
connection.provider_label
|
||||||
{{
|
}}</Badge>
|
||||||
gitConnection.account_login
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
gitConnection.account_name ??
|
|
||||||
'Unnamed remote account'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{{ gitConnection.base_url }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
<p class="text-muted-foreground text-xs">
|
class="text-caption text-muted-foreground break-words"
|
||||||
Remote user ID:
|
>
|
||||||
{{ gitConnection.provider_user_id }}
|
{{
|
||||||
|
connection.account_name ??
|
||||||
|
t('gitConnections.unnamedAccount')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="text-caption text-muted-foreground flex min-w-0 items-start gap-2"
|
||||||
|
>
|
||||||
|
<Server
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">{{
|
||||||
|
connection.base_url
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-all"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('gitConnections.remoteId', {
|
||||||
|
id: connection.provider_user_id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
GitConnectionController.update.form(
|
|
||||||
gitConnection.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="provider"
|
|
||||||
type="hidden"
|
|
||||||
:value="gitConnection.provider"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`base-url-${gitConnection.id}`"
|
|
||||||
>
|
|
||||||
Gitea base URL
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`base-url-${gitConnection.id}`"
|
|
||||||
name="base_url"
|
|
||||||
type="url"
|
|
||||||
:default-value="
|
|
||||||
gitConnection.base_url
|
|
||||||
"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.base_url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`token-${gitConnection.id}`"
|
|
||||||
>
|
|
||||||
New application token
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`token-${gitConnection.id}`"
|
|
||||||
name="api_token"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="Paste a new token to verify and save"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.api_token"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-between gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
GitConnectionController.destroy.form(
|
|
||||||
gitConnection.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Remove connection
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div
|
||||||
</Card>
|
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
|
||||||
</div>
|
>
|
||||||
</div>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t('gitConnections.editAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('edit', connection)"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" />{{
|
||||||
|
t('gitConnections.editAction')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="
|
||||||
|
t('gitConnections.removeAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('remove', connection)"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />{{
|
||||||
|
t('gitConnections.remove')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Dialog :open="dialogOpen" @update:open="closeDialog">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!processing"
|
||||||
|
@escape-key-down="processing && $event.preventDefault()"
|
||||||
|
@interact-outside="processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ dialogTitle }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t(
|
||||||
|
mode === 'remove'
|
||||||
|
? 'gitConnections.removeDescription'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'gitConnections.editDescription'
|
||||||
|
: 'gitConnections.addDescription',
|
||||||
|
)
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
v-if="mode !== 'remove'"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="connectionForm.processing"
|
||||||
|
@submit.prevent="saveConnection"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="connectionProtection.state.value"
|
||||||
|
:title="
|
||||||
|
connectionProtection.responseStatus.value ===
|
||||||
|
403
|
||||||
|
? t('gitConnections.forbidden')
|
||||||
|
: connectionForm.processing
|
||||||
|
? t('gitConnections.verifying')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
connectionProtection.responseStatus.value ===
|
||||||
|
403
|
||||||
|
? t('gitConnections.forbiddenDescription')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="
|
||||||
|
connectionProtection.responseStatus.value !==
|
||||||
|
403
|
||||||
|
"
|
||||||
|
@retry="saveConnection"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="connectionForm.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">{{ dialogTitle }}</legend>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="justify-self-start"
|
||||||
|
>{{ t('gitConnections.gitea') }}</Badge
|
||||||
|
>
|
||||||
|
<InputError
|
||||||
|
:message="connectionForm.errors.provider"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="git-base-url">{{
|
||||||
|
t('gitConnections.baseUrl')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="git-base-url"
|
||||||
|
v-model="connectionForm.base_url"
|
||||||
|
name="base_url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://git.example.com"
|
||||||
|
autocomplete="url"
|
||||||
|
:aria-invalid="
|
||||||
|
!!connectionForm.errors.base_url
|
||||||
|
"
|
||||||
|
aria-describedby="git-base-url-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="git-base-url-error"
|
||||||
|
:message="connectionForm.errors.base_url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="git-token">{{
|
||||||
|
t(
|
||||||
|
mode === 'edit'
|
||||||
|
? 'gitConnections.newToken'
|
||||||
|
: 'gitConnections.token',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="git-token"
|
||||||
|
v-model="connectionForm.api_token"
|
||||||
|
name="api_token"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t('gitConnections.tokenPlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!connectionForm.errors.api_token
|
||||||
|
"
|
||||||
|
aria-describedby="git-token-help git-token-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="git-token-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('gitConnections.tokenHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="git-token-error"
|
||||||
|
:message="connectionForm.errors.api_token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="closeDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit">{{
|
||||||
|
t(
|
||||||
|
connectionForm.processing
|
||||||
|
? 'gitConnections.verifying'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'gitConnections.save'
|
||||||
|
: 'gitConnections.add',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="removalForm.processing"
|
||||||
|
@submit.prevent="removeConnection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-destructive/8 border-destructive/25 text-body grid min-w-0 gap-2 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="break-all font-semibold">
|
||||||
|
{{ selectedConnection?.account_login }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground break-all">
|
||||||
|
{{ selectedConnection?.base_url }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
:state="removalProtection.state.value"
|
||||||
|
:title="
|
||||||
|
removalProtection.responseStatus.value === 403
|
||||||
|
? t('gitConnections.forbidden')
|
||||||
|
: removalForm.processing
|
||||||
|
? t('gitConnections.removing')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
removalProtection.responseStatus.value === 403
|
||||||
|
? t('gitConnections.forbiddenDescription')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="
|
||||||
|
removalProtection.responseStatus.value !== 403
|
||||||
|
"
|
||||||
|
@retry="removeConnection"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="removalForm.processing"
|
||||||
|
@click="closeDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="removalForm.processing"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
removalForm.processing
|
||||||
|
? 'gitConnections.removing'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,222 +1,340 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
import { ShieldCheck } from 'lucide-vue-next';
|
||||||
import { onUnmounted, ref } from 'vue';
|
import { computed, onUnmounted, ref } from 'vue';
|
||||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
||||||
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/security/index';
|
import { edit } from '@/routes/security/index';
|
||||||
import { disable, enable } from '@/routes/two-factor';
|
import { disable, enable } from '@/routes/two-factor';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
const props = withDefaults(
|
||||||
canManageTwoFactor?: boolean;
|
defineProps<{
|
||||||
mustChangePassword?: boolean;
|
canManageTwoFactor?: boolean;
|
||||||
requiresConfirmation?: boolean;
|
requiresConfirmation?: boolean;
|
||||||
status?: string | null;
|
twoFactorEnabled?: boolean;
|
||||||
twoFactorEnabled?: boolean;
|
}>(),
|
||||||
};
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
canManageTwoFactor: false,
|
|
||||||
mustChangePassword: false,
|
|
||||||
requiresConfirmation: false,
|
|
||||||
status: null,
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
|
||||||
{
|
{
|
||||||
title: 'Security settings',
|
canManageTwoFactor: false,
|
||||||
href: edit(),
|
requiresConfirmation: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.security'), href: edit() },
|
||||||
|
]);
|
||||||
|
const passwordForm = useForm({
|
||||||
|
current_password: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
}).dontRemember('current_password', 'password', 'password_confirmation');
|
||||||
|
const passwordProtection = useFormProtection(passwordForm, {
|
||||||
|
isDirty: () => false,
|
||||||
|
});
|
||||||
|
const twoFactorForm = useForm({});
|
||||||
|
const twoFactorProtection = useFormProtection(twoFactorForm);
|
||||||
|
const showSetupModal = ref(false);
|
||||||
|
const showDisableDialog = ref(false);
|
||||||
|
const setupStarted = ref(false);
|
||||||
|
|
||||||
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
|
function savePassword(): void {
|
||||||
const showSetupModal = ref<boolean>(false);
|
passwordProtection.submit(update(), {
|
||||||
|
preserveScroll: true,
|
||||||
onUnmounted(() => clearTwoFactorAuthData());
|
onFinish: () => passwordForm.reset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startSetup(): void {
|
||||||
|
if (setupStarted.value) {
|
||||||
|
showSetupModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
twoFactorProtection.submit(enable(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
setupStarted.value = true;
|
||||||
|
showSetupModal.value = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function disableTwoFactor(): void {
|
||||||
|
twoFactorProtection.submit(disable(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
showDisableDialog.value = false;
|
||||||
|
setupStarted.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateDisableDialog(open: boolean): void {
|
||||||
|
if (!twoFactorForm.processing) {
|
||||||
|
showDisableDialog.value = open;
|
||||||
|
twoFactorProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUnmounted(() => passwordForm.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Security settings" />
|
<Head :title="t('security.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Security settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
v-if="mustChangePassword || status"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
<h2 class="text-title">
|
||||||
>
|
{{ t('security.passwordTitle') }}
|
||||||
{{
|
</h2>
|
||||||
mustChangePassword
|
<p class="text-muted-foreground">
|
||||||
? 'You must change your password before continuing anywhere else in the app.'
|
{{ t('security.passwordDescription') }}
|
||||||
: status
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<Heading
|
|
||||||
variant="small"
|
|
||||||
title="Update password"
|
|
||||||
description="Ensure your account is using a long, random password to stay secure"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="SecurityController.update.form()"
|
|
||||||
:options="{
|
|
||||||
preserveScroll: true,
|
|
||||||
}"
|
|
||||||
reset-on-success
|
|
||||||
:reset-on-error="[
|
|
||||||
'password',
|
|
||||||
'password_confirmation',
|
|
||||||
'current_password',
|
|
||||||
]"
|
|
||||||
class="space-y-6"
|
|
||||||
v-slot="{ errors, processing, recentlySuccessful }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="current_password">Current password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="current_password"
|
|
||||||
name="current_password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
autocomplete="current-password"
|
|
||||||
placeholder="Current password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.current_password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password">New password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="New password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password_confirmation"
|
|
||||||
>Confirm password</Label
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password_confirmation" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="update-password-button"
|
|
||||||
>
|
|
||||||
Save password
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition ease-in-out"
|
|
||||||
enter-from-class="opacity-0"
|
|
||||||
leave-active-class="transition ease-in-out"
|
|
||||||
leave-to-class="opacity-0"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-show="recentlySuccessful"
|
|
||||||
class="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Saved.
|
|
||||||
</p>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="canManageTwoFactor" class="space-y-6">
|
|
||||||
<Heading
|
|
||||||
variant="small"
|
|
||||||
title="Two-factor authentication"
|
|
||||||
description="Manage your two-factor authentication settings"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!twoFactorEnabled"
|
|
||||||
class="flex flex-col items-start justify-start space-y-4"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
When you enable two-factor authentication, you will be
|
|
||||||
prompted for a secure pin during login. This pin can be
|
|
||||||
retrieved from a TOTP-supported application on your
|
|
||||||
phone.
|
|
||||||
</p>
|
</p>
|
||||||
|
</CardHeader>
|
||||||
<div>
|
<CardContent class="p-5 sm:p-6">
|
||||||
<Button
|
<form
|
||||||
v-if="hasSetupData"
|
class="gap-column grid"
|
||||||
@click="showSetupModal = true"
|
:aria-busy="passwordForm.processing"
|
||||||
|
@submit.prevent="savePassword"
|
||||||
|
>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="passwordProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
passwordProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
:loading-title="t('security.savingPassword')"
|
||||||
|
:success-title="t('security.passwordSaved')"
|
||||||
|
@retry="savePassword"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
>
|
>
|
||||||
<ShieldCheck />Continue setup
|
<legend class="sr-only">
|
||||||
</Button>
|
{{ t('security.passwordTitle') }}
|
||||||
<Form
|
</legend>
|
||||||
v-else
|
<div class="grid gap-2">
|
||||||
v-bind="enable.form()"
|
<Label for="current_password">{{
|
||||||
@success="showSetupModal = true"
|
t('security.currentPassword')
|
||||||
#default="{ processing }"
|
}}</Label>
|
||||||
>
|
<PasswordInput
|
||||||
<Button type="submit" :disabled="processing">
|
id="current_password"
|
||||||
Enable 2FA
|
v-model="passwordForm.current_password"
|
||||||
</Button>
|
name="current_password"
|
||||||
</Form>
|
required
|
||||||
</div>
|
autocomplete="current-password"
|
||||||
</div>
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
<div
|
!!passwordForm.errors.current_password
|
||||||
v-else
|
"
|
||||||
class="flex flex-col items-start justify-start space-y-4"
|
aria-describedby="current-password-error"
|
||||||
>
|
/>
|
||||||
<p class="text-muted-foreground text-sm">
|
<InputError
|
||||||
You will be prompted for a secure, random pin during
|
id="current-password-error"
|
||||||
login, which you can retrieve from the TOTP-supported
|
:message="
|
||||||
application on your phone.
|
passwordForm.errors.current_password
|
||||||
</p>
|
"
|
||||||
|
/>
|
||||||
<div class="relative inline">
|
</div>
|
||||||
<Form v-bind="disable.form()" #default="{ processing }">
|
<div class="grid gap-2">
|
||||||
|
<Label for="password">{{
|
||||||
|
t('security.newPassword')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
v-model="passwordForm.password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors.password
|
||||||
|
"
|
||||||
|
aria-describedby="new-password-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="new-password-error"
|
||||||
|
:message="passwordForm.errors.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="password_confirmation">{{
|
||||||
|
t('security.confirmPassword')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password_confirmation"
|
||||||
|
v-model="passwordForm.password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors
|
||||||
|
.password_confirmation
|
||||||
|
"
|
||||||
|
aria-describedby="password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="password-confirmation-error"
|
||||||
|
:message="
|
||||||
|
passwordForm.errors
|
||||||
|
.password_confirmation
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="processing"
|
class="justify-self-start"
|
||||||
|
:loading="passwordForm.processing"
|
||||||
|
:loading-label="t('security.savingPassword')"
|
||||||
|
data-test="update-password-button"
|
||||||
|
>{{ t('security.savePassword') }}</Button
|
||||||
>
|
>
|
||||||
Disable 2FA
|
</fieldset>
|
||||||
</Button>
|
</form>
|
||||||
</Form>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-if="canManageTwoFactor"
|
||||||
|
class="gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
|
>
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<h2 class="text-title flex items-center gap-2">
|
||||||
|
<ShieldCheck class="text-primary size-5" />{{
|
||||||
|
t('security.twoFactorTitle')
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
class="bg-muted rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
role="status"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.enabled'
|
||||||
|
: setupStarted
|
||||||
|
? 'security.pending'
|
||||||
|
: 'security.disabled',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<TwoFactorRecoveryCodes />
|
{{
|
||||||
</div>
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
<TwoFactorSetupModal
|
? 'security.enabledDescription'
|
||||||
v-model:isOpen="showSetupModal"
|
: 'security.twoFactorDescription',
|
||||||
:requiresConfirmation="requiresConfirmation"
|
)
|
||||||
:twoFactorEnabled="twoFactorEnabled"
|
}}
|
||||||
/>
|
</p>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||||
|
<AuthFormFeedback
|
||||||
|
v-if="!showDisableDialog"
|
||||||
|
:state="twoFactorProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
twoFactorProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
@retry="
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? (showDisableDialog = true)
|
||||||
|
: startSetup()
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!twoFactorEnabled"
|
||||||
|
class="justify-self-start"
|
||||||
|
:loading="twoFactorForm.processing"
|
||||||
|
@click="startSetup"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
setupStarted
|
||||||
|
? 'security.continueSetup'
|
||||||
|
: 'security.enable',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<template v-else>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="text-destructive justify-self-start"
|
||||||
|
@click="showDisableDialog = true"
|
||||||
|
>{{ t('security.disable') }}</Button
|
||||||
|
>
|
||||||
|
<TwoFactorRecoveryCodes />
|
||||||
|
</template>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<TwoFactorSetupModal
|
||||||
|
v-if="canManageTwoFactor"
|
||||||
|
v-model:is-open="showSetupModal"
|
||||||
|
:requires-confirmation="requiresConfirmation"
|
||||||
|
:two-factor-enabled="twoFactorEnabled"
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
:open="showDisableDialog"
|
||||||
|
@update:open="updateDisableDialog"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!twoFactorForm.processing"
|
||||||
|
@escape-key-down="
|
||||||
|
twoFactorForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
@interact-outside="
|
||||||
|
twoFactorForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('security.disableTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('security.disableDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="twoFactorProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
twoFactorProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
@retry="disableTwoFactor"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="twoFactorForm.processing"
|
||||||
|
@click="updateDisableDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
:loading="twoFactorForm.processing"
|
||||||
|
@click="disableTwoFactor"
|
||||||
|
>{{ t('security.disable') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
|
|||||||
'base_url' => 'https://git.example.test',
|
'base_url' => 'https://git.example.test',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
UserGitConnection::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('git-connections.edit'))
|
->get(route('git-connections.edit'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/GitConnections')
|
->component('settings/GitConnections')
|
||||||
|
->has('gitConnections', 1)
|
||||||
|
->missing('gitConnections.0.api_token')
|
||||||
->where('gitConnections.0.account_login', 'nico')
|
->where('gitConnections.0.account_login', 'nico')
|
||||||
->where('gitConnections.0.base_url', 'https://git.example.test')
|
->where('gitConnections.0.base_url', 'https://git.example.test')
|
||||||
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
|
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
|
||||||
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
|
|||||||
'api_token' => 'wrong-token',
|
'api_token' => 'wrong-token',
|
||||||
])
|
])
|
||||||
->assertRedirect(route('git-connections.edit'))
|
->assertRedirect(route('git-connections.edit'))
|
||||||
->assertSessionHasErrors('api_token');
|
->assertSessionHasErrors('api_token')
|
||||||
|
->assertSessionMissing('_old_input.api_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase
|
|||||||
'id' => $gitConnection->id,
|
'id' => $gitConnection->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_empty_personal_connections_are_displayed(): void
|
||||||
|
{
|
||||||
|
UserGitConnection::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->get(route('git-connections.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/GitConnections')
|
||||||
|
->where('gitConnections', []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guests_cannot_access_git_connections(): void
|
||||||
|
{
|
||||||
|
$this->get(route('git-connections.edit'))->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_cannot_delete_foreign_connections(): void
|
||||||
|
{
|
||||||
|
$connection = UserGitConnection::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->delete(route('git-connections.destroy', $connection))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertModelExists($connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$connection = UserGitConnection::factory()->for($user)->create([
|
||||||
|
'base_url' => 'https://git.original.test',
|
||||||
|
'api_token' => 'original-secret',
|
||||||
|
]);
|
||||||
|
$original = $connection->fresh()->getAttributes();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('git-connections.edit'))
|
||||||
|
->patch(route('git-connections.update', $connection), [
|
||||||
|
'provider' => 'gitea',
|
||||||
|
'base_url' => 'https://git.changed.test',
|
||||||
|
'api_token' => 'replacement-secret',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('git-connections.edit'))
|
||||||
|
->assertSessionHasErrors('api_token')
|
||||||
|
->assertSessionMissing('_old_input.api_token');
|
||||||
|
|
||||||
|
$this->assertSame($original, $connection->fresh()->getAttributes());
|
||||||
|
$this->assertSame('original-secret', $connection->fresh()->api_token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GitConnectionsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_personal_git_connections_rendering_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/git-connections.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
|
|||||||
|
|
||||||
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_two_factor_setup_requires_a_valid_code_before_protection_is_enabled(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()])
|
||||||
|
->from(route('security.edit'))
|
||||||
|
->post(route('two-factor.enable'))->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertNotNull($user->refresh()->two_factor_secret);
|
||||||
|
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||||
|
$this->getJson(route('two-factor.secret-key'))->assertOk()->assertJsonStructure(['secretKey']);
|
||||||
|
$this->getJson(route('two-factor.qr-code'))->assertOk()->assertJsonStructure(['svg']);
|
||||||
|
|
||||||
|
$this->mock(TwoFactorAuthenticationProvider::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '000000')->once()->andReturn(false);
|
||||||
|
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '123456')->once()->andReturn(true);
|
||||||
|
});
|
||||||
|
$this->post(route('two-factor.confirm'), ['code' => '000000'])
|
||||||
|
->assertSessionHasErrors('code', errorBag: 'confirmTwoFactorAuthentication');
|
||||||
|
$this->assertFalse($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
$this->post(route('two-factor.confirm'), ['code' => '123456'])->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_recovery_codes_can_be_retrieved_regenerated_and_removed_with_two_factor(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->withTwoFactor()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
||||||
|
$oldCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||||
|
$this->assertSame($user->recoveryCodes(), $oldCodes);
|
||||||
|
$this->post(route('two-factor.regenerate-recovery-codes'))->assertSessionHasNoErrors();
|
||||||
|
$newCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||||
|
$this->assertCount(8, $newCodes);
|
||||||
|
$this->assertEmpty(array_intersect($oldCodes, $newCodes));
|
||||||
|
$this->delete(route('two-factor.disable'))->assertSessionHasNoErrors();
|
||||||
|
$this->assertNull($user->refresh()->two_factor_secret);
|
||||||
|
$this->assertNull($user->two_factor_recovery_codes);
|
||||||
|
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sensitive_two_factor_endpoints_reject_expired_password_confirmation(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->withTwoFactor()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
foreach (['two-factor.qr-code', 'two-factor.secret-key', 'two-factor.recovery-codes'] as $route) {
|
||||||
|
$this->getJson(route($route))->assertStatus(423);
|
||||||
|
}
|
||||||
|
$this->deleteJson(route('two-factor.disable'))->assertStatus(423);
|
||||||
|
$this->postJson(route('two-factor.regenerate-recovery-codes'))->assertStatus(423);
|
||||||
|
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SecurityUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_security_and_application_key_rendering(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/security.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Security UI rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ class UserApplicationTest extends TestCase
|
|||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/Applications')
|
->component('settings/Applications')
|
||||||
->where('applications.0.name', 'Claude Desktop')
|
->where('applications.0.name', 'Claude Desktop')
|
||||||
->where('applicationKey', null),
|
->where('applicationKey', null)
|
||||||
|
->missing('applications.0.key_hash')
|
||||||
|
->missing('applications.0.plain_text_key'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,4 +102,26 @@ class UserApplicationTest extends TestCase
|
|||||||
'id' => $application->id,
|
'id' => $application->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_application_list_only_contains_the_current_users_keys(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
UserApplication::factory()->for($user)->create(['name' => 'Own tool']);
|
||||||
|
UserApplication::factory()->create(['name' => 'Foreign tool']);
|
||||||
|
$this->actingAs($user)->get(route('applications.edit'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('applications', 1)
|
||||||
|
->where('applications.0.name', 'Own tool')
|
||||||
|
->where('applicationKey', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_application_name_does_not_issue_a_key(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->from(route('applications.edit'))
|
||||||
|
->post(route('applications.store'), ['name' => ''])
|
||||||
|
->assertSessionHasErrors('name')
|
||||||
|
->assertSessionMissing('applicationKey');
|
||||||
|
$this->assertDatabaseCount('user_applications', 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/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),
|
||||||
|
});
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-general-settings-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Project general settings fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/settings/General.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Local override/);
|
||||||
|
assert.match(html, /Inherited/);
|
||||||
|
assert.match(html, /Team baseline/);
|
||||||
|
assert.match(html, /Show author/);
|
||||||
|
assert.match(html, /href="\/projects\/1\/settings\/metadata"/);
|
||||||
|
assert.match(html, /aria-current="page"/);
|
||||||
|
assert.match(html, /Reset Project settings to template/);
|
||||||
|
assert.match(html, /Type CORE exactly/);
|
||||||
|
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||||
|
for (const label of ['Switch template', 'Delete project']) {
|
||||||
|
const button = buttons.find((item) => item.includes(label));
|
||||||
|
assert.match(button, /disabled/);
|
||||||
|
assert.match(button, /type="submit"/);
|
||||||
|
}
|
||||||
|
const standalone = await render({
|
||||||
|
templateState: null,
|
||||||
|
project: { ...initial().project, project_template: null },
|
||||||
|
});
|
||||||
|
assert.match(standalone, /No linked template/);
|
||||||
|
assert.doesNotMatch(standalone, /Reset Project settings to template/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await render({
|
||||||
|
templateState: {
|
||||||
|
...initial().templateState,
|
||||||
|
inheritsFromTemplate: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Local override/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render({ sourceTemplates: [template] }),
|
||||||
|
/No other published templates/,
|
||||||
|
);
|
||||||
|
const inherited = await render({
|
||||||
|
templateState: {
|
||||||
|
...initial().templateState,
|
||||||
|
inheritedAreas: [area('project_settings', 'Project settings')],
|
||||||
|
overriddenAreas: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(
|
||||||
|
inherited,
|
||||||
|
/Local override|Reset Project settings to template/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'projectSettings.danger': 'Gefahrenbereich',
|
||||||
|
'inheritance.area.projectSettings':
|
||||||
|
'Projekteinstellungen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Gefahrenbereich/,
|
||||||
|
);
|
||||||
|
console.log('Project general settings rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4185';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'security_test_result', value, url: base }]);
|
||||||
|
const enabled = (value) =>
|
||||||
|
context.addCookies([
|
||||||
|
{ name: 'security_enabled', value: String(value), url: base },
|
||||||
|
]);
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
let recoveryFetches = 0;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (
|
||||||
|
request.url().endsWith('/user/two-factor-recovery-codes') &&
|
||||||
|
request.method() === 'GET'
|
||||||
|
) {
|
||||||
|
recoveryFetches++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
for (const path of ['security', 'applications']) {
|
||||||
|
await page.goto(`${base}/settings/${path}`);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', {
|
||||||
|
name:
|
||||||
|
path === 'security'
|
||||||
|
? 'Update password'
|
||||||
|
: 'Applications',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`${path} overflow at ${width}/${theme}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((el) => el.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
(await page.locator('section').first().boundingBox())
|
||||||
|
.width <= width,
|
||||||
|
`${path} content is clipped at ${width}/${theme}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/settings/security`);
|
||||||
|
await button('Enable 2FA').click();
|
||||||
|
await page.getByLabel('Manual setup key').waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Manual setup key').inputValue(),
|
||||||
|
'MANUALSETUPTESTKEY',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate(
|
||||||
|
(el) => el.scrollWidth <= el.clientWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await button('Continue').click();
|
||||||
|
const code = page.getByLabel('Authentication code', {
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await code.waitFor();
|
||||||
|
assert.equal(await code.getAttribute('inputmode'), 'numeric');
|
||||||
|
assert.equal(
|
||||||
|
await code.getAttribute('autocomplete'),
|
||||||
|
'one-time-code',
|
||||||
|
);
|
||||||
|
await code.focus();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await button('Back').evaluate(
|
||||||
|
(el) => el === document.activeElement,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(await page.getByLabel('Manual setup key').count(), 0);
|
||||||
|
assert.match(
|
||||||
|
await page.locator('body').innerText(),
|
||||||
|
/Awaiting confirmation/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await button('Continue setup').click();
|
||||||
|
await button('Continue').click();
|
||||||
|
await result('validation');
|
||||||
|
await page
|
||||||
|
.getByLabel('Authentication code', { exact: true })
|
||||||
|
.fill('000000');
|
||||||
|
await button('Confirm').click();
|
||||||
|
await page
|
||||||
|
.getByText('The provided two factor authentication code was invalid.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Authentication code', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Authentication code', { exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await result('success');
|
||||||
|
await page
|
||||||
|
.getByLabel('Authentication code', { exact: true })
|
||||||
|
.fill('123456');
|
||||||
|
await button('Confirm').click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await button('Disable 2FA').waitFor();
|
||||||
|
assert.equal(
|
||||||
|
recoveryFetches,
|
||||||
|
0,
|
||||||
|
'Recovery codes are fetched only on request',
|
||||||
|
);
|
||||||
|
await button('View recovery codes').click();
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||||
|
await button('Hide recovery codes').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await button('View recovery codes').click();
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||||
|
assert.equal(recoveryFetches, 2);
|
||||||
|
await button('Regenerate codes').click();
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await button('Regenerate codes').click();
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Regenerate codes', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||||
|
assert.equal(recoveryFetches, 3);
|
||||||
|
await button('Hide recovery codes').click();
|
||||||
|
await page.route('**/user/two-factor-recovery-codes', async (route) => {
|
||||||
|
if (route.request().method() === 'GET') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
await route.fulfill({ json: ['late-secret-code'] });
|
||||||
|
} else {
|
||||||
|
await route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await button('View recovery codes').click();
|
||||||
|
await button('Hide recovery codes').click();
|
||||||
|
await page.waitForTimeout(450);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByText('late-secret-code', { exact: true }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.unroute('**/user/two-factor-recovery-codes');
|
||||||
|
await page.route('**/user/two-factor-recovery-codes', (route) =>
|
||||||
|
route.fulfill({ status: 423, json: {} }),
|
||||||
|
);
|
||||||
|
await button('View recovery codes').click();
|
||||||
|
await page
|
||||||
|
.getByText(
|
||||||
|
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
|
||||||
|
{ exact: true },
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
await page.unroute('**/user/two-factor-recovery-codes');
|
||||||
|
await button('Try again').click();
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).waitFor();
|
||||||
|
await button('Disable 2FA').click();
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await button('Disable 2FA').click();
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Disable 2FA', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await button('Enable 2FA').waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByText('old-code-0001', { exact: true }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.route('**/user/two-factor-secret-key', (route) => route.abort());
|
||||||
|
await button('Enable 2FA').click();
|
||||||
|
await page
|
||||||
|
.getByText(
|
||||||
|
'Authenticator setup could not be loaded. Please try again.',
|
||||||
|
{ exact: true },
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await button('Continue').count(), 0);
|
||||||
|
await page.unroute('**/user/two-factor-secret-key');
|
||||||
|
await button('Try again').click();
|
||||||
|
await page.getByLabel('Manual setup key').waitFor();
|
||||||
|
await page.locator('[data-slot="dialog-close"]').click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await page.getByLabel('Current password', { exact: true }).fill('wrong');
|
||||||
|
await page.getByLabel('New password', { exact: true }).fill('new-password');
|
||||||
|
await page
|
||||||
|
.getByLabel('Confirm password', { exact: true })
|
||||||
|
.fill('new-password');
|
||||||
|
await result('validation');
|
||||||
|
await button('Save password').click();
|
||||||
|
await page
|
||||||
|
.getByText('The password is incorrect.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Current password', { exact: true }).inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('New password', { exact: true }).inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Current password', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await page.evaluate(() => JSON.stringify(history.state)),
|
||||||
|
/MANUALSETUPTESTKEY|123456|new-password/,
|
||||||
|
);
|
||||||
|
|
||||||
|
await result('success');
|
||||||
|
await page.goto(`${base}/settings/applications`);
|
||||||
|
assert.equal(await button('Copy key').count(), 0);
|
||||||
|
await page.getByLabel('Application name').fill('My tool');
|
||||||
|
await page.route('**/settings/applications', (route) =>
|
||||||
|
route.request().method() === 'POST' ? route.abort() : route.continue(),
|
||||||
|
);
|
||||||
|
await button('Create application').click();
|
||||||
|
await page.locator('[data-form-state="network"]').waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Application name').inputValue(),
|
||||||
|
'My tool',
|
||||||
|
);
|
||||||
|
await page.unroute('**/settings/applications');
|
||||||
|
await button('Try again').click();
|
||||||
|
await page.getByLabel('API key', { exact: true }).waitFor();
|
||||||
|
assert.ok(
|
||||||
|
(await page.locator('section').first().boundingBox()).width <= 390,
|
||||||
|
'One-time key content is clipped on mobile',
|
||||||
|
);
|
||||||
|
await button('Copy key').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => navigator.clipboard.readText()),
|
||||||
|
'kg_app_one_time_test_key',
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await button('Copy key').count(), 0);
|
||||||
|
assert.equal(await page.getByLabel('API key', { exact: true }).count(), 0);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
|
||||||
|
.click();
|
||||||
|
await result('expired');
|
||||||
|
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||||
|
await dialog()
|
||||||
|
.getByText('Your session has expired', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('button[aria-label="Delete Desktop tool"]').count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await result('success');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByText('No applications have been created yet.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Security browser behavior checks passed: 2FA setup, confirmation, cancellation, recovery hiding and regeneration, late responses, errors, password reset, one-time key copy, deletion, keyboard, mobile and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Security from '../../resources/js/pages/settings/Security.vue';
|
||||||
|
import Applications from '../../resources/js/pages/settings/Applications.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) => (name === 'Security' ? Security : Applications),
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/security-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const application = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Desktop tool',
|
||||||
|
created_at: '2026-09-14T00:00:00Z',
|
||||||
|
last_used_at: null,
|
||||||
|
};
|
||||||
|
const key = 'kg_app_one_time_test_key';
|
||||||
|
function makePage(url, extra = {}) {
|
||||||
|
return {
|
||||||
|
component: url.startsWith('/settings/security')
|
||||||
|
? 'Security'
|
||||||
|
: 'Applications',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
canManageTwoFactor: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
applications: [application],
|
||||||
|
applicationKey: null,
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
const path = new URL(request.url, 'http://localhost').pathname;
|
||||||
|
if (!path.startsWith('/settings') && !path.startsWith('/user/')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(/security_test_result=(\w+)/)?.[1] ??
|
||||||
|
'success';
|
||||||
|
const mutation = request.method !== 'GET';
|
||||||
|
if (mutation && ['server', 'expired'].includes(result)) {
|
||||||
|
response.statusCode = result === 'expired' ? 419 : 503;
|
||||||
|
response.end('Simulated failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
if (mutation) {
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const submitted = body ? JSON.parse(body) : {};
|
||||||
|
const cookies = request.headers.cookie ?? '';
|
||||||
|
let enabled = cookies.includes('security_enabled=true');
|
||||||
|
let extra = { twoFactorEnabled: enabled };
|
||||||
|
let url = path;
|
||||||
|
if (path.startsWith('/user/two-factor-') && !mutation) {
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(
|
||||||
|
JSON.stringify(
|
||||||
|
path.endsWith('qr-code')
|
||||||
|
? {
|
||||||
|
svg: '<svg viewBox="0 0 100 100"><rect width="100" height="100" fill="black"/></svg>',
|
||||||
|
}
|
||||||
|
: path.endsWith('secret-key')
|
||||||
|
? { secretKey: 'MANUALSETUPTESTKEY' }
|
||||||
|
: ['old-code-0001', 'old-code-0002'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mutation) {
|
||||||
|
url = path.startsWith('/settings/applications')
|
||||||
|
? '/settings/applications'
|
||||||
|
: '/settings/security';
|
||||||
|
if (result === 'validation') {
|
||||||
|
extra.errors = path.includes('confirmed-two-factor')
|
||||||
|
? {
|
||||||
|
confirmTwoFactorAuthentication: {
|
||||||
|
code: 'The provided two factor authentication code was invalid.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: path.includes('applications')
|
||||||
|
? { name: 'The name field is required.' }
|
||||||
|
: { current_password: 'The password is incorrect.' };
|
||||||
|
} else if (path.includes('confirmed-two-factor')) {
|
||||||
|
enabled = true;
|
||||||
|
response.setHeader(
|
||||||
|
'Set-Cookie',
|
||||||
|
'security_enabled=true; Path=/',
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
path === '/user/two-factor-authentication' &&
|
||||||
|
request.method === 'DELETE'
|
||||||
|
) {
|
||||||
|
enabled = false;
|
||||||
|
response.setHeader(
|
||||||
|
'Set-Cookie',
|
||||||
|
'security_enabled=false; Path=/',
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
path === '/settings/applications' &&
|
||||||
|
request.method === 'POST'
|
||||||
|
) {
|
||||||
|
extra.applicationKey = key;
|
||||||
|
extra.applications = [{ ...application, name: submitted.name }];
|
||||||
|
} else if (
|
||||||
|
path.startsWith('/settings/applications/') &&
|
||||||
|
request.method === 'DELETE'
|
||||||
|
) {
|
||||||
|
extra.applications = [];
|
||||||
|
}
|
||||||
|
extra.twoFactorEnabled = enabled;
|
||||||
|
}
|
||||||
|
const page = makePage(url, extra);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/security-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Security fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const Security = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/Security.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const Applications = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/Applications.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (url, component, extra = {}) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(url, extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const security = await render('/settings/security', Security);
|
||||||
|
assert.match(security, /autocomplete="current-password"/);
|
||||||
|
assert.match(security, /Enable 2FA/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await render('/settings/security', Security, {
|
||||||
|
canManageTwoFactor: false,
|
||||||
|
}),
|
||||||
|
/Enable 2FA/,
|
||||||
|
);
|
||||||
|
const enabled = await render('/settings/security', Security, {
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
});
|
||||||
|
assert.match(enabled, /View recovery codes/);
|
||||||
|
assert.doesNotMatch(enabled, /old-code/);
|
||||||
|
const hidden = await render('/settings/applications', Applications);
|
||||||
|
assert.doesNotMatch(hidden, /id="application-key"/);
|
||||||
|
assert.doesNotMatch(hidden, /Copy key/);
|
||||||
|
const shown = await render('/settings/applications', Applications, {
|
||||||
|
applicationKey: key,
|
||||||
|
});
|
||||||
|
assert.match(shown, /id="application-key"/);
|
||||||
|
assert.match(shown, /Copy key/);
|
||||||
|
assert.match(shown, /kg_app_one_time_test_key/);
|
||||||
|
console.log('Security UI rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user