Implement Gitea user connection foundation #15

Merged
Twyco merged 3 commits from codex/0.2-gitea-integration/01-git-provider-abstraction-and-gitea-foundation into main 2026-04-01 16:34:49 +00:00
25 changed files with 1206 additions and 2 deletions
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Git\Contracts;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
interface GitProviderClient
{
public function key(): GitProviderKey;
public function getAuthenticatedUser(string $baseUrl, string $token): GitAuthenticatedUser;
/**
* @return array<int, GitRepository>
*/
public function listRepositories(string $baseUrl, string $token): array;
}
@@ -0,0 +1,15 @@
<?php
namespace App\Actions\Git\Data;
/**
* Provider-agnostic representation of the authenticated remote account.
*/
final readonly class GitAuthenticatedUser
{
public function __construct(
public string $providerUserId,
public string $login,
public ?string $name,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Actions\Git\Data;
/**
* Provider-agnostic representation of a repository visible to a user connection.
*/
final readonly class GitRepository
{
public function __construct(
public string $providerRepositoryId,
public string $owner,
public string $name,
public string $fullName,
public string $htmlUrl,
public bool $isPrivate,
) {}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Actions\Git;
enum GitProviderKey: string
{
case Gitea = 'gitea';
public function label(): string
{
return match ($this) {
self::Gitea => 'Gitea',
};
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Actions\Git;
use App\Actions\Git\Contracts\GitProviderClient;
use App\Actions\Git\Gitea\GiteaProviderClient;
use Illuminate\Support\Str;
class GitProviderManager
{
public function __construct(
private readonly GiteaProviderClient $giteaProviderClient,
) {}
public function client(GitProviderKey|string $provider): GitProviderClient
{
$providerKey = $provider instanceof GitProviderKey
? $provider
: GitProviderKey::from(Str::lower(trim($provider)));
return match ($providerKey) {
GitProviderKey::Gitea => $this->giteaProviderClient,
};
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Actions\Git\Gitea;
use App\Actions\Git\Contracts\GitProviderClient;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GiteaProviderClient implements GitProviderClient
{
public function key(): GitProviderKey
{
return GitProviderKey::Gitea;
}
public function getAuthenticatedUser(string $baseUrl, string $token): GitAuthenticatedUser
{
/** @var array{id: int|string, login: string, full_name?: string|null} $payload */
$payload = $this->request($baseUrl, $token)
->get('/user')
->throw()
->json();
return new GitAuthenticatedUser(
providerUserId: (string) $payload['id'],
login: $payload['login'],
name: blank($payload['full_name'] ?? null) ? null : $payload['full_name'],
);
}
/**
* @return array<int, GitRepository>
*/
public function listRepositories(string $baseUrl, string $token): array
{
$page = 1;
$repositories = [];
do {
/** @var array<int, array{
* id: int|string,
* name: string,
* full_name: string,
* html_url: string,
* owner: array{login: string},
* private: bool
* }> $payload
*/
$payload = $this->request($baseUrl, $token)
->get('/user/repos', [
'limit' => 100,
'page' => $page,
])
->throw()
->json();
foreach ($payload as $repository) {
$repositories[] = new GitRepository(
providerRepositoryId: (string) $repository['id'],
owner: $repository['owner']['login'],
name: $repository['name'],
fullName: $repository['full_name'],
htmlUrl: $repository['html_url'],
isPrivate: $repository['private'],
);
}
$page++;
} while (count($payload) === 100);
return $repositories;
}
private function request(string $baseUrl, string $token): PendingRequest
{
return Http::acceptJson()
->baseUrl($this->apiBaseUrl($baseUrl))
->connectTimeout(5)
->timeout(10)
->withHeader('Authorization', 'token '.$token);
}
private function apiBaseUrl(string $baseUrl): string
{
$normalizedBaseUrl = Str::of(trim($baseUrl))
->rtrim('/')
->value();
if (Str::endsWith(Str::lower($normalizedBaseUrl), '/api/v1')) {
return $normalizedBaseUrl;
}
return Str::of($normalizedBaseUrl)
->append('/api/v1')
->value();
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Actions\Git\GitProviderManager;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreUserGitConnectionRequest;
use App\Http\Requests\Settings\UpdateUserGitConnectionRequest;
use App\Models\UserGitConnection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Throwable;
class GitConnectionController extends Controller
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
) {}
public function edit(Request $request): Response
{
return Inertia::render('settings/GitConnections', [
'gitConnections' => $request->user()->gitConnections
->map(fn (UserGitConnection $gitConnection): array => [
Twyco marked this conversation as resolved
Review

würde auch auslagern, weil unschön

würde auch auslagern, weil unschön
'account_login' => $gitConnection->account_login,
'account_name' => $gitConnection->account_name,
'base_url' => $gitConnection->base_url,
'created_at' => $gitConnection->created_at?->toISOString(),
'id' => $gitConnection->id,
'provider' => $gitConnection->provider->value,
'provider_label' => $gitConnection->provider->label(),
'provider_user_id' => $gitConnection->provider_user_id,
'updated_at' => $gitConnection->updated_at?->toISOString(),
'user_id' => $gitConnection->user_id,
])
->values(),
'status' => $request->session()->get('status'),
]);
}
public function store(StoreUserGitConnectionRequest $request): RedirectResponse
{
try {
$gitProvider = $this->gitProviderManager->client($request->provider());
$authenticatedUser = $gitProvider->getAuthenticatedUser(
$request->baseUrl(),
$request->apiToken(),
);
} catch (Throwable) {
return back()
->withErrors([
'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.',
]);
}
UserGitConnection::query()->create([
'account_login' => $authenticatedUser->login,
'account_name' => $authenticatedUser->name,
'api_token' => $request->apiToken(),
'base_url' => $request->baseUrl(),
'provider' => $request->provider(),
'provider_user_id' => $authenticatedUser->providerUserId,
'user_id' => $request->user()->id,
]);
return to_route('git-connections.edit')
->with('status', 'Git connection was added.');
}
public function update(
UpdateUserGitConnectionRequest $request,
UserGitConnection $gitConnection,
): RedirectResponse {
$this->abortUnlessOwner($request, $gitConnection);
try {
$gitProvider = $this->gitProviderManager->client($request->provider());
$authenticatedUser = $gitProvider->getAuthenticatedUser(
$request->baseUrl(),
$request->apiToken(),
);
} catch (Throwable) {
return back()
->withErrors([
'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.',
]);
}
$gitConnection->update([
'account_login' => $authenticatedUser->login,
'account_name' => $authenticatedUser->name,
'api_token' => $request->apiToken(),
'base_url' => $request->baseUrl(),
'provider' => $request->provider(),
'provider_user_id' => $authenticatedUser->providerUserId,
]);
return to_route('git-connections.edit')
->with('status', 'Git connection was updated.');
}
public function destroy(Request $request, UserGitConnection $gitConnection): RedirectResponse
{
$this->abortUnlessOwner($request, $gitConnection);
$label = "{$gitConnection->account_login} @ {$gitConnection->base_url}";
$gitConnection->delete();
return to_route('git-connections.edit')
->with('status', "{$label} was removed.");
}
private function abortUnlessOwner(Request $request, UserGitConnection $gitConnection): void
Twyco marked this conversation as resolved
Review

Kann man das nicht auch besser auslagern in nen Gate oder Policy? Weiß icht ob der Controller darüber bescheid wissen muss

Kann man das nicht auch besser auslagern in nen Gate oder Policy? Weiß icht ob der Controller darüber bescheid wissen muss
{
abort_unless($gitConnection->user_id === $request->user()->id, 403);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Requests\Settings;
use App\Actions\Git\GitProviderKey;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class StoreUserGitConnectionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'api_token' => ['required', 'string', 'max:2048'],
'base_url' => [
'required',
'url:http,https',
'max:255',
Rule::unique('user_git_connections')
->where(fn ($query) => $query
->where('user_id', $this->user()->id)
->where('provider', $this->input('provider'))),
],
'provider' => ['required', Rule::in(array_map(
static fn (GitProviderKey $providerKey): string => $providerKey->value,
GitProviderKey::cases(),
))],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'base_url' => $this->normalizedBaseUrl(),
'provider' => Str::lower(trim((string) $this->input('provider'))),
]);
}
public function apiToken(): string
{
return (string) $this->validated('api_token');
}
public function baseUrl(): string
{
return (string) $this->validated('base_url');
}
public function provider(): GitProviderKey
{
return GitProviderKey::from((string) $this->validated('provider'));
}
private function normalizedBaseUrl(): string
{
$baseUrl = Str::of((string) $this->input('base_url'))
->trim()
->rtrim('/')
->value();
return preg_replace('#/api/v1$#i', '', $baseUrl) ?? $baseUrl;
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Requests\Settings;
use App\Actions\Git\GitProviderKey;
use App\Models\UserGitConnection;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UpdateUserGitConnectionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var UserGitConnection $gitConnection */
$gitConnection = $this->route('gitConnection');
return [
'api_token' => ['required', 'string', 'max:2048'],
'base_url' => [
'required',
'url:http,https',
'max:255',
Rule::unique('user_git_connections')
->ignore($gitConnection->id)
->where(fn ($query) => $query
->where('user_id', $this->user()->id)
->where('provider', $this->input('provider'))),
],
'provider' => ['required', Rule::in(array_map(
static fn (GitProviderKey $providerKey): string => $providerKey->value,
GitProviderKey::cases(),
))],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'base_url' => $this->normalizedBaseUrl(),
'provider' => Str::lower(trim((string) $this->input('provider'))),
]);
}
public function apiToken(): string
{
return (string) $this->validated('api_token');
}
public function baseUrl(): string
{
return (string) $this->validated('base_url');
}
public function provider(): GitProviderKey
{
return GitProviderKey::from((string) $this->validated('provider'));
}
private function normalizedBaseUrl(): string
{
$baseUrl = Str::of((string) $this->input('base_url'))
->trim()
->rtrim('/')
->value();
return preg_replace('#/api/v1$#i', '', $baseUrl) ?? $baseUrl;
}
}
+8
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
@@ -39,4 +40,11 @@ class User extends Authenticatable
return $this->belongsToMany(Team::class)
->withTimestamps();
}
public function gitConnections(): HasMany
{
return $this->hasMany(UserGitConnection::class)
->orderBy('provider')
->orderBy('base_url');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Actions\Git\GitProviderKey;
use Database\Factories\UserGitConnectionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'account_login',
'account_name',
'api_token',
'base_url',
'provider',
'provider_user_id',
'user_id',
])]
#[Hidden(['api_token'])]
class UserGitConnection extends Model
{
/** @use HasFactory<UserGitConnectionFactory> */
use HasFactory;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'api_token' => 'encrypted',
'provider' => GitProviderKey::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Actions\Git\GitProviderKey;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserGitConnection>
*/
class UserGitConnectionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'account_login' => fake()->userName(),
'account_name' => fake()->name(),
'api_token' => 'gitea-token-'.fake()->uuid(),
'base_url' => 'https://git.'.fake()->domainName(),
'provider' => GitProviderKey::Gitea,
'provider_user_id' => (string) fake()->numberBetween(1, 999_999),
'user_id' => User::factory(),
];
}
}
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_git_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('provider');
$table->string('base_url');
$table->string('provider_user_id');
$table->string('account_login');
$table->string('account_name')->nullable();
$table->text('api_token');
$table->timestamps();
$table->unique(['user_id', 'provider', 'base_url']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_git_connections');
}
};
+1
View File
@@ -14,6 +14,7 @@ class DatabaseSeeder extends Seeder
{
$this->call([
UserSeeder::class,
UserGitConnectionSeeder::class,
ProjectSeeder::class,
BoardColumnSeeder::class,
ProjectTicketMetadataSeeder::class,
@@ -0,0 +1,32 @@
<?php
namespace Database\Seeders;
use App\Actions\Git\GitProviderKey;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Database\Seeder;
class UserGitConnectionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$firstUser = User::query()->orderBy('id')->first();
if ($firstUser === null) {
return;
}
UserGitConnection::factory()->for($firstUser)->create([
'account_login' => 'admin-user',
'account_name' => 'Admin User',
'api_token' => 'seeded-gitea-token',
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
'provider_user_id' => '1001',
]);
}
}
+1
View File
@@ -46,6 +46,7 @@
- made story points more static(change to table field in table), but only show if checked in project settings → new value on Project: hasStoryPoints
- more ticket field types (string, string array, boolean, precision (double))
- abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController)
- ticket assignee fest einbauen in ticket
- 06
- add Ticket Status
- ticketstatus can have one or more board columns
+6 -1
View File
@@ -6,6 +6,7 @@ import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editGitConnections } from '@/routes/git-connections';
import { edit as editProfile } from '@/routes/profile';
import { edit as editSecurity } from '@/routes/security';
import type { NavItem } from '@/types';
@@ -19,6 +20,10 @@ const sidebarNavItems: NavItem[] = [
title: 'Security',
href: editSecurity(),
},
{
title: 'Git connections',
href: editGitConnections(),
},
{
title: 'Appearance',
href: editAppearance(),
@@ -32,7 +37,7 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
<div class="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
description="Manage your profile, security, and Git integration settings"
/>
<div class="flex flex-col lg:flex-row lg:space-x-12">
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
defineProps<SettingsGitConnectionsPageProps>();
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Git connections',
href: editGitConnections(),
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Git connections" />
<SettingsLayout>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Heading
variant="small"
title="Git connections"
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access."
/>
<p class="text-sm text-muted-foreground">
Each user needs their own application key. Without a
matching personal connection, KanbanGit 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="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Add Gitea connection</CardTitle>
<CardDescription>
Connect a Gitea account by storing the instance
base URL and a personal application token.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="GitConnectionController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
>
<input name="provider" type="hidden" value="gitea" />
<div class="grid gap-2">
<Label for="base_url">Gitea base URL</Label>
<Input
id="base_url"
name="base_url"
type="url"
required
autofocus
placeholder="https://git.example.com"
/>
<InputError :message="errors.base_url" />
</div>
<div class="grid gap-2">
<Label for="api_token">Application token</Label>
<Input
id="api_token"
name="api_token"
type="password"
required
placeholder="Paste your Gitea application key"
/>
<InputError :message="errors.api_token" />
</div>
<Button type="submit" :disabled="processing">
Add connection
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Saved connections</CardTitle>
<CardDescription>
Tokens stay hidden after save. Updating a
connection requires entering a fresh token.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="gitConnections.length === 0"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
No Git connections have been added yet.
</div>
<div
v-for="gitConnection in gitConnections"
:key="gitConnection.id"
class="rounded-xl border border-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>
<span class="text-sm font-medium">
{{ gitConnection.account_login }}
</span>
</div>
<p class="text-sm text-muted-foreground">
{{
gitConnection.account_name ??
'Unnamed remote account'
}}
</p>
<p class="text-xs text-muted-foreground">
{{ gitConnection.base_url }}
</p>
</div>
<p class="text-xs text-muted-foreground">
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"
/>
<div class="grid gap-2">
<Label :for="`base-url-${gitConnection.id}`">
Gitea base URL
</Label>
<Input
:id="`base-url-${gitConnection.id}`"
name="base_url"
type="url"
:default-value="gitConnection.base_url"
required
/>
<InputError :message="errors.base_url" />
</div>
<div class="grid gap-2">
<Label :for="`token-${gitConnection.id}`">
New application token
</Label>
<Input
:id="`token-${gitConnection.id}`"
name="api_token"
type="password"
required
placeholder="Paste a new token to verify and save"
/>
<InputError :message="errors.api_token" />
</div>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save changes
</Button>
</div>
</Form>
<Form
v-bind="
GitConnectionController.destroy.form(
gitConnection.id,
)
"
class="mt-3 flex justify-end"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Remove connection
</Button>
</Form>
</div>
</CardContent>
</Card>
</div>
</div>
</SettingsLayout>
</AppLayout>
</template>
-1
View File
@@ -19,7 +19,6 @@ declare module '@inertiajs/core' {
name: string;
auth: Auth;
sidebarOpen: boolean;
[key: string]: unknown;
};
}
}
@@ -0,0 +1,12 @@
export type UserGitConnectionModel = {
id: number;
user_id: number;
provider: string;
provider_label: string;
base_url: string;
provider_user_id: string;
account_login: string;
account_name: string | null;
created_at: string | null;
updated_at: string | null;
};
+6
View File
@@ -9,6 +9,7 @@ import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
@@ -100,3 +101,8 @@ export type ProjectTicketPageProps = {
ticketTypes: TicketTypeModel[];
users: UserModel[];
};
export type SettingsGitConnectionsPageProps = {
gitConnections: UserGitConnectionModel[];
status?: string | null;
};
+10
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Settings\GitConnectionController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\SecurityController;
use Illuminate\Support\Facades\Route;
@@ -20,5 +21,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
->middleware('throttle:6,1')
->name('user-password.update');
Route::get('settings/git-connections', [GitConnectionController::class, 'edit'])
->name('git-connections.edit');
Route::post('settings/git-connections', [GitConnectionController::class, 'store'])
->name('git-connections.store');
Route::patch('settings/git-connections/{gitConnection}', [GitConnectionController::class, 'update'])
->name('git-connections.update');
Route::delete('settings/git-connections/{gitConnection}', [GitConnectionController::class, 'destroy'])
->name('git-connections.destroy');
Route::inertia('settings/appearance', 'settings/Appearance')->name('appearance.edit');
});
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature\Git;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\Gitea\GiteaProviderClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class GiteaProviderTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the provider authenticates against the Gitea user endpoint.
*/
public function test_provider_can_fetch_the_authenticated_user(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/user' => Http::response([
'id' => 101,
'login' => 'nico',
'full_name' => 'Nico Example',
]),
]);
$authenticatedUser = app(GiteaProviderClient::class)->getAuthenticatedUser(
'https://git.example.test',
'super-secret-token',
);
$this->assertInstanceOf(GitAuthenticatedUser::class, $authenticatedUser);
$this->assertSame('101', $authenticatedUser->providerUserId);
$this->assertSame('nico', $authenticatedUser->login);
$this->assertSame('Nico Example', $authenticatedUser->name);
Http::assertSent(function ($request): bool {
return $request->url() === 'https://git.example.test/api/v1/user'
&& $request->hasHeader('Authorization', 'token super-secret-token');
});
}
/**
* Ensure the provider lists repositories from the authenticated Gitea account.
*/
public function test_provider_can_list_repositories(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/user/repos*' => Http::response([
[
'id' => 501,
'name' => 'kanbangit',
'full_name' => 'nico/kanbangit',
'html_url' => 'https://git.example.test/nico/kanbangit',
'owner' => [
'login' => 'nico',
],
'private' => false,
],
]),
]);
$repositories = app(GiteaProviderClient::class)->listRepositories(
'https://git.example.test/api/v1',
'repo-token',
);
$this->assertCount(1, $repositories);
$this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories);
$this->assertSame('501', $repositories[0]->providerRepositoryId);
$this->assertSame('nico/kanbangit', $repositories[0]->fullName);
$this->assertFalse($repositories[0]->isPrivate);
Http::assertSent(function ($request): bool {
return str_starts_with($request->url(), 'https://git.example.test/api/v1/user/repos')
&& $request->hasHeader('Authorization', 'token repo-token');
});
}
}
@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature\Models;
use App\Actions\Git\GitProviderKey;
use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class UserGitConnectionTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the user git connection factory creates a valid encrypted Gitea connection.
*/
public function test_factory_creates_a_gitea_connection_with_an_encrypted_token(): void
{
$gitConnection = UserGitConnection::factory()->create([
'api_token' => 'plain-test-token',
]);
$storedToken = DB::table('user_git_connections')
->where('id', $gitConnection->id)
->value('api_token');
$this->assertNotSame('plain-test-token', $storedToken);
$this->assertSame('plain-test-token', $gitConnection->fresh()->api_token);
$this->assertSame(GitProviderKey::Gitea, $gitConnection->fresh()->provider);
}
/**
* Ensure the model belongs to a user for per-account credential scoping.
*/
public function test_git_connections_belong_to_a_user(): void
{
$gitConnection = UserGitConnection::factory()->create();
$this->assertTrue($gitConnection->user()->exists());
$this->assertTrue($gitConnection->user->is($gitConnection->fresh()->user));
}
}
@@ -0,0 +1,184 @@
<?php
namespace Tests\Feature\Settings;
use App\Actions\Git\GitProviderKey;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class GitConnectionTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the Git connections settings page is displayed to authenticated users.
*/
public function test_git_connections_page_is_displayed(): void
{
$user = User::factory()->create();
UserGitConnection::factory()->for($user)->create([
'account_login' => 'nico',
'base_url' => 'https://git.example.test',
]);
$this->actingAs($user)
->get(route('git-connections.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections')
->where('gitConnections.0.account_login', 'nico')
->where('gitConnections.0.base_url', 'https://git.example.test')
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
);
}
/**
* Ensure a user can add a verified Gitea connection from settings.
*/
public function test_users_can_store_verified_gitea_connections(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/user' => Http::response([
'id' => 101,
'login' => 'nico',
'full_name' => 'Nico Example',
]),
]);
$user = User::factory()->create();
$this->actingAs($user)
->post(route('git-connections.store'), [
'provider' => 'gitea',
'base_url' => 'https://git.example.test/api/v1',
'api_token' => 'super-secret-token',
])
->assertRedirect(route('git-connections.edit'))
->assertSessionHas('status', 'Git connection was added.');
$this->assertDatabaseHas('user_git_connections', [
'user_id' => $user->id,
'provider' => GitProviderKey::Gitea->value,
'base_url' => 'https://git.example.test',
'provider_user_id' => '101',
'account_login' => 'nico',
'account_name' => 'Nico Example',
]);
}
/**
* Ensure invalid provider credentials are rejected with a validation error.
*/
public function test_invalid_provider_credentials_are_rejected(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/user' => Http::response([
'message' => 'Unauthorized',
], 401),
]);
$user = User::factory()->create();
$this->actingAs($user)
->from(route('git-connections.edit'))
->post(route('git-connections.store'), [
'provider' => 'gitea',
'base_url' => 'https://git.example.test',
'api_token' => 'wrong-token',
])
->assertRedirect(route('git-connections.edit'))
->assertSessionHasErrors('api_token');
}
/**
* Ensure a user can update only their own Git connection.
*/
public function test_users_can_update_their_own_git_connections(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.internal.test/api/v1/user' => Http::response([
'id' => 202,
'login' => 'updated-user',
'full_name' => 'Updated User',
]),
]);
$user = User::factory()->create();
$gitConnection = UserGitConnection::factory()->for($user)->create([
'account_login' => 'old-user',
'account_name' => 'Old User',
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
'provider_user_id' => '100',
]);
$this->actingAs($user)
->patch(route('git-connections.update', $gitConnection), [
'provider' => 'gitea',
'base_url' => 'https://git.internal.test',
'api_token' => 'fresh-token',
])
->assertRedirect(route('git-connections.edit'))
->assertSessionHas('status', 'Git connection was updated.');
$this->assertDatabaseHas('user_git_connections', [
'id' => $gitConnection->id,
'base_url' => 'https://git.internal.test',
'provider_user_id' => '202',
'account_login' => 'updated-user',
'account_name' => 'Updated User',
]);
}
/**
* Ensure users cannot update another user's Git connection.
*/
public function test_users_cannot_update_foreign_git_connections(): void
{
Http::preventStrayRequests();
Http::fake();
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$gitConnection = UserGitConnection::factory()->for($owner)->create();
$this->actingAs($otherUser)
->patch(route('git-connections.update', $gitConnection), [
'provider' => 'gitea',
'base_url' => 'https://git.example.test',
'api_token' => 'fresh-token',
])
->assertForbidden();
}
/**
* Ensure users can remove their own Git connections.
*/
public function test_users_can_delete_their_own_git_connections(): void
{
$user = User::factory()->create();
$gitConnection = UserGitConnection::factory()->for($user)->create([
'account_login' => 'nico',
'base_url' => 'https://git.example.test',
]);
$this->actingAs($user)
->delete(route('git-connections.destroy', $gitConnection))
->assertRedirect(route('git-connections.edit'))
->assertSessionHas(
'status',
'nico @ https://git.example.test was removed.',
);
$this->assertDatabaseMissing('user_git_connections', [
'id' => $gitConnection->id,
]);
}
}