Compare commits

...

8 Commits

Author SHA1 Message Date
Gregor Vostrak
433a6f3770 rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-05-20 22:10:10 +02:00
Gregor Vostrak
0ba20fd24c add banners for invitation accept 2026-05-20 21:42:02 +02:00
Constantin Graf
3267acb161 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-05-20 16:25:17 +02:00
dependabot[bot]
7d9ecd9526 Bump aglipanci/laravel-pint-action from 2.5 to 2.6
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.5 to 2.6.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.5...2.6)

---
updated-dependencies:
- dependency-name: aglipanci/laravel-pint-action
  dependency-version: '2.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:28:48 +02:00
dependabot[bot]
3a17f80f99 Bump codecov/codecov-action from 5.4.3 to 5.5.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.3 to 5.5.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.3...v5.5.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:14:44 +02:00
dependabot[bot]
e29ea2ea42 Bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:13:14 +02:00
dependabot[bot]
fb6e4639ce Bump actions/download-artifact from 4 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:12:01 +02:00
dependabot[bot]
69bc41988a Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:11:19 +02:00
58 changed files with 1219 additions and 259 deletions

View File

@@ -91,7 +91,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -177,7 +177,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,12 +68,12 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -169,7 +169,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -9,9 +9,9 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5
uses: aglipanci/laravel-pint-action@2.6
with:
configPath: "pint.json"

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ yarn-error.log
/data
/config/caddy
/config/composer
/AGENTS.md

View File

