Implement Gitea user connection foundation #15
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|||||||
|
'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
Eric
commented
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,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
@@ -39,4 +40,11 @@ class User extends Authenticatable
|
|||||||
return $this->belongsToMany(Team::class)
|
return $this->belongsToMany(Team::class)
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function gitConnections(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserGitConnection::class)
|
||||||
|
->orderBy('provider')
|
||||||
|
->orderBy('base_url');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
|
UserGitConnectionSeeder::class,
|
||||||
ProjectSeeder::class,
|
ProjectSeeder::class,
|
||||||
BoardColumnSeeder::class,
|
BoardColumnSeeder::class,
|
||||||
ProjectTicketMetadataSeeder::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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
- 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))
|
- more ticket field types (string, string array, boolean, precision (double))
|
||||||
- abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController)
|
- abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController)
|
||||||
|
- ticket assignee fest einbauen in ticket
|
||||||
- 06
|
- 06
|
||||||
- add Ticket Status
|
- add Ticket Status
|
||||||
- ticketstatus can have one or more board columns
|
- ticketstatus can have one or more board columns
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
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 editProfile } from '@/routes/profile';
|
||||||
import { edit as editSecurity } from '@/routes/security';
|
import { edit as editSecurity } from '@/routes/security';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
@@ -19,6 +20,10 @@ const sidebarNavItems: NavItem[] = [
|
|||||||
title: 'Security',
|
title: 'Security',
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Git connections',
|
||||||
|
href: editGitConnections(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Appearance',
|
title: 'Appearance',
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
@@ -32,7 +37,7 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
<Heading
|
<Heading
|
||||||
title="Settings"
|
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">
|
<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>
|
||||||
Vendored
-1
@@ -19,7 +19,6 @@ declare module '@inertiajs/core' {
|
|||||||
name: string;
|
name: string;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
sidebarOpen: boolean;
|
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;
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import type { TicketModel } from '@/types/models/ticket-model';
|
|||||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
import type { TicketTypeModel } from '@/types/models/ticket-type-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';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
export type AdminUsersPageProps = {
|
export type AdminUsersPageProps = {
|
||||||
@@ -100,3 +101,8 @@ export type ProjectTicketPageProps = {
|
|||||||
ticketTypes: TicketTypeModel[];
|
ticketTypes: TicketTypeModel[];
|
||||||
users: UserModel[];
|
users: UserModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SettingsGitConnectionsPageProps = {
|
||||||
|
gitConnections: UserGitConnectionModel[];
|
||||||
|
status?: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Settings\GitConnectionController;
|
||||||
use App\Http\Controllers\Settings\ProfileController;
|
use App\Http\Controllers\Settings\ProfileController;
|
||||||
use App\Http\Controllers\Settings\SecurityController;
|
use App\Http\Controllers\Settings\SecurityController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -20,5 +21,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->name('user-password.update');
|
->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');
|
Route::inertia('settings/appearance', 'settings/Appearance')->name('appearance.edit');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
würde auch auslagern, weil unschön