-
-
-
- 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.
-
-
-
-
+
- {{ status }}
-
-
-
-
-
- Add Gitea connection
-
- Connect a Gitea account by storing the instance
- base URL and a personal application token.
-
-
-
-
-
-
-
-
-
-
- Saved connections
-
- Tokens stay hidden after save. Updating a
- connection requires entering a fresh token.
-
-
-
-
-
- No Git connections have been added yet.
-
-
-
+
+
+
+ {{ t('navigation.gitConnections') }}
+
+
+ {{ t('gitConnections.description') }}
+
+
+
+ {{
+ t('gitConnections.add')
+ }}
+
+
+
+
+
{{ t('gitConnections.privacy') }}
+
+
+
+
+
+
+ {{ t('gitConnections.saved') }}
+
+ {{
+ gitConnections.length
+ }}
+
+
+
+
+
-
+
+
+
- {{
- gitConnection.provider_label
- }}
-
- {{
- gitConnection.account_login
- }}
-
-
-
- {{
- gitConnection.account_name ??
- 'Unnamed remote account'
- }}
-
-
- {{ gitConnection.base_url }}
-
+ {{ connection.account_login }}
+
+
{{
+ connection.provider_label
+ }}
-
-
- Remote user ID:
- {{ gitConnection.provider_user_id }}
+
+ {{
+ connection.account_name ??
+ t('gitConnections.unnamedAccount')
+ }}
+
+
+
+ {{
+ connection.base_url
+ }}
+
+
+ {{
+ t('gitConnections.remoteId', {
+ id: connection.provider_user_id,
+ })
+ }}
-
-
-
-
-
-
-
-
+
+
+ {{
+ t('gitConnections.editAction')
+ }}
+
+
+ {{
+ t('gitConnections.remove')
+ }}
+
+
+
+
+
+
+
+
+
+ {{ dialogTitle }}
+ {{
+ t(
+ mode === 'remove'
+ ? 'gitConnections.removeDescription'
+ : mode === 'edit'
+ ? 'gitConnections.editDescription'
+ : 'gitConnections.addDescription',
+ )
+ }}
+
+
+
+
+
diff --git a/tests/Feature/Settings/GitConnectionTest.php b/tests/Feature/Settings/GitConnectionTest.php
index c1c051d..34af7c9 100644
--- a/tests/Feature/Settings/GitConnectionTest.php
+++ b/tests/Feature/Settings/GitConnectionTest.php
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
'base_url' => 'https://git.example.test',
]);
+ UserGitConnection::factory()->create();
+
$this->actingAs($user)
->get(route('git-connections.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections')
+ ->has('gitConnections', 1)
+ ->missing('gitConnections.0.api_token')
->where('gitConnections.0.account_login', 'nico')
->where('gitConnections.0.base_url', 'https://git.example.test')
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
'api_token' => 'wrong-token',
])
->assertRedirect(route('git-connections.edit'))
- ->assertSessionHasErrors('api_token');
+ ->assertSessionHasErrors('api_token')
+ ->assertSessionMissing('_old_input.api_token');
}
/**
@@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase
'id' => $gitConnection->id,
]);
}
+
+ public function test_empty_personal_connections_are_displayed(): void
+ {
+ UserGitConnection::factory()->create();
+
+ $this->actingAs(User::factory()->create())
+ ->get(route('git-connections.edit'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('settings/GitConnections')
+ ->where('gitConnections', []),
+ );
+ }
+
+ public function test_guests_cannot_access_git_connections(): void
+ {
+ $this->get(route('git-connections.edit'))->assertRedirect(route('login'));
+ }
+
+ public function test_users_cannot_delete_foreign_connections(): void
+ {
+ $connection = UserGitConnection::factory()->create();
+
+ $this->actingAs(User::factory()->create())
+ ->delete(route('git-connections.destroy', $connection))
+ ->assertForbidden();
+
+ $this->assertModelExists($connection);
+ }
+
+ public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void
+ {
+ Http::preventStrayRequests();
+ Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]);
+ $user = User::factory()->create();
+ $connection = UserGitConnection::factory()->for($user)->create([
+ 'base_url' => 'https://git.original.test',
+ 'api_token' => 'original-secret',
+ ]);
+ $original = $connection->fresh()->getAttributes();
+
+ $this->actingAs($user)
+ ->from(route('git-connections.edit'))
+ ->patch(route('git-connections.update', $connection), [
+ 'provider' => 'gitea',
+ 'base_url' => 'https://git.changed.test',
+ 'api_token' => 'replacement-secret',
+ ])
+ ->assertRedirect(route('git-connections.edit'))
+ ->assertSessionHasErrors('api_token')
+ ->assertSessionMissing('_old_input.api_token');
+
+ $this->assertSame($original, $connection->fresh()->getAttributes());
+ $this->assertSame('original-secret', $connection->fresh()->api_token);
+ }
}
diff --git a/tests/Feature/Settings/GitConnectionsUiTest.php b/tests/Feature/Settings/GitConnectionsUiTest.php
new file mode 100644
index 0000000..4ff1fcd
--- /dev/null
+++ b/tests/Feature/Settings/GitConnectionsUiTest.php
@@ -0,0 +1,18 @@
+setTimeout(60);
+ $process->run();
+ $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
+ $this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput());
+ }
+}
diff --git a/tests/Fixtures/git-connections-browser.mjs b/tests/Fixtures/git-connections-browser.mjs
new file mode 100644
index 0000000..adef9f0
--- /dev/null
+++ b/tests/Fixtures/git-connections-browser.mjs
@@ -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();
+}
diff --git a/tests/Fixtures/git-connections-preview.mjs b/tests/Fixtures/git-connections-preview.mjs
new file mode 100644
index 0000000..b4d0c63
--- /dev/null
+++ b/tests/Fixtures/git-connections-preview.mjs
@@ -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),
+});
diff --git a/tests/Fixtures/git-connections.mjs b/tests/Fixtures/git-connections.mjs
new file mode 100644
index 0000000..39ec4a4
--- /dev/null
+++ b/tests/Fixtures/git-connections.mjs
@@ -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,
+ '
',
+ ),
+ );
+ });
+ 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();
+ }
+}