Merge branch 'main' into main

This commit is contained in:
Paulo Castellano 2026-05-19 12:25:54 -03:00 committed by GitHub
commit c5209aeb0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 335 additions and 16 deletions

View file

@ -94,15 +94,7 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user->update(['current_workspace_id' => null]);
$account = $user->account;
// Cancel account subscription if exists
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
if ($account) {
$account->subscriptions()->delete();
}
$isOwner = $user->isAccountOwner();
$ownedWorkspaces = Workspace::where('user_id', $user->id)->get();
@ -126,7 +118,12 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user->workspaces()->detach();
if ($account) {
if ($account && $isOwner) {
if ($account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
$account->subscriptions()->delete();
$account->delete();
}
});

View file

@ -57,6 +57,8 @@ public function store(Request $request): RedirectResponse
Auth::login($user);
$request->session()->forget('pending_invite_id');
if ($redirect = $request->input('redirect')) {
if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) {
return redirect($redirect);

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware\App;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EnsureRegistrationEnabled
{
public function handle(Request $request, Closure $next): mixed
{
if (! config('trypost.self_hosted')) {
return $next($request);
}
if ($inviteId = $request->query('invite') ?? $request->session()->get('pending_invite_id')) {
$request->session()->put('pending_invite_id', $inviteId);
return $next($request);
}
throw new NotFoundHttpException;
}
}

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\Api\LoadWorkspaceFromToken;
use App\Http\Middleware\App\EnsureRegistrationEnabled;
use App\Http\Middleware\App\HandleInertiaRequests;
use App\Http\Middleware\App\SetLocale;
use Illuminate\Foundation\Application;
@ -32,6 +33,7 @@
$middleware->alias([
'workspace.token' => LoadWorkspaceFromToken::class,
'registration.enabled' => EnsureRegistrationEnabled::class,
]);
$middleware->preventRequestForgery(except: [

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Actions\User\CreateUser;
use App\Actions\Workspace\CreateWorkspace;
use App\Models\User;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
public function run(): void
{
if (User::query()->exists()) {
$this->command->info('UserSeeder: a user already exists, skipping.');
return;
}
$user = CreateUser::execute([
'name' => 'Admin',
'email' => 'admin@trypost.it',
'password' => 'password',
'email_verified_at' => now(),
'timezone' => 'UTC',
]);
CreateWorkspace::execute($user, ['name' => 'My Workspace']);
$this->command->info('Admin account created — change the password on first login:');
$this->command->line(' email: admin@trypost.it');
$this->command->line(' password: password');
}
}

View file

@ -96,7 +96,7 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
</Link>
</Button>
<Button as-child variant="outline" size="lg" class="w-full">
<Link :href="register({ query: { redirect: inviteUrl, email: invite.email } })">
<Link :href="register({ query: { redirect: inviteUrl, email: invite.email, invite: invite.id } })">
{{ $t('auth.accept_invite.create_account') }}
</Link>
</Button>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Form, Head, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import SocialLogin from '@/components/auth/SocialLogin.vue';
import InputError from '@/components/InputError.vue';
@ -19,6 +20,9 @@ defineProps<{
email?: string | null;
redirect?: string | null;
}>();
const page = usePage();
const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
</script>
<template>
@ -69,7 +73,7 @@ defineProps<{
</Button>
</div>
<div class="text-center text-sm text-muted-foreground">
<div v-if="!isSelfHosted" class="text-center text-sm text-muted-foreground">
{{ $t('auth.login.no_account') }}
<TextLink :href="register()" :tabindex="5">{{ $t('auth.login.sign_up') }}</TextLink>
</div>

View file

@ -18,8 +18,10 @@
Route::get('/invites/{invite}', [AcceptInviteController::class, 'show'])->name('app.invites.show');
Route::middleware(['guest'])->group(function () {
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
Route::middleware('registration.enabled')->group(function () {
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
});
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->name('login.store');

View file

@ -19,7 +19,9 @@
]);
});
test('show invite displays invite details for guest', function () {
test('show invite displays invite details for guest when not self_hosted', function () {
config()->set('trypost.self_hosted', false);
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
@ -39,6 +41,26 @@
);
});
test('show invite displays invite details for guest when self_hosted (page renders, gate happens on /register)', function () {
config()->set('trypost.self_hosted', true);
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'newuser@example.com',
'workspaces' => [$this->workspace->id],
]);
$response = $this->get(route('app.invites.show', $invite));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('auth/AcceptInvite', false)
->has('invite')
->where('invite.id', $invite->id)
);
});
test('show invite displays invite details for authenticated user', function () {
$user = User::factory()->create([
'email' => 'invitee@example.com',

View file

@ -12,6 +12,26 @@
$response->assertOk();
});
test('login page exposes selfHosted as false when SELF_HOSTED is off', function () {
config()->set('trypost.self_hosted', false);
$response = $this->get(route('login'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['selfHosted'])->toBeFalse();
});
test('login page exposes selfHosted as true when SELF_HOSTED is on', function () {
config()->set('trypost.self_hosted', true);
$response = $this->get(route('login'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['selfHosted'])->toBeTrue();
});
test('users can authenticate using the login screen', function () {
$user = User::factory()->create();

View file

@ -4,6 +4,8 @@
use App\Models\User;
beforeEach(fn () => config()->set('trypost.self_hosted', false));
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
@ -111,3 +113,87 @@
expect($user->email_verified_at)->not->toBeNull();
});
test('register page returns 404 when self_hosted and no pending invite in session', function () {
config()->set('trypost.self_hosted', true);
$response = $this->get(route('register'));
$response->assertNotFound();
});
test('register POST returns 404 when self_hosted and no pending invite in session', function () {
config()->set('trypost.self_hosted', true);
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'Password123!',
]);
$response->assertNotFound();
expect(User::where('email', 'test@example.com')->exists())->toBeFalse();
});
test('register page renders when self_hosted but session has pending invite', function () {
config()->set('trypost.self_hosted', true);
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->get(route('register'));
$response->assertOk();
});
test('register page renders when self_hosted with invite query param and persists it to session', function () {
config()->set('trypost.self_hosted', true);
$response = $this->get(route('register', ['invite' => 'invite-xyz']));
$response->assertOk();
$response->assertSessionHas('pending_invite_id', 'invite-xyz');
});
test('signup clears pending_invite_id from session', function () {
config()->set('trypost.self_hosted', true);
$this->withSession(['pending_invite_id' => 'invite-abc'])
->post(route('register.store'), [
'name' => 'Invitee',
'email' => 'invitee@example.com',
'password' => 'Password123!',
]);
expect(session('pending_invite_id'))->toBeNull();
expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
});
test('register POST passes when self_hosted with invite query param even without prior session', function () {
config()->set('trypost.self_hosted', true);
$response = $this->post(route('register.store', ['invite' => 'invite-xyz']), [
'name' => 'Invitee',
'email' => 'invitee@example.com',
'password' => 'Password123!',
]);
$response->assertSessionHasNoErrors();
expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
});
test('register works normally when not self_hosted even with pending invite in session', function () {
config()->set('trypost.self_hosted', false);
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->post(route('register.store'), [
'name' => 'Invitee',
'email' => 'invitee@example.com',
'password' => 'Password123!',
]);
$response->assertSessionHasNoErrors();
expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
// Cleared regardless of mode, since signup consumes the marker.
expect(session('pending_invite_id'))->toBeNull();
});

View file

@ -6,6 +6,8 @@
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(fn () => config()->set('trypost.self_hosted', false));
test('email registration saves utm parameters from the register page query string', function () {
$utms = [
'utm_source' => 'peerlist',

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Database\Seeders\UserSeeder;
test('seeder creates the admin user and a workspace when database is empty', function () {
expect(User::count())->toBe(0);
$this->seed(UserSeeder::class);
$admin = User::where('email', 'admin@trypost.it')->first();
expect($admin)->not->toBeNull();
expect($admin->account_id)->not->toBeNull();
expect($admin->workspaces()->count())->toBe(1);
});
test('seeder is idempotent when a user already exists', function () {
User::factory()->create();
$this->seed(UserSeeder::class);
expect(User::where('email', 'admin@trypost.it')->exists())->toBeFalse();
});

View file

@ -2,6 +2,8 @@
declare(strict_types=1);
beforeEach(fn () => config()->set('trypost.self_hosted', false));
test('login page shares github auth enabled prop as false when disabled', function () {
config(['trypost.github_auth_enabled' => false]);
@ -52,3 +54,16 @@
// Should redirect to login on failure (no OAuth code), not 404
$response->assertRedirect(route('login'));
});
test('register page still shares github auth enabled prop when self_hosted (via pending invite)', function () {
config()->set('trypost.self_hosted', true);
config()->set('trypost.github_auth_enabled', true);
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->get(route('register'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['githubAuthEnabled'])->toBeTrue();
});

View file

@ -2,6 +2,8 @@
declare(strict_types=1);
beforeEach(fn () => config()->set('trypost.self_hosted', false));
test('login page loads when google auth is disabled', function () {
config(['trypost.google_auth_enabled' => false]);
@ -69,3 +71,16 @@
// Should redirect to login on failure (no OAuth code), not 404
$response->assertRedirect(route('login'));
});
test('register page still shares google auth enabled prop when self_hosted (via pending invite)', function () {
config()->set('trypost.self_hosted', true);
config()->set('trypost.google_auth_enabled', true);
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->get(route('register'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['googleAuthEnabled'])->toBeTrue();
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
@ -250,3 +251,65 @@
$response->assertRedirect(route('login'));
});
test('member deleting profile does NOT destroy the shared account', function (bool $selfHosted) {
config()->set('trypost.self_hosted', $selfHosted);
$owner = User::factory()->create();
$member = User::factory()->create(['account_id' => $owner->account_id]);
$workspace = Workspace::factory()->create([
'account_id' => $owner->account_id,
'user_id' => $owner->id,
]);
$owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$this->actingAs($member)->delete(route('app.profile.destroy'), [
'password' => 'password',
]);
expect($member->fresh())->toBeNull();
expect(Account::find($owner->account_id))->not->toBeNull();
expect(Workspace::find($workspace->id))->not->toBeNull();
expect($owner->fresh())->not->toBeNull();
})->with([true, false]);
test('member deleting profile detaches them from workspaces', function (bool $selfHosted) {
config()->set('trypost.self_hosted', $selfHosted);
$owner = User::factory()->create();
$member = User::factory()->create(['account_id' => $owner->account_id]);
$workspace = Workspace::factory()->create([
'account_id' => $owner->account_id,
'user_id' => $owner->id,
]);
$member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$this->actingAs($member)->delete(route('app.profile.destroy'), [
'password' => 'password',
]);
expect($workspace->fresh()->members()->where('users.id', $member->id)->exists())->toBeFalse();
})->with([true, false]);
test('owner deleting profile destroys the account and cascades', function (bool $selfHosted) {
config()->set('trypost.self_hosted', $selfHosted);
$owner = User::factory()->create();
$accountId = $owner->account_id;
$workspace = Workspace::factory()->create([
'account_id' => $accountId,
'user_id' => $owner->id,
]);
$owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$this->actingAs($owner)->delete(route('app.profile.destroy'), [
'password' => 'password',
]);
expect(Account::find($accountId))->toBeNull();
expect(Workspace::find($workspace->id))->toBeNull();
})->with([true, false]);