Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f34b608
Updated invitation flow, Moved jetstream function to REST endpoints; …
korridor Feb 25, 2026
983e6c3
add banners for invitation accept
Onatcer May 20, 2026
64c5da5
rephrase logged out user invite accept message to clarify that the
Onatcer May 20, 2026
09827d3
Migrate permission away from Jetstream; Moved update user to REST API
korridor May 21, 2026
5779494
Add migration to lower case the user emails
korridor May 21, 2026
4e26c8a
Add more tests
korridor May 22, 2026
d42e3ff
Updated composer dependencies
korridor May 22, 2026
77e4d76
add photo delete logic to user update endpoint
Onatcer May 26, 2026
34a1a89
add 1MB photo upload limit
Onatcer May 26, 2026
72bddfb
update email address change info to use session based banners
Onatcer May 26, 2026
5a41c35
add profile page e2e tests
Onatcer May 26, 2026
a880ccb
update npm dependencies
Onatcer May 26, 2026
07cf3f7
add user endpoint tests for idempotence email update, unauthenticated
Onatcer May 26, 2026
1f832a2
update ui package dependencies; update lucide imports
Onatcer May 26, 2026
dcd2134
add pending email to UserResource and update openapi client
Onatcer May 26, 2026
67dcf77
fix e2e selectors to adapt to reka-ui change;
Onatcer May 27, 2026
ca84316
show null billable rate as empty not as 0 to avoid confusion
Onatcer May 27, 2026
4c25869
use api routes for profile information updates
Onatcer May 27, 2026
117c3c4
move user delete to api endpoint
Onatcer May 27, 2026
82ad8ee
Add reset pending email endpoint to user controller
korridor May 28, 2026
dc082b2
Replaces all Jetstream model trait functions and relations
korridor May 29, 2026
d2b6be1
add pending email cancel button
Onatcer May 29, 2026
f32ec59
move banners on login and register cards into the cards
Onatcer May 29, 2026
7035d5f
remove jetstream inertia properties; remove unused ApiTokenManager;
Onatcer Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ yarn-error.log
/data
/config/caddy
/config/composer
/AGENTS.md
16 changes: 12 additions & 4 deletions app/Actions/Fortify/UpdateUserProfileInformation.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
namespace App\Actions\Fortify;

use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
Expand All @@ -24,6 +27,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
public function update(User $user, array $input): void
{
if (isset($input['email']) && is_string($input['email'])) {
$input['email'] = Str::lower($input['email']);
}

Validator::make($input, [
'name' => [
'required',
Expand Down Expand Up @@ -58,16 +65,17 @@ public function update(User $user, array $input): void
$user->updateProfilePhoto($input['photo']);
}

if ($input['email'] !== $user->email) {
$email = Str::lower((string) $input['email']);

if ($email !== Str::lower($user->email)) {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
'pending_email' => $email,
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();

$user->sendEmailVerificationNotification();
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
} else {
$user->forceFill([
'name' => $input['name'],
Expand Down
77 changes: 2 additions & 75 deletions app/Actions/Jetstream/AddOrganizationMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,9 @@

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
Expand All @@ -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;
}
}
7 changes: 4 additions & 3 deletions app/Actions/Jetstream/CreateOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
Expand All @@ -25,6 +26,8 @@ class CreateOrganization implements CreatesTeams
*
* @throws AuthorizationException
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function create(User $user, array $input): Organization
{
Expand All @@ -48,10 +51,8 @@ public function create(User $user, array $input): Organization
$currency
);

$user->switchTeam($organization);
app(UserService::class)->switchCurrentOrganization($user, $organization);

// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);

return $organization;
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
{
/**
* Delete the given team.
*
* @deprecated Use REST endpoint instead
*/
public function delete(Organization $organization): void
{
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/ValidateOrganizationDeletion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 1 addition & 1 deletion app/Console/Commands/Admin/UserCreateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function handle(): int
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
$organization = $user->ownedOrganizations->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}
Expand Down
28 changes: 28 additions & 0 deletions app/Events/MemberAdded.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
28 changes: 28 additions & 0 deletions app/Events/MemberAdding.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Api;

class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{
public const string KEY = 'user_resend_email_verification_no_pending_email';
}
2 changes: 1 addition & 1 deletion app/Filament/Resources/FailedJobResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public static function form(Form $form): Form
TextInput::make('queue')->disabled(),

// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
Textarea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Resources/OrganizationInvitationResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public static function form(Form $form): Form
->required(),
Select::make('role')
->options(Role::class),
Forms\Components\Select::make('organization_id')
Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
Expand Down
16 changes: 8 additions & 8 deletions app/Filament/Resources/OrganizationResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public static function form(Form $form): Form
->label('Is personal?')
->hiddenOn(['create'])
->required(),
Forms\Components\Select::make('user_id')
Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email'])
Expand All @@ -76,7 +76,7 @@ public static function form(Form $form): Form
Select::make('time_format')
->options(TimeFormat::toSelectArray())
->required(),
Forms\Components\Select::make('currency')
Select::make('currency')
->label('Currency')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
Expand Down Expand Up @@ -114,22 +114,22 @@ public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('personal_team')
->boolean()
->label('Is personal?')
->sortable(),
Tables\Columns\TextColumn::make('owner.email')
TextColumn::make('owner.email')
->sortable(),
Tables\Columns\TextColumn::make('currency'),
TextColumn::make('currency'),
TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Expand Down Expand Up @@ -223,7 +223,7 @@ public static function table(Table $table): Table

return $select;
}),
Forms\Components\Select::make('timezone')
Select::make('timezone')
->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static string $relationship = 'organizationInvitations';

protected static ?string $title = 'Invitations';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ public function table(Table $table): Table
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'),
TextColumn::make('name'),
TextColumn::make('role'),
TextColumn::make('billable_rate')
->money($organization->currency, divideBy: 100),
])
->headerActions([
Tables\Actions\AttachAction::make()
AttachAction::make()
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Expand Down
Loading
Loading