@@ -4,18 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
class AddOrganizationMember implements AddsTeamMembers
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
*/
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
$this->validate($organization, $email, $role);
$newOrganizationMember = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->firstOrFail();
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
}
/**
* Validate the add member operation.
*/
protected function validate(Organization $organization, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules())->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
/**
* Get the validation rules for adding a team member.
*
* @return array<string, array<ValidationRule|Rule|string|In>>
*/
protected function rules(): array
{
return [
'email' => [
'required',
'email',
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
})->withMessage(__('We were unable to find a registered user with this email address.')),
],
'role' => [
'required',
'string',
Rule::in([
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
];
}
/**
* Ensure that the user is not already on the team.
*/
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{
return function ($validator) use ($team, $email): void {
$validator->errors()->addIf(
$team->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
};
throw new MovedToApiException;
}
}

View File

@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
*
* @throws AuthorizationException
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function create(User $user, array $input): Organization
{

View File

@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
{
/**
* Delete the given team.
*
* @deprecated Use REST endpoint instead
*/
public function delete(Organization $organization): void
{

View File

@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
* Delete the given user.
*
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function delete(User $user): void
{

View File

@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*
* @deprecated Use REST endpoint instead
*/
public function validate(User $user, Organization $organization): void
{

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class MemberAdded
{
use Dispatchable;
public Member $member;
public Organization $organization;
public User $user;
public function __construct(Member $member, Organization $organization, User $user)
{
$this->member = $member;
$this->organization = $organization;
$this->user = $user;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class MemberAdding
{
use Dispatchable;
public User $user;
public Organization $organization;
public Role $role;
public function __construct(User $user, Organization $organization, Role $role)
{
$this->user = $user;
$this->organization = $organization;
$this->role = $role;
}
}

View File

@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class OrganizationController extends Controller
{
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
return new OrganizationResource($organization, true);
}
/**
* Create organization
*
* @operationId createOrganization
*/
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
{
$user = $this->user();
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
$currency = $ipLookupResponse?->currency;
$organization = $organizationService->createOrganization(
$request->getName(),
$user,
false,
$currency
);
$user->switchTeam($organization);
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);
return new OrganizationResource($organization, true);
}
/**
* Delete organization
*
* @operationId deleteOrganization
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
{
$this->checkPermission($organization, 'organizations:delete');
$deletionService->deleteOrganization($organization);
return response()->json(null, 204);
}
}

View File

@@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Http\Resources\V1\User\UserResource;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
@@ -24,4 +28,29 @@ class UserController extends Controller
return new UserResource($user);
}
/**
* Handles the deletion of a user.
*
* This endpoint is independent of organization.
*
* @operationId deleteUser
*
* @param User $user The user instance to be deleted.
* @param DeletionService $deletionService The service responsible for performing the user deletion.
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/
public function destroy(User $user, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$deletionService->deleteUser($user);
return response()->json(null, 204);
}
}

View File

@@ -4,30 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
/**
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
public function dashboard(): Response
{
$user = $this->user();
$organization = $this->currentOrganization();
$latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard');
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use RuntimeException;
class OrganizationInvitationController extends Controller
{
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
{
$email = strtolower($invitation->email);
$role = Role::tryFrom($invitation->role);
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
throw new RuntimeException('Invalid role');
}
$organization = $invitation->organization;
$invitee = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->first();
// No account yet — finish on registration.
if ($invitee === null) {
if ($invitation->accepted_at === null) {
$invitation->accepted_at = now();
$invitation->save();
}
return redirect(route('register'))
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'info');
}
$alreadyMember = $memberService->isEmailAlreadyMember($organization, $email);
if (! $alreadyMember) {
$memberService->addMember($invitee, $organization, $role);
$invitation->delete();
}
// Logged out — banner on /login.
if (! Auth::check()) {
return redirect(route('login'))
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization. Please log in to access it.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'success');
}
// Logged in — banner on /dashboard.
if ($alreadyMember) {
return redirect(route('dashboard'))
->with('bannerText', __('You are already a member of the :organization organization.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'danger');
}
return redirect(route('dashboard'))
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'success');
}
}

View File

@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),
'bannerText' => fn () => $request->session()->get('bannerText'),
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
],
]);
}

View File

@@ -39,7 +39,6 @@ class ShareInertiaData
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
'flash' => $request->session()->get('flash', []),
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
'hasApiFeatures' => Jetstream::hasApiFeatures(),
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\Member;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded;
class RemovePlaceholder
{
/**
* Handle the event.
*/
public function handle(TeamMemberAdded $event): void
{
$memberService = app(MemberService::class);
$member = Member::query()
->whereBelongsTo($event->team, 'organization')
->whereBelongsTo($event->user, 'user')
->firstOrFail();
$placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', true)
->where('email', '=', $event->user->email);
})
->whereBelongsTo($event->team, 'organization')
->with(['user'])
->get();
foreach ($placeholders as $placeholder) {
/** @var Member $placeholder */
$placeholderUser = $placeholder->user;
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
$placeholder->delete();
$placeholderUser->delete();
}
}
}

View File

@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\URL;
class OrganizationInvitationMail extends Mailable
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
public function build(): self
{
return $this->markdown('emails.organization-invitation', [
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
'invitation' => $this->invitation,
]),
'acceptUrl' => URL::to(URL::signedRoute(
'organization-invitations.accept',
['invitation' => $this->invitation->getKey()],
Carbon::now()->addDays(90),
false
)),
])->subject(__('Organization Invitation'));
}
}

View File

@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at

View File

@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $email
* @property string $role
* @property string $organization_id
* @property Carbon|null $accepted_at
* @property Carbon|null $updated_at
* @property Carbon|null $created_at
* @property-read Organization $organization
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
protected $table = 'organization_invitations';
/**
* The attributes that are mass assignable.
* Get the attributes that should be cast.
*
* @var array<int, string>
* @return array<string, string>
*/
protected $fillable = [
'email',
'role',
];
public function casts(): array
{
return [
'accepted_at' => 'datetime',
];
}
/**
* Get the organization that the invitation belongs to.

View File

@@ -62,18 +62,6 @@ class OrganizationPolicy
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
}
/**
* Determine whether the user can add team members.
*/
public function addTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return true;
}
/**
* Determine whether the user can update team member permissions.
*/

View File

@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Providers;
use App\Listeners\RemovePlaceholder;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Laravel\Jetstream\Events\TeamMemberAdded;
class EventServiceProvider extends ServiceProvider
{
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
Registered::class => [
SendEmailVerificationNotification::class,
],
TeamMemberAdded::class => [
RemovePlaceholder::class,
],
];
/**

View File

@@ -8,9 +8,11 @@ use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Events\InvitingTeamMember;
@@ -21,11 +23,7 @@ class InvitationService
*/
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{
if (Member::query()
->whereBelongsTo($organization, 'organization')
->whereRelation('user', 'email', '=', $email)
->where('role', '!=', Role::Placeholder->value)
->exists()) {
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
@@ -48,4 +46,37 @@ class InvitationService
return $invitation;
}
/**
* @return Collection<int, Organization>
*/
public function processAcceptedInvitations(User $user): Collection
{
$organizations = new Collection;
$invitations = OrganizationInvitation::query()
->where('email', $user->email)
->whereNotNull('accepted_at')
->get();
foreach ($invitations as $invitation) {
$organization = $invitation->organization;
$role = Role::tryFrom($invitation->role);
if ($role === null) {
Log::error('Invalid role in invitation', [
'invitation' => $invitation->getKey(),
'role' => $invitation->role,
]);
continue;
}
app(MemberService::class)->addMember($user, $organization, $role);
$invitation->delete();
$organizations->push($organization);
}
return $organizations;
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\MemberAdded;
use App\Events\MemberAdding;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
@@ -36,7 +38,8 @@ class MemberService
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
{
if (! $asSuperAdmin) {
AddingTeamMember::dispatch($organization, $user);
MemberAdding::dispatch($user, $organization, $role);
AddingTeamMember::dispatch($organization, $user); // Legacy event
}
$member = new Member;
@@ -49,14 +52,37 @@ class MemberService
$user->currentOrganization()->associate($organization);
$user->save();
});
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
if (! $asSuperAdmin) {
TeamMemberAdded::dispatch($organization, $user);
MemberAdded::dispatch($member, $organization, $user);
TeamMemberAdded::dispatch($organization, $user); // Legacy event
}
return $member;
}
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
{
$placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($user): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', true)
->where('email', '=', $user->email);
})
->whereBelongsTo($organization, 'organization')
->with(['user'])
->get();
foreach ($placeholders as $placeholder) {
/** @var Member $placeholder */
$placeholderUser = $placeholder->user;
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
$placeholder->delete();
$placeholderUser->delete();
}
}
/**
* @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException
@@ -209,4 +235,13 @@ class MemberService
$this->userService->makeSureUserHasCurrentOrganization($user);
}
}
public function isEmailAlreadyMember(Organization $organization, string $email): bool
{
return Member::query()
->whereBelongsTo($organization, 'organization')
->whereRelation('user', 'email', '=', $email)
->where('role', '!=', Role::Placeholder->value)
->exists();
}
}

View File

@@ -38,7 +38,7 @@ class UserService
): User {
$user = new User;
$user->name = $name;
$user->email = $email;
$user->email = strtolower($email);
$user->password = Hash::make($password);
$user->timezone = $timezone;
$user->week_start = $weekStart;
@@ -47,19 +47,22 @@ class UserService
}
$user->save();
$organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name),
$user,
true,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
$user->ownedTeams()->save($organization);
if ($organizations->isEmpty()) {
$organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name),
$user,
true,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
$user->ownedTeams()->save($organization);
}
return $user;
}

View File

@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
'email' => $this->faker->unique()->safeEmail(),
'role' => Role::Employee->value,
'organization_id' => Organization::factory(),
'accepted_at' => null,
];
}
public function role(Role $role): self
{
return $this->state(fn (array $attributes) => [
'role' => $role->value,
]);
}
public function accepted(): self
{
return $this->state(fn (array $attributes): array => [
'accepted_at' => $this->faker->dateTime(),
]);
}
public function forOrganization(Organization $organization): self
{
return $this->state(fn (array $attributes) => [

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
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::table('organization_invitations', function (Blueprint $table): void {
$table->timestamp('accepted_at')->nullable()->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organization_invitations', function (Blueprint $table): void {
$table->dropColumn('accepted_at');
});
}
};

View File

@@ -0,0 +1,158 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import { getInvitationAcceptUrl } from './utils/mailpit';
import { registerUser } from './utils/members';
// Invitation acceptance flows touch mail delivery + redirects.
test.describe.configure({ timeout: 45000 });
test.describe('invitation accept banners', () => {
test('shows success banner on dashboard when a logged-in registered user accepts an invitation', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `success+${memberId}@invite-banner.test`;
// Invitee already has an account and is logged in.
const invitee = await registerUser(browser, 'Banner Success', memberEmail);
// Owner sends the invitation.
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
await page.getByLabel('Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Invitee clicks the email link.
const acceptUrl = await getInvitationAcceptUrl(invitee.page.request, memberEmail);
await invitee.page.goto(acceptUrl);
await invitee.page.waitForURL(/\/dashboard$/);
const banner = invitee.page.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(
/Great! You have accepted the invitation to join the .* organization\./
);
await invitee.close();
});
test('shows info banner on login screen when a registered-but-logged-out invitee clicks the accept link', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `loggedout+${memberId}@invite-banner.test`;
// Invitee has an account, but the context that clicks the link has no session.
const invitee = await registerUser(browser, 'Banner Loggedout', memberEmail);
await invitee.close();
// Owner sends the invitation.
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
await page.getByLabel('Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Open the accept link in a fresh browser context (no session).
const context = await browser.newContext();
const inviteePage = await context.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/login$/);
const banner = inviteePage.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(
/Great! You have accepted the invitation to join the .* organization\. Please log in to access it\./
);
// Logging in lands the invitee on the dashboard — they were already added silently
// by the accept controller, so the inviter's members list shows them.
await inviteePage.getByLabel('Email').fill(memberEmail);
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
await inviteePage.getByRole('button', { name: 'Log in' }).click();
await inviteePage.waitForURL(/\/dashboard/);
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Loggedout' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
await context.close();
});
test('shows info banner on register screen when an unregistered email accepts an invitation, then auto-joins on registration', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `info+${memberId}@invite-banner.test`;
// Owner invites an email that has no account yet.
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
await page.getByLabel('Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Open the accept link in a fresh browser context (no session).
const context = await browser.newContext();
const inviteePage = await context.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/register$/);
const banner = inviteePage.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(
/Please create an account to finish joining the .* organization\./
);
// Complete registration — the invitee should auto-join the inviter's org
// (no fresh personal organization is created on top).
await inviteePage.getByLabel('Name').fill('Banner Info');
await inviteePage.getByLabel('Email').fill(memberEmail);
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
await inviteePage.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
await inviteePage.getByLabel('I agree to the Terms of').click();
await inviteePage.getByRole('button', { name: 'Register' }).click();
await inviteePage.waitForURL(/\/dashboard/);
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Info' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
await context.close();
});
});

View File

@@ -46,7 +46,9 @@ export async function getInvitationAcceptUrl(
expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID);
const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/);
const acceptUrlMatch = message.HTML.match(
/href="([^"]*(?:organization-invitations|team-invitations)[^"]*)"/
);
expect(acceptUrlMatch).toBeTruthy();
return acceptUrlMatch![1].replace(/&amp;/g, '&');

View File

@@ -1,36 +1,38 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { ref } from 'vue';
import { usePage } from '@inertiajs/vue3';
const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const;
type BannerStyle = (typeof ALLOWED_STYLES)[number];
const page = usePage<{
jetstream: {
flash: {
banner: string;
bannerStyle: string;
};
flash: {
bannerText?: string;
bannerStyle?: string;
};
}>();
const show = ref(true);
const style = ref('success');
const message = ref('');
const rawStyle = page.props.flash?.bannerStyle;
const message = page.props.flash?.bannerText ?? '';
const style: BannerStyle = (ALLOWED_STYLES as readonly string[]).includes(rawStyle ?? '')
? (rawStyle as BannerStyle)
: 'success';
watchEffect(async () => {
style.value = page.props.jetstream.flash?.bannerStyle || 'success';
message.value = page.props.jetstream.flash?.banner || '';
show.value = true;
});
const show = ref(true);
</script>
<template>
<div>
<div v-if="show && message" class="bg-secondary border-b border-border-secondary">
<div
v-if="show && message"
data-testid="banner"
class="bg-secondary border-b border-border-secondary">
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
<div class="flex items-center justify-between flex-wrap">
<div class="w-0 flex-1 flex items-center min-w-0">
<span class="flex">
<svg
v-if="style == 'success'"
v-if="style === 'success'"
class="h-6 w-6 text-text-secondary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -44,7 +46,7 @@ watchEffect(async () => {
</svg>
<svg
v-if="style == 'danger'"
v-if="style === 'danger'"
class="h-5 w-5 text-text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -56,6 +58,20 @@ watchEffect(async () => {
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<svg
v-if="style === 'info'"
class="h-6 w-6 text-text-secondary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</span>
<p class="ms-3 font-medium text-sm text-text-primary truncate">

View File

@@ -2,6 +2,7 @@
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
import Banner from '@/Components/Banner.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
@@ -36,6 +37,8 @@ const page = usePage<{
<template>
<Head title="Log in" />
<Banner />
<AuthenticationCard>
<template #logo>
<AuthenticationCardLogo />

View File

@@ -2,6 +2,7 @@
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
import Banner from '@/Components/Banner.vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
@@ -41,6 +42,8 @@ const page = usePage<{
<template>
<Head title="Register" />
<Banner />
<AuthenticationCard>
<template #logo>
<AuthenticationCardLogo />

View File

@@ -1,7 +1,8 @@
@component('mail::message')
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
{{ __('You can create a new API token in your profile:') }}

View File

@@ -1,8 +1,7 @@
@component('mail::message')
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
{{ __('You can create a new API token in your profile:') }}

View File

@@ -1,20 +1,6 @@
@component('mail::message')
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}
@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}
@component('mail::button', ['url' => route('register')])
{{ __('Create Account') }}
@endcomponent
{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }}
@else
{{ __('You may accept this invitation by clicking the button below:') }}
@endif
@component('mail::button', ['url' => $acceptUrl])
{{ __('Accept Invitation') }}
@endcomponent

View File

@@ -42,8 +42,10 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
])->group(static function (): void {
// Organization routes
Route::name('organizations.')->group(static function (): void {
Route::post('/organizations', [OrganizationController::class, 'store'])->name('store');
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
Route::delete('/organizations/{organization}', [OrganizationController::class, 'destroy'])->name('destroy');
});
// Member routes
@@ -59,6 +61,7 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
// User routes
Route::name('users.')->group(static function (): void {
Route::get('/users/me', [UserController::class, 'me'])->name('me');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy');
});
// Api token routes

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\OrganizationInvitationController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Jetstream\Jetstream;
@@ -83,3 +84,10 @@ Route::middleware([
})->name('import');
});
Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
->middleware(['signed'])
->name('team-invitations.accept'); // Note: legacy naming
Route::get('/organization-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
->middleware(['signed:relative'])
->name('organization-invitations.accept');

View File

@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
Mail::fake();
$placeholder = User::factory()->placeholder()->create();
$owner = User::factory()->withPersonalOrganization()->create();
$placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
$placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);

View File

@@ -8,21 +8,21 @@ use App\Enums\Role;
use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\Member;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use App\Service\IpLookup\IpLookupResponseDto;
use App\Service\IpLookup\IpLookupServiceContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Tests\TestCase;
use Tests\TestCaseWithDatabase;
use TiMacDonald\Log\LogEntry;
class RegistrationTest extends TestCase
class RegistrationTest extends TestCaseWithDatabase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
if (! Features::enabled(Features::registration())) {
@@ -346,4 +346,82 @@ class RegistrationTest extends TestCase
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
public function test_registration_does_not_create_private_organization_if_invite_was_accepted_for_the_email_with_the_registration_email(): void
{
// Arrange
$user = $this->createUserWithPermission();
$organizationInvitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'test@example.com',
]);
// Act
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$newUser = User::where('email', 'test@example.com')->first();
$this->assertNotNull($newUser);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'test@example.com',
]);
$organizations = $newUser->organizations;
$this->assertCount(1, $organizations);
$this->assertSame($user->organization->id, $organizations->first()->id);
}
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
{
// Arrange
$user = $this->createUserWithPermission();
$organizationInvitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->accepted()
->create([
'email' => 'test@example.com',
'role' => 'invalid-role',
]);
// Act
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
// Assert
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
Log::assertLogged(fn (LogEntry $log) => $log->level === 'error'
&& $log->message === 'Invalid role in invitation'
&& $log->context === [
'invitation' => $organizationInvitation->getKey(),
'role' => 'invalid-role',
]);
$newUser = User::where('email', 'test@example.com')->firstOrFail();
$this->assertDatabaseHas(OrganizationInvitation::class, [
'id' => $organizationInvitation->getKey(),
'email' => 'test@example.com',
'role' => 'invalid-role',
]);
$this->assertDatabaseMissing(Member::class, [
'organization_id' => $user->organization->getKey(),
'user_id' => $newUser->getKey(),
]);
$organizations = $newUser->organizations;
$this->assertCount(1, $organizations);
$this->assertNotSame($user->organization->id, $organizations->first()->id);
}
}

View File

@@ -38,7 +38,7 @@ abstract class TestCase extends BaseTestCase
protected function mockPrivateStorage(): void
{
Storage::fake(config('filesystems.default'));
Storage::fake(config('filesystems.private'));
}
protected function mockPublicStorage(): void

View File

@@ -5,9 +5,15 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Controllers\Api\V1\OrganizationController;
use App\Models\Member;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\IpLookup\IpLookupResponseDto;
use App\Service\IpLookup\IpLookupServiceContract;
use Illuminate\Support\Facades\Event;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\UsesClass;
@@ -93,6 +99,121 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.billable_rate', null);
}
public function test_store_endpoint_creates_new_organization(): void
{
// Arrange
$data = $this->createUserWithPermission();
$organizationFake = Organization::factory()->make();
Event::fake([
AfterCreateOrganization::class,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.organizations.store'), [
'name' => $organizationFake->name,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', $organizationFake->name)
->where('data.is_personal', false)
->where('data.currency', config('app.localization.default_currency'))
->etc()
);
/** @var Organization $newOrganization */
$newOrganization = Organization::query()->where('name', $organizationFake->name)->firstOrFail();
$this->assertTrue($newOrganization->owner->is($data->user));
$this->assertSame($newOrganization->getKey(), $data->user->fresh()->current_team_id);
$this->assertDatabaseHas(Member::class, [
'organization_id' => $newOrganization->getKey(),
'user_id' => $data->user->getKey(),
'role' => Role::Owner->value,
]);
Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool {
return $event->organization->is($newOrganization);
});
}
public function test_store_endpoint_uses_ip_lookup_currency_for_new_organization(): void
{
// Arrange
$data = $this->createUserWithPermission();
$this->mock(IpLookupServiceContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('lookup')
->once()
->andReturn(new IpLookupResponseDto(null, null, 'USD'));
});
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.organizations.store'), [
'name' => 'Test Organization',
]);
// Assert
$response->assertStatus(201);
$response->assertJsonPath('data.currency', 'USD');
$this->assertDatabaseHas(Organization::class, [
'name' => 'Test Organization',
'currency' => 'USD',
'user_id' => $data->user->getKey(),
'personal_team' => false,
]);
}
public function test_store_endpoint_fails_if_name_is_missing(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.organizations.store'), []);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
$this->assertDatabaseCount(Organization::class, 1);
}
public function test_store_endpoint_fails_if_name_is_not_a_string(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.organizations.store'), [
'name' => ['Test Organization'],
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
$this->assertDatabaseCount(Organization::class, 1);
}
public function test_store_endpoint_fails_if_name_is_too_long(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.organizations.store'), [
'name' => str_repeat('a', 256),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
$this->assertDatabaseCount(Organization::class, 1);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void
{
// Arrange
@@ -260,4 +381,51 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
'billable_rate' => $organizationFake->billable_rate,
]);
}
public function test_delete_endpoint_if_user_does_not_have_permission(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_delete_endpoint_fails_with_not_found_if_id_is_not_uuid(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']));
// Assert
$response->assertNotFound();
}
public function test_delete_endpoint_can_delete_organization(): void
{
// Arrange
$this->mockPrivateStorage();
$data = $this->createUserWithPermission([
'organizations:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
// Assert
$response->assertNoContent();
$this->assertDatabaseMissing(Organization::class, [
'id' => $data->organization->getKey(),
]);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\User;
use Laravel\Passport\Passport;
class UserEndpointTest extends ApiEndpointTestAbstract
@@ -40,4 +41,57 @@ class UserEndpointTest extends ApiEndpointTestAbstract
],
]);
}
public function test_delete_fails_if_given_user_is_not_the_authenticated_user(): void
{
// Arrange
$data = $this->createUserWithPermission();
$otherData = $this->createUserWithPermission();
Passport::actingAs($otherData->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertForbidden();
}
public function test_delete_fails_if_not_authenticated(): void
{
// Arrange
$data = $this->createUserWithPermission();
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertUnauthorized();
}
public function test_delete_fails_if_user_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid'));
// Assert
$response->assertNotFound();
}
public function test_delete_removes_user(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertNoContent();
$this->assertDatabaseMissing(User::class, ['id' => $data->user->getKey()]);
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Enums\Role;
use App\Http\Controllers\Web\OrganizationInvitationController;
use App\Models\Member;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Support\Facades\URL;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(OrganizationInvitationController::class)]
#[CoversClass(MemberService::class)]
class OrganizationInvitationEndpointTest extends EndpointTestAbstract
{
public function test_legacy_url_still_works(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create();
// Act
$acceptUrl = URL::temporarySignedRoute(
'team-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()]
);
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
public function test_can_accept_invitation_without_an_account_with_the_email_address_and_redirects_to_registration(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create();
// Act
$acceptUrl = URL::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
public function test_can_accept_invitation_with_an_account_with_the_email_address_and_redirects_to_dashboard(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user2 = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $user2->user->email,
]);
$this->actingAs($user2->user);
// Act
$acceptUrl = URL::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('dashboard'));
$response->assertSessionHas('bannerText', 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'success');
$this->assertDatabaseHas(Member::class, [
'user_id' => $user2->user->getKey(),
'organization_id' => $user->organization->getKey(),
'role' => Role::Employee->value,
]);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $invitation->getKey(),
]);
}
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitee = User::factory()->create([
'email' => 'invitee@example.com',
]);
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $invitee->email,
]);
// Act (no actingAs — request is unauthenticated)
$acceptUrl = URL::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('login'));
$response->assertSessionHas('bannerText', 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization. Please log in to access it.');
$response->assertSessionHas('bannerStyle', 'success');
// Member was added silently — invitation is consumed.
$this->assertDatabaseHas(Member::class, [
'user_id' => $invitee->getKey(),
'organization_id' => $user->organization->getKey(),
'role' => Role::Employee->value,
]);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $invitation->getKey(),
]);
}
public function test_fails_if_user_is_already_member_of_the_organization(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user2 = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $user2->user->email,
]);
Member::factory()->forOrganization($user->organization)->forUser($user2->user)->create();
$this->actingAs($user2->user);
// Act
$acceptUrl = URL::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('dashboard'));
$response->assertSessionHas('bannerText', 'You are already a member of the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'danger');
}
public function test_accepting_invitation_with_existing_account_migrates_data_of_placeholder_users_with_same_email_to_new_member(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user2 = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $user2->user->email,
]);
$placeholder1 = User::factory()->placeholder()->create([
'email' => $user2->user->email,
]);
$placeholder1Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder1)->role(Role::Placeholder)->create();
$placeholder2 = User::factory()->placeholder()->create([
'email' => $user2->user->email,
]);
$placeholder2Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder2)->role(Role::Placeholder)->create();
$this->actingAs($user2->user);
// Act
$acceptUrl = URL::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('dashboard'));
$response->assertSessionHas('bannerText', 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'success');
$this->assertDatabaseHas(Member::class, [
'user_id' => $user2->user->getKey(),
'organization_id' => $user->organization->getKey(),
'role' => Role::Employee->value,
]);
$this->assertDatabaseMissing(User::class, [
'id' => $placeholder1->getKey(),
]);
$this->assertDatabaseMissing(User::class, [
'id' => $placeholder2->getKey(),
]);
$this->assertDatabaseMissing(Member::class, [
'id' => $placeholder1Member->getKey(),
]);
$this->assertDatabaseMissing(Member::class, [
'id' => $placeholder2Member->getKey(),
]);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $invitation->getKey(),
]);
}
public function test_fails_with_invalid_signature(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create();
// Act
$response = $this->get(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()]).
'?invalid'
);
// Assert
$response->assertForbidden();
}
}

View File

@@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
$rendered = $mail->render();
// Assert
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
}
}

View File

@@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
$rendered = $mail->render();
// Assert
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
}
}