Merge branch 'main' into main
This commit is contained in:
commit
c5209aeb0e
16 changed files with 335 additions and 16 deletions
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
27
app/Http/Middleware/App/EnsureRegistrationEnabled.php
Normal file
27
app/Http/Middleware/App/EnsureRegistrationEnabled.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
36
database/seeders/UserSeeder.php
Normal file
36
database/seeders/UserSeeder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
26
tests/Feature/Database/UserSeederTest.php
Normal file
26
tests/Feature/Database/UserSeederTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue