Files
KanbanGit/resources/js/pages/projects/settings/Repositories.vue
T

1153 lines
52 KiB
Vue

<script setup lang="ts">
import { Head, Link, router, useForm } from '@inertiajs/vue3';
import {
ExternalLink,
GitBranch,
Link2,
Pencil,
Plus,
RefreshCw,
Search,
Server,
Trash2,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import {
destroy,
store,
update,
} from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index, show } from '@/routes/projects/index';
import { repositories } from '@/routes/projects/settings';
import type {
BreadcrumbItem,
ProjectRepositorySettingsPageProps,
} from '@/types';
import type { AvailableGitRepositorySourceModel } from '@/types/models/available-git-repository-source-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
type DialogMode = 'link' | 'edit' | 'remove';
const props = defineProps<ProjectRepositorySettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{
title: t('navigation.repositories'),
href: repositories(props.project.id),
},
]);
const dialogOpen = ref(false);
const dialogMode = ref<DialogMode>('link');
const selectedConnection = ref<RepositoryConnectionModel | null>(null);
const repositorySearch = ref('');
const refreshingSources = ref(false);
const repositoryForm = useForm<{
provider_repository_id: string;
user_git_connection_id: string;
}>({
provider_repository_id: '',
user_git_connection_id: '',
});
const removalForm = useForm({});
const repositoryProtection = useFormProtection(repositoryForm);
const removalProtection = useFormProtection(removalForm);
const processing = computed(
() => repositoryForm.processing || removalForm.processing,
);
const successfulSourceCount = computed(
() =>
props.availableRepositorySources.filter((source) => !source.error)
.length,
);
const selectedSource = computed(() =>
props.availableRepositorySources.find(
(source) =>
source.user_git_connection_id ===
Number(repositoryForm.user_git_connection_id),
),
);
const filteredRepositories = computed(() => {
const search = repositorySearch.value.trim().toLocaleLowerCase();
const sourceRepositories = selectedSource.value?.repositories ?? [];
if (search === '') {
return sourceRepositories;
}
return sourceRepositories.filter((repository) =>
[
repository.full_name,
repository.owner_name,
repository.repository_name,
]
.join(' ')
.toLocaleLowerCase()
.includes(search),
);
});
const canSaveRepository = computed(
() =>
selectedSource.value !== undefined &&
selectedSource.value.error === null &&
selectedSource.value.repositories.some(
(repository) =>
repository.provider_repository_id ===
repositoryForm.provider_repository_id,
),
);
const matchingPersonalSourceMissing = computed(
() =>
dialogMode.value === 'edit' &&
selectedConnection.value !== null &&
!props.availableRepositorySources.some(
(source) =>
source.provider === selectedConnection.value?.provider &&
source.base_url === selectedConnection.value?.base_url,
),
);
const dialogTitle = computed(() =>
t(
dialogMode.value === 'link'
? 'repositorySettings.linkTitle'
: dialogMode.value === 'edit'
? 'repositorySettings.editTitle'
: 'repositorySettings.removeTitle',
),
);
const successTitle = computed(() => {
if (props.status?.endsWith('connection was updated.')) {
return t('repositorySettings.updated');
}
if (props.status?.endsWith('was removed from the project.')) {
return t('repositorySettings.removed');
}
return t('repositorySettings.linked');
});
function sourceForConnection(
repositoryConnection: RepositoryConnectionModel | null,
): AvailableGitRepositorySourceModel | undefined {
if (repositoryConnection) {
const matchingSource = props.availableRepositorySources.find(
(source) =>
source.provider === repositoryConnection.provider &&
source.base_url === repositoryConnection.base_url,
);
if (matchingSource) {
return matchingSource;
}
}
return (
props.availableRepositorySources.find(
(source) => source.error === null && source.repositories.length > 0,
) ??
props.availableRepositorySources.find(
(source) => source.error === null,
) ??
props.availableRepositorySources[0]
);
}
function resetForms(): void {
repositoryForm.defaults({
provider_repository_id: '',
user_git_connection_id: '',
});
repositoryForm.resetAndClearErrors();
repositoryProtection.clearFeedback();
removalForm.resetAndClearErrors();
removalProtection.clearFeedback();
repositorySearch.value = '';
}
function openDialog(
mode: DialogMode,
repositoryConnection: RepositoryConnectionModel | null = null,
): void {
resetForms();
dialogMode.value = mode;
selectedConnection.value = repositoryConnection;
if (mode !== 'remove') {
const source = sourceForConnection(repositoryConnection);
const currentRepositoryId =
repositoryConnection?.provider_repository_id;
const providerRepositoryId = source?.repositories.some(
(repository) =>
repository.provider_repository_id === currentRepositoryId,
)
? (currentRepositoryId ?? '')
: '';
repositoryForm.defaults({
provider_repository_id: providerRepositoryId,
user_git_connection_id: String(
source?.user_git_connection_id ?? '',
),
});
repositoryForm.reset();
}
dialogOpen.value = true;
}
function closeDialog(open: boolean): void {
if (
open ||
processing.value ||
!repositoryProtection.confirmLeave() ||
!removalProtection.confirmLeave()
) {
return;
}
dialogOpen.value = false;
resetForms();
}
function finishMutation(): void {
dialogOpen.value = false;
selectedConnection.value = null;
resetForms();
}
function selectSource(source: AvailableGitRepositorySourceModel): void {
repositoryForm.user_git_connection_id = String(
source.user_git_connection_id,
);
}
function selectRepository(providerRepositoryId: string): void {
repositoryForm.provider_repository_id = providerRepositoryId;
repositoryForm.clearErrors('provider_repository_id');
}
function saveRepository(): void {
if (!canSaveRepository.value || processing.value) {
return;
}
const target =
dialogMode.value === 'edit' && selectedConnection.value
? update([props.project.id, selectedConnection.value.id])
: store(props.project.id);
repositoryProtection.submit(target, {
preserveScroll: true,
onSuccess: finishMutation,
});
}
function removeRepository(): void {
if (!selectedConnection.value || processing.value) {
return;
}
removalProtection.submit(
destroy([props.project.id, selectedConnection.value.id]),
{
preserveScroll: true,
onSuccess: finishMutation,
},
);
}
function refreshSources(): void {
if (refreshingSources.value || processing.value) {
return;
}
router.reload({
only: ['availableRepositorySources'],
onStart: () => {
refreshingSources.value = true;
},
onFinish: () => {
refreshingSources.value = false;
},
});
}
watch(
() => repositoryForm.user_git_connection_id,
() => {
repositorySearch.value = '';
if (
selectedSource.value?.repositories.some(
(repository) =>
repository.provider_repository_id ===
repositoryForm.provider_repository_id,
)
) {
return;
}
repositoryForm.provider_repository_id = '';
repositoryForm.clearErrors('provider_repository_id');
},
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head
:title="
t('repositorySettings.pageTitle', { project: project.name })
"
/>
<SettingsLayout :project-name="project.name" :project-key="project.key">
<FeedbackState
v-if="status"
state="success"
:title="successTitle"
/>
<TemplateInheritancePanel
:areas="['repository_connections']"
:project-id="project.id"
:template-state="templateState"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
>
<div
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('repositorySettings.title') }}
</h2>
<p class="text-muted-foreground">
{{ t('repositorySettings.description') }}
</p>
</div>
<Button
type="button"
class="shrink-0 self-start"
@click="openDialog('link')"
>
<Plus aria-hidden="true" />
{{ t('repositorySettings.linkAction') }}
</Button>
</div>
<div
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
>
<Link2
class="text-primary mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{ t('repositorySettings.projectScope') }}
</p>
</div>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('repositorySettings.linkedRepositories') }}
</h3>
<Badge variant="secondary">
{{ repositoryConnections.length }}
</Badge>
</div>
<FeedbackState
v-if="repositoryConnections.length === 0"
state="empty"
:title="t('repositorySettings.empty')"
:description="t('repositorySettings.emptyDescription')"
>
<Button type="button" @click="openDialog('link')">
<Plus aria-hidden="true" />
{{ t('repositorySettings.linkAction') }}
</Button>
</FeedbackState>
<ul
v-else
class="gap-column grid min-w-0"
:aria-label="t('repositorySettings.linkedRepositories')"
>
<li
v-for="repositoryConnection in repositoryConnections"
:key="repositoryConnection.id"
class="gap-column grid min-w-0 rounded-lg border p-4"
>
<div class="flex min-w-0 items-start gap-3">
<div
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
>
<GitBranch
class="size-5"
aria-hidden="true"
/>
</div>
<div class="gap-stack grid min-w-0 flex-1">
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<h4
class="min-w-0 break-all font-semibold"
>
{{
repositoryConnection.repository_full_name
}}
</h4>
<Badge variant="secondary">
{{
repositoryConnection.provider_label
}}
</Badge>
<Badge
:variant="
repositoryConnection.has_personal_access
? 'success'
: 'warning'
"
>
{{
t(
repositoryConnection.has_personal_access
? 'repositorySettings.personalAccessReady'
: 'repositorySettings.personalAccessMissing',
)
}}
</Badge>
</div>
<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">
{{ repositoryConnection.base_url }}
</span>
</div>
<p
class="text-caption text-muted-foreground"
>
{{
t('repositorySettings.ticketUsage')
}}
</p>
</div>
</div>
<div
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
>
<Button
v-if="
repositoryConnection.has_personal_access
"
as-child
variant="outline"
>
<a
:href="
repositoryConnection.repository_url
"
target="_blank"
rel="noreferrer"
:aria-label="
t(
'repositorySettings.openRepository',
{
repository:
repositoryConnection.repository_full_name,
},
)
"
>
<ExternalLink aria-hidden="true" />
{{ t('repositorySettings.open') }}
</a>
</Button>
<Button v-else as-child variant="outline">
<Link :href="editGitConnections()">
<Link2 aria-hidden="true" />
{{
t(
'repositorySettings.connectAccess',
)
}}
</Link>
</Button>
<Button
type="button"
variant="outline"
:aria-label="
t('repositorySettings.editRepository', {
repository:
repositoryConnection.repository_full_name,
})
"
@click="
openDialog('edit', repositoryConnection)
"
>
<Pencil aria-hidden="true" />
{{ t('common.edit') }}
</Button>
<Button
type="button"
variant="ghost"
:aria-label="
t(
'repositorySettings.removeRepository',
{
repository:
repositoryConnection.repository_full_name,
},
)
"
@click="
openDialog(
'remove',
repositoryConnection,
)
"
>
<Trash2 aria-hidden="true" />
{{ t('common.remove') }}
</Button>
</div>
</li>
</ul>
</CardContent>
</Card>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
>
<div
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('repositorySettings.personalSources') }}
</h2>
<p class="text-muted-foreground">
{{
t(
'repositorySettings.personalSourcesDescription',
)
}}
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button as-child variant="outline">
<Link :href="editGitConnections()">
{{
t(
'repositorySettings.manageConnections',
)
}}
</Link>
</Button>
<Button
type="button"
variant="outline"
:loading="refreshingSources"
:loading-label="
t('repositorySettings.refreshing')
"
@click="refreshSources"
>
<RefreshCw aria-hidden="true" />
{{ t('repositorySettings.refresh') }}
</Button>
</div>
</div>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<FeedbackState
v-if="refreshingSources"
state="loading"
:title="t('repositorySettings.refreshing')"
:description="
t('repositorySettings.refreshingDescription')
"
/>
<FeedbackState
v-else-if="availableRepositorySources.length === 0"
state="empty"
:title="t('repositorySettings.noConnections')"
:description="
t('repositorySettings.noConnectionsDescription')
"
>
<Button as-child variant="outline">
<Link :href="editGitConnections()">
{{ t('repositorySettings.manageConnections') }}
</Link>
</Button>
</FeedbackState>
<template v-else>
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('repositorySettings.detectedSources') }}
</h3>
<Badge variant="secondary">
{{ successfulSourceCount }} /
{{ availableRepositorySources.length }}
</Badge>
</div>
<ul class="gap-column grid min-w-0">
<li
v-for="source in availableRepositorySources"
:key="source.user_git_connection_id"
class="gap-card grid min-w-0 rounded-lg border p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
>
<div class="flex min-w-0 items-start gap-3">
<div
class="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-md"
>
<Server
class="size-4"
aria-hidden="true"
/>
</div>
<div class="gap-stack grid min-w-0">
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<span
class="min-w-0 break-all font-semibold"
>
{{ source.account_login }}
</span>
<Badge variant="secondary">
{{ source.provider_label }}
</Badge>
</div>
<span
class="text-caption text-muted-foreground break-all"
>
{{ source.base_url }}
</span>
<p
v-if="source.error"
class="text-caption text-destructive break-words"
>
{{ source.error }}
</p>
</div>
</div>
<Badge
:variant="
source.error ? 'warning' : 'outline'
"
class="justify-self-start"
>
{{
source.error
? t(
'repositorySettings.unavailable',
)
: t(
'repositorySettings.repositoryCount',
{
count: source.repositories
.length,
},
)
}}
</Badge>
</li>
</ul>
</template>
</CardContent>
</Card>
<Dialog :open="dialogOpen" @update:open="closeDialog">
<DialogContent
class="sm:max-w-2xl"
:show-close-button="!processing"
@escape-key-down="processing && $event.preventDefault()"
@interact-outside="processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ dialogTitle }}</DialogTitle>
<DialogDescription>
{{
t(
dialogMode === 'link'
? 'repositorySettings.linkDescription'
: dialogMode === 'edit'
? 'repositorySettings.editDescription'
: 'repositorySettings.removeDescription',
)
}}
</DialogDescription>
</DialogHeader>
<form
v-if="dialogMode !== 'remove'"
class="gap-column grid min-w-0"
:aria-busy="repositoryForm.processing"
@submit.prevent="saveRepository"
>
<FormFeedback
:state="repositoryProtection.state.value"
:title="
repositoryProtection.responseStatus.value ===
403
? t('repositorySettings.forbidden')
: repositoryForm.processing
? t(
dialogMode === 'edit'
? 'repositorySettings.saving'
: 'repositorySettings.linking',
)
: undefined
"
:description="
repositoryProtection.responseStatus.value ===
403
? t(
'repositorySettings.forbiddenDescription',
)
: undefined
"
:retryable="
repositoryProtection.responseStatus.value !==
403
"
@retry="saveRepository"
/>
<FeedbackState
v-if="matchingPersonalSourceMissing"
state="empty"
:title="
t(
'repositorySettings.matchingConnectionMissing',
)
"
:description="
t(
'repositorySettings.matchingConnectionMissingDescription',
)
"
>
<Button as-child variant="outline">
<Link :href="editGitConnections()">
{{
t(
'repositorySettings.manageConnections',
)
}}
</Link>
</Button>
</FeedbackState>
<fieldset
class="gap-column grid min-w-0"
:disabled="repositoryForm.processing"
>
<legend class="sr-only">{{ dialogTitle }}</legend>
<div class="gap-stack grid min-w-0">
<div
class="flex flex-wrap items-baseline justify-between gap-2"
>
<Label>
{{
t(
'repositorySettings.personalConnection',
)
}}
</Label>
<span
class="text-caption text-muted-foreground"
>
{{
t(
'repositorySettings.personalConnectionHelp',
)
}}
</span>
</div>
<FeedbackState
v-if="
availableRepositorySources.length === 0
"
state="empty"
:title="
t('repositorySettings.noConnections')
"
:description="
t(
'repositorySettings.noConnectionsDescription',
)
"
>
<Button as-child variant="outline">
<Link :href="editGitConnections()">
{{
t(
'repositorySettings.manageConnections',
)
}}
</Link>
</Button>
</FeedbackState>
<div
v-else
class="grid min-w-0 gap-2 sm:grid-cols-2"
>
<button
v-for="source in availableRepositorySources"
:key="source.user_git_connection_id"
type="button"
:aria-pressed="
repositoryForm.user_git_connection_id ===
String(
source.user_git_connection_id,
)
"
:class="[
'focus-visible:ring-ring/35 gap-stack grid min-w-0 rounded-lg border p-3 text-left outline-none transition-colors focus-visible:ring-2',
repositoryForm.user_git_connection_id ===
String(
source.user_git_connection_id,
)
? 'border-primary/50 bg-accent'
: 'hover:bg-muted/50',
]"
@click="selectSource(source)"
>
<span
class="flex min-w-0 flex-wrap items-center gap-2"
>
<span
class="min-w-0 break-all font-semibold"
>
{{ source.account_login }}
</span>
<Badge variant="secondary">
{{ source.provider_label }}
</Badge>
</span>
<span
class="text-caption text-muted-foreground break-all"
>
{{ source.base_url }}
</span>
<span
v-if="source.error"
class="text-caption text-destructive"
>
{{
t(
'repositorySettings.unavailable',
)
}}
</span>
</button>
</div>
<InputError
:message="
repositoryForm.errors
.user_git_connection_id
"
/>
</div>
<div v-if="selectedSource" class="gap-column grid">
<FeedbackState
v-if="selectedSource.error"
state="error"
:title="
t('repositorySettings.providerError')
"
:description="selectedSource.error"
retryable
@retry="refreshSources"
/>
<template v-else>
<div class="grid gap-2">
<Label for="repository-search">
{{
t(
'repositorySettings.searchLabel',
)
}}
</Label>
<div class="relative min-w-0">
<Search
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
id="repository-search"
v-model="repositorySearch"
type="search"
class="pl-9"
:placeholder="
t(
'repositorySettings.searchPlaceholder',
)
"
/>
</div>
</div>
<div
v-if="refreshingSources"
class="gap-stack grid"
role="status"
:aria-label="
t('repositorySettings.refreshing')
"
>
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
</div>
<FeedbackState
v-else-if="
selectedSource.repositories
.length === 0
"
state="empty"
:title="
t(
'repositorySettings.noRepositories',
)
"
:description="
t(
'repositorySettings.noRepositoriesDescription',
)
"
/>
<FeedbackState
v-else-if="
filteredRepositories.length === 0
"
state="empty"
:title="
t(
'repositorySettings.noSearchResults',
)
"
:description="
t(
'repositorySettings.noSearchResultsDescription',
{ search: repositorySearch },
)
"
/>
<div
v-else
class="max-h-72 space-y-2 overflow-y-auto pr-1"
role="radiogroup"
:aria-label="
t(
'repositorySettings.repositoryLabel',
)
"
>
<button
v-for="repository in filteredRepositories"
:key="
repository.provider_repository_id
"
type="button"
role="radio"
:aria-checked="
repositoryForm.provider_repository_id ===
repository.provider_repository_id
"
:class="[
'focus-visible:ring-ring/35 flex w-full min-w-0 items-start justify-between gap-3 rounded-lg border p-3 text-left outline-none transition-colors focus-visible:ring-2',
repositoryForm.provider_repository_id ===
repository.provider_repository_id
? 'border-primary/50 bg-accent'
: 'hover:bg-muted/50',
]"
@click="
selectRepository(
repository.provider_repository_id,
)
"
>
<span
class="gap-stack grid min-w-0"
>
<span
class="min-w-0 break-all font-semibold"
>
{{ repository.full_name }}
</span>
<span
class="text-caption text-muted-foreground break-all"
>
{{
repository.repository_url
}}
</span>
</span>
<Badge
variant="outline"
class="shrink-0"
>
{{
repository.visibility_label
}}
</Badge>
</button>
</div>
</template>
<InputError
:message="
repositoryForm.errors
.provider_repository_id
"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
@click="closeDialog(false)"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:disabled="!canSaveRepository"
:loading="repositoryForm.processing"
:loading-label="
t(
dialogMode === 'edit'
? 'repositorySettings.saving'
: 'repositorySettings.linking',
)
"
>
{{
t(
dialogMode === 'edit'
? 'repositorySettings.save'
: 'repositorySettings.linkAction',
)
}}
</Button>
</DialogFooter>
</fieldset>
</form>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="removalForm.processing"
@submit.prevent="removeRepository"
>
<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?.repository_full_name }}
</p>
<p class="text-muted-foreground break-all">
{{ selectedConnection?.base_url }}
</p>
</div>
<FormFeedback
:state="removalProtection.state.value"
:title="
removalProtection.responseStatus.value === 403
? t('repositorySettings.forbidden')
: removalForm.processing
? t('repositorySettings.removing')
: undefined
"
:description="
removalProtection.responseStatus.value === 403
? t(
'repositorySettings.forbiddenDescription',
)
: undefined
"
:retryable="
removalProtection.responseStatus.value !== 403
"
@retry="removeRepository"
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="removalForm.processing"
@click="closeDialog(false)"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
variant="destructive"
:loading="removalForm.processing"
:loading-label="
t('repositorySettings.removing')
"
>
{{ t('repositorySettings.removeTitle') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</SettingsLayout>
</AppLayout>
</template>