feat(KAN-38): rebuild personal Git connection settings
This commit is contained in:
+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();
|
||||||
|
|||||||
@@ -731,4 +731,43 @@ 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',
|
||||||
|
'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,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."
|
>
|
||||||
/>
|
<div
|
||||||
<p class="text-muted-foreground text-sm">
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
Each user needs their own application key. Without a
|
>
|
||||||
matching personal connection, {{ appName }} can only
|
<div class="gap-stack grid min-w-0">
|
||||||
show repository names and will link you back to this
|
<h2 class="text-title">
|
||||||
page.
|
{{ t('navigation.gitConnections') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('gitConnections.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
<div
|
type="button"
|
||||||
v-if="status"
|
class="shrink-0 self-start"
|
||||||
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"
|
@click="openDialog('create')"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<Plus aria-hidden="true" />{{
|
||||||
</div>
|
t('gitConnections.add')
|
||||||
|
}}
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Add Gitea connection</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Connect a Gitea account by storing the instance
|
|
||||||
base URL and a personal application token.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="GitConnectionController.store.form()"
|
|
||||||
class="space-y-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="provider"
|
|
||||||
type="hidden"
|
|
||||||
value="gitea"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="base_url">Gitea base URL</Label>
|
|
||||||
<Input
|
|
||||||
id="base_url"
|
|
||||||
name="base_url"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="https://git.example.com"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.base_url" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="api_token"
|
|
||||||
>Application token</Label
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="api_token"
|
|
||||||
name="api_token"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="Paste your Gitea application key"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.api_token" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add connection
|
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</div>
|
||||||
</CardContent>
|
<div
|
||||||
</Card>
|
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
|
||||||
|
>
|
||||||
<Card class="gap-0 overflow-hidden">
|
<LockKeyhole
|
||||||
<CardHeader class="border-b">
|
class="text-primary mt-0.5 size-4 shrink-0"
|
||||||
<CardTitle>Saved connections</CardTitle>
|
aria-hidden="true"
|
||||||
<CardDescription>
|
/>
|
||||||
Tokens stay hidden after save. Updating a
|
<p>{{ t('gitConnections.privacy') }}</p>
|
||||||
connection requires entering a fresh token.
|
</div>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<CardContent class="space-y-4 p-6">
|
<FeedbackState
|
||||||
<div
|
v-if="status"
|
||||||
v-if="gitConnections.length === 0"
|
state="success"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
:title="successTitle"
|
||||||
>
|
/>
|
||||||
No Git connections have been added yet.
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<h3 class="font-semibold">
|
||||||
|
{{ t('gitConnections.saved') }}
|
||||||
<div
|
</h3>
|
||||||
v-for="gitConnection in gitConnections"
|
<Badge variant="secondary">{{
|
||||||
:key="gitConnection.id"
|
gitConnections.length
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mb-4 flex flex-wrap items-start justify-between gap-3"
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge>{{
|
|
||||||
gitConnection.provider_label
|
|
||||||
}}</Badge>
|
}}</Badge>
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{
|
|
||||||
gitConnection.account_login
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<FeedbackState
|
||||||
class="text-muted-foreground text-sm"
|
v-if="gitConnections.length === 0"
|
||||||
>
|
state="empty"
|
||||||
{{
|
:title="t('gitConnections.empty')"
|
||||||
gitConnection.account_name ??
|
:description="t('gitConnections.emptyDescription')"
|
||||||
'Unnamed remote account'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{{ gitConnection.base_url }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
Remote user ID:
|
|
||||||
{{ gitConnection.provider_user_id }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
GitConnectionController.update.form(
|
|
||||||
gitConnection.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="provider"
|
|
||||||
type="hidden"
|
|
||||||
:value="gitConnection.provider"
|
|
||||||
/>
|
/>
|
||||||
|
<ul
|
||||||
<div class="grid gap-2">
|
v-else
|
||||||
<Label
|
class="gap-column grid min-w-0"
|
||||||
:for="`base-url-${gitConnection.id}`"
|
:aria-label="t('gitConnections.saved')"
|
||||||
>
|
>
|
||||||
Gitea base URL
|
<li
|
||||||
</Label>
|
v-for="connection in gitConnections"
|
||||||
<Input
|
:key="connection.id"
|
||||||
:id="`base-url-${gitConnection.id}`"
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
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
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
</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
|
<div
|
||||||
class="flex flex-wrap justify-between gap-3"
|
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"
|
||||||
|
>
|
||||||
|
{{ connection.account_login }}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
connection.provider_label
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-words"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
connection.account_name ??
|
||||||
|
t('gitConnections.unnamedAccount')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="text-caption text-muted-foreground flex min-w-0 items-start gap-2"
|
||||||
|
>
|
||||||
|
<Server
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">{{
|
||||||
|
connection.base_url
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-all"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('gitConnections.remoteId', {
|
||||||
|
id: connection.provider_user_id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="processing"
|
:aria-label="
|
||||||
|
t('gitConnections.editAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('edit', connection)"
|
||||||
>
|
>
|
||||||
Save changes
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</li>
|
||||||
|
</ul>
|
||||||
<Form
|
</CardContent>
|
||||||
v-bind="
|
</Card>
|
||||||
GitConnectionController.destroy.form(
|
<Dialog :open="dialogOpen" @update:open="closeDialog">
|
||||||
gitConnection.id,
|
<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
|
||||||
"
|
"
|
||||||
class="mt-3 flex justify-end"
|
:description="
|
||||||
v-slot="{ processing }"
|
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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
:disabled="processing"
|
:disabled="removalForm.processing"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
removalForm.processing
|
||||||
|
? 'gitConnections.removing'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Remove connection
|
</DialogFooter>
|
||||||
</Button>
|
</form>
|
||||||
</Form>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user