Compare commits

...

15 Commits

Author SHA1 Message Date
Constantin Graf
5b756be058 Updated composer dependencies 2026-05-22 16:18:02 +02:00
Constantin Graf
dc70eb7130 Add more tests 2026-05-22 16:06:51 +02:00
Constantin Graf
c2a8eac65f Add migration to lower case the user emails 2026-05-21 23:22:27 +02:00
Constantin Graf
28ecfc63a3 Migrate permission away from Jetstream; Moved update user to REST API 2026-05-21 23:22:09 +02:00
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
Gregor Vostrak
f7663b1c8b Clarify out of scope items for vulnerability reports
Added out of scope section for vulnerability reporting.
2026-05-18 19:21:32 +02:00
Gregor Vostrak
793bd11dcf remove member, invitation, and owner email disclosure from Teams/Show inertia props
The Teams/Show Inertia page serialized members, pending invitations, and the
owner email into props using only a belongsToTeam authorization gate, while
the corresponding API endpoints correctly enforced members:view and
invitations:view. The serialized data was unused by the live UI (the
TeamMemberManager partial that referenced it was orphaned), so dropping the
fields removes the disclosure surface without functional impact. The owner
card retains name and photo.
2026-05-18 19:04:57 +02:00
Gregor Vostrak
77a62afd69 add alphabetic sorting to multiselect dropdowns 2026-04-29 18:32:05 +02:00
112 changed files with 4954 additions and 2216 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -3,3 +3,18 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)! If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -24,6 +27,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/ */
public function update(User $user, array $input): void 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, [ Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
@@ -58,16 +65,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateProfilePhoto($input['photo']); $user->updateProfilePhoto($input['photo']);
} }
if ($input['email'] !== $user->email) { $email = Str::lower((string) $input['email']);
if ($email !== Str::lower($user->email)) {
$user->forceFill([ $user->forceFill([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'pending_email' => $email,
'email_verified_at' => null,
'timezone' => $input['timezone'], 'timezone' => $input['timezone'],
'week_start' => $input['week_start'], 'week_start' => $input['week_start'],
])->save(); ])->save();
$user->sendEmailVerificationNotification(); Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
} else { } else {
$user->forceFill([ $user->forceFill([
'name' => $input['name'], 'name' => $input['name'],

View File

@@ -4,18 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Exceptions\MovedToApiException;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; 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; use Laravel\Jetstream\Contracts\AddsTeamMembers;
class AddOrganizationMember implements 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 public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{ {
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring throw new MovedToApiException;
$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.')
);
};
} }
} }

View File

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

View File

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

View File

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

View File

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

@@ -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';
}

View File

@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
TextInput::make('queue')->disabled(), TextInput::make('queue')->disabled(),
// make text a little bit smaller because often a complete Stack Trace is shown: // 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), PrettyJsonField::make('payload')->disabled()->columnSpan(4),
])->columns(4); ])->columns(4);
} }

View File

@@ -39,7 +39,7 @@ class OrganizationInvitationResource extends Resource
->required(), ->required(),
Select::make('role') Select::make('role')
->options(Role::class), ->options(Role::class),
Forms\Components\Select::make('organization_id') Select::make('organization_id')
->label('Organization') ->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name') ->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])

View File

@@ -55,7 +55,7 @@ class OrganizationResource extends Resource
->label('Is personal?') ->label('Is personal?')
->hiddenOn(['create']) ->hiddenOn(['create'])
->required(), ->required(),
Forms\Components\Select::make('user_id') Select::make('user_id')
->label('Owner') ->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email') ->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email']) ->searchable(['name', 'email'])
@@ -76,7 +76,7 @@ class OrganizationResource extends Resource
Select::make('time_format') Select::make('time_format')
->options(TimeFormat::toSelectArray()) ->options(TimeFormat::toSelectArray())
->required(), ->required(),
Forms\Components\Select::make('currency') Select::make('currency')
->label('Currency') ->label('Currency')
->options(function (): array { ->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
@@ -114,22 +114,22 @@ class OrganizationResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('personal_team') Tables\Columns\IconColumn::make('personal_team')
->boolean() ->boolean()
->label('Is personal?') ->label('Is personal?')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('owner.email') TextColumn::make('owner.email')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('currency'), TextColumn::make('currency'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100), ->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
@@ -223,7 +223,7 @@ class OrganizationResource extends Resource
return $select; return $select;
}), }),
Forms\Components\Select::make('timezone') Select::make('timezone')
->label('Timezone') ->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable() ->searchable()

View File

@@ -49,13 +49,13 @@ class UsersRelationManager extends RelationManager
return $table return $table
->recordTitleAttribute('name') ->recordTitleAttribute('name')
->columns([ ->columns([
Tables\Columns\TextColumn::make('name'), TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'), TextColumn::make('role'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money($organization->currency, divideBy: 100), ->money($organization->currency, divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make() AttachAction::make()
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})") ->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
->form(fn (AttachAction $action): array => [ ->form(fn (AttachAction $action): array => [
$action->getRecordSelect(), $action->getRecordSelect(),

View File

@@ -63,11 +63,11 @@ class ReportResource extends Resource
return $record->getRawOriginal('properties'); return $record->getRawOriginal('properties');
}) })
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('created_at') DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
@@ -78,10 +78,10 @@ class ReportResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('description') TextColumn::make('description')
->searchable() ->searchable()
->sortable(), ->sortable(),
ToggleColumn::make('is_public') ToggleColumn::make('is_public')
@@ -90,10 +90,10 @@ class ReportResource extends Resource
TextColumn::make('organization.name') TextColumn::make('organization.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),

View File

@@ -93,11 +93,11 @@ class TimeEntryResource extends Resource
($record->end?->toDateTimeString('minute') ?? '...').')'; ($record->end?->toDateTimeString('minute') ?? '...').')';
}) })
->label('Time'), ->label('Time'),
Tables\Columns\TextColumn::make('organization.name') TextColumn::make('organization.name')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([

View File

@@ -47,17 +47,17 @@ class UserResource extends Resource
return $form return $form
->columns(1) ->columns(1)
->schema([ ->schema([
Forms\Components\TextInput::make('id') TextInput::make('id')
->label('ID') ->label('ID')
->disabled() ->disabled()
->visibleOn(['update', 'show']) ->visibleOn(['update', 'show'])
->readOnly() ->readOnly()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('email') TextInput::make('email')
->label('Email') ->label('Email')
->required() ->required()
->rules($record?->is_placeholder ? [] : [ ->rules($record?->is_placeholder ? [] : [

View File

@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
/** /**
* List all api token of the currently authenticated user * List all api token of the currently authenticated user
* *
* This endpoint is independent of organization. * This endpoint is independent of the organization.
* *
* @operationId getApiTokens * @operationId getApiTokens
* *

View File

@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; 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\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource; use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class OrganizationController extends Controller class OrganizationController extends Controller
{ {
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
return new OrganizationResource($organization, true); 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

@@ -59,7 +59,7 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
{ {
if (! $organization->prevent_overlapping_time_entries) { if (! $organization->prevent_overlapping_time_entries) {
return; return;

View File

@@ -4,15 +4,26 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Http\Requests\V1\User\UserUpdateRequest;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User;
use App\Service\DeletionService;
use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller class UserController extends Controller
{ {
/** /**
* Get the current user * Get the current user
* *
* This endpoint is independent of organization. * This endpoint is independent of the organization.
* *
* @operationId getMe * @operationId getMe
* *
@@ -24,4 +35,114 @@ class UserController extends Controller
return new UserResource($user); return new UserResource($user);
} }
/**
* Update the current user
*
* This endpoint is independent of the organization.
*
* @operationId updateUser
*/
public function update(User $user, UserUpdateRequest $request): UserResource
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($request->getPhoto() !== null) {
$photo = Base64File::decode($request->getPhoto());
assert($photo !== null);
$extension = Base64File::extension($photo['mime_type']);
assert($extension !== null);
$previousPhotoPath = $user->profile_photo_path;
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
Storage::disk($photoDisk)->put($photoPath, $photo['data'], 'public');
$user->profile_photo_path = $photoPath;
if ($previousPhotoPath !== null) {
Storage::disk($photoDisk)->delete($previousPhotoPath);
}
}
$emailToVerify = null;
$email = $request->getEmail();
if ($email !== null && $email !== Str::lower($user->email)) {
$emailToVerify = $email;
$user->pending_email = $email;
}
if ($request->getName() !== null) {
$user->name = $request->getName();
}
if ($request->getTimezone() !== null) {
$user->timezone = $request->getTimezone();
}
if ($request->getWeekStart() !== null) {
$user->week_start = $request->getWeekStart();
}
$user->save();
if ($emailToVerify !== null) {
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
}
return new UserResource($user);
}
/**
* Resend the pending email update verification email.
*
* This endpoint is independent of the organization.
*
* @operationId resendUserEmailVerification
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
*/
public function resendEmailVerification(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($user->pending_email === null) {
throw new UserResendEmailVerificationNoPendingEmailApiException;
}
Mail::to($user->pending_email)
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
return response()->json(null, 204);
}
/**
* Handles the deletion of a user.
*
* This endpoint is independent of the 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

@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
/** /**
* Get the memberships of the current user * Get the memberships of the current user
* *
* This endpoint is independent of organization. * This endpoint is independent of the organization.
* *
* @operationId getMyMemberships * @operationId getMyMemberships
* *

View File

@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
/** /**
* Get the active time entry of the current user * Get the active time entry of the current user
* *
* This endpoint is independent of organization. * This endpoint is independent of the organization.
* *
* @operationId getMyActiveTimeEntry * @operationId getMyActiveTimeEntry
*/ */

View File

@@ -4,30 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; 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\Inertia;
use Inertia\Response; use Inertia\Response;
class DashboardController extends Controller class DashboardController extends Controller
{ {
/** public function dashboard(): Response
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): 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'); 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

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class UserController extends Controller
{
public function verifyEmailChange(Request $request, User $user): RedirectResponse
{
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
abort(403);
}
$email = $request->query('email');
if (! is_string($email)) {
abort(403);
}
$email = Str::lower($email);
if ($user->pending_email !== $email) {
abort(403);
}
$emailAlreadyInUse = User::query()
->where('email', '=', $email)
->where('is_placeholder', '=', false)
->whereKeyNot($user->getKey())
->exists();
if ($emailAlreadyInUse) {
return redirect(route('dashboard', [
'bannerStyle' => 'danger',
'bannerText' => __('The email address is already in use.'),
]));
}
$user->email = $email;
$user->pending_email = null;
$user->email_verified_at = Carbon::now();
$user->save();
return redirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => __('Your email address has been updated successfully.'),
]));
}
}

View File

@@ -4,9 +4,37 @@ declare(strict_types=1);
namespace App\Http; namespace App\Http;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\CheckOrganizationBlocked; use App\Http\Middleware\CheckOrganizationBlocked;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureEmailIsVerified;
use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Laravel\Passport\Http\Middleware\CreateFreshApiToken;
class Kernel extends HttpKernel class Kernel extends HttpKernel
{ {
@@ -18,13 +46,13 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string> * @var array<int, class-string|string>
*/ */
protected $middleware = [ protected $middleware = [
\App\Http\Middleware\ForceHttps::class, ForceHttps::class,
\App\Http\Middleware\TrustProxies::class, TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class, HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class, TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ConvertEmptyStringsToNull::class,
]; ];
/** /**
@@ -34,21 +62,21 @@ class Kernel extends HttpKernel
*/ */
protected $middlewareGroups = [ protected $middlewareGroups = [
'web' => [ 'web' => [
\App\Http\Middleware\EncryptCookies::class, EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class, HandleInertiaRequests::class,
\App\Http\Middleware\ShareInertiaData::class, ShareInertiaData::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, AddLinkHeadersForPreloadedAssets::class,
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, CreateFreshApiToken::class,
], ],
'api' => [ 'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api', ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class, SubstituteBindings::class,
ForceJsonResponse::class, ForceJsonResponse::class,
], ],
@@ -64,17 +92,17 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string> * @var array<string, class-string|string>
*/ */
protected $middlewareAliases = [ protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class, 'auth' => Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'auth.session' => AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'cache.headers' => SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class, 'can' => Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'guest' => RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'password.confirm' => RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, 'precognitive' => HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class, 'signed' => ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => ThrottleRequests::class,
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, 'verified' => EnsureEmailIsVerified::class,
'check-organization-blocked' => CheckOrganizationBlocked::class, 'check-organization-blocked' => CheckOrganizationBlocked::class,
]; ];
} }

View File

@@ -14,7 +14,7 @@ class ForceHttps
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next, string ...$guards): Response public function handle(Request $request, Closure $next, string ...$guards): Response
{ {

View File

@@ -13,7 +13,7 @@ class ForceJsonResponse
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next, string ...$guards): Response public function handle(Request $request, Closure $next, string ...$guards): Response
{ {

View File

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

View File

@@ -15,7 +15,7 @@ class RedirectIfAuthenticated
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next, string ...$guards): Response public function handle(Request $request, Closure $next, string ...$guards): Response
{ {

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -19,7 +20,7 @@ class MemberMergeIntoRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>> * @return array<string, array<string|ValidationRule|Rule>>
*/ */
public function rules(): array public function rules(): array
{ {

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule;
/**
* @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|Rule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\User;
use App\Rules\Base64ImageRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property User $user User from model binding
*/
class UserUpdateRequest extends BaseFormRequest
{
protected function prepareForValidation(): void
{
if ($this->has('email') && is_string($this->input('email'))) {
$this->merge([
'email' => Str::lower((string) $this->input('email')),
]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'string',
'max:255',
],
'email' => [
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
new Base64ImageRule,
],
'timezone' => [
'timezone:all',
],
'week_start' => [
Rule::enum(Weekday::class),
],
];
}
public function getName(): ?string
{
return $this->has('name') ? (string) $this->input('name') : null;
}
public function getEmail(): ?string
{
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
}
public function getTimezone(): ?string
{
return $this->has('timezone') ? (string) $this->input('timezone') : null;
}
public function getWeekStart(): ?Weekday
{
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
}
public function getPhoto(): ?string
{
return $this->has('photo') ? (string) $this->input('photo') : null;
}
}

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

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
class VerifyUpdatedEmailMail extends Mailable
{
use Queueable, SerializesModels;
public User $user;
public string $email;
public function __construct(User $user, string $email)
{
$this->user = $user;
$this->email = Str::lower($email);
}
/**
* Build the message.
*/
public function build(): self
{
$verificationUrl = URL::temporarySignedRoute(
'users.verify-email-change',
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
[
'user' => $this->user->getKey(),
'email' => $this->email,
],
false
);
return $this->markdown('emails.verify-updated-email', [
'verificationUrl' => URL::to($verificationUrl),
])->subject(__('Verify Email Address'));
}
}

View File

@@ -24,6 +24,7 @@ use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated; use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Team;
use Laravel\Jetstream\Team as JetstreamTeam; use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -36,6 +37,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $user_id * @property string $user_id
* @property bool $employees_can_see_billable_rates * @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks * @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -176,7 +178,7 @@ class Organization extends JetstreamTeam implements AuditableContract
* *
* @param array<string> $columns * @param array<string> $columns
*/ */
public function findOrFail(string $id, array $columns = ['*']): \Laravel\Jetstream\Team public function findOrFail(string $id, array $columns = ['*']): Team
{ {
if (! Str::isUuid($id)) { if (! Str::isUuid($id)) {
throw (new ModelNotFoundException)->setModel( throw (new ModelNotFoundException)->setModel(

View File

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

View File

@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $id * @property string $id
* @property string $name * @property string $name
* @property string $email * @property string $email
* @property string|null $pending_email
* @property Carbon|null $email_verified_at * @property Carbon|null $email_verified_at
* @property string|null $password * @property string|null $password
* @property string|null $two_factor_secret * @property string|null $two_factor_secret
@@ -105,6 +106,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
protected $casts = [ protected $casts = [
'name' => 'string', 'name' => 'string',
'email' => 'string', 'email' => 'string',
'pending_email' => 'string',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'is_placeholder' => 'boolean', 'is_placeholder' => 'boolean',

View File

@@ -62,18 +62,6 @@ class OrganizationPolicy
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); 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. * Determine whether the user can update team member permissions.
*/ */

View File

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

View File

@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse; use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Responses\LoginResponse; use Laravel\Fortify\Http\Responses\LoginResponse;
@@ -41,6 +42,14 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class); Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'),
]);
});
Fortify::authenticateUsing(function (Request $request): ?User { Fortify::authenticateUsing(function (Request $request): ?User {
/** @var User|null $user */ /** @var User|null $user */
$user = User::query() $user = User::query()

View File

@@ -13,20 +13,18 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization; use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion; use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole; use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion; use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -60,13 +58,6 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useTeamInvitationModel(OrganizationInvitation::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class); app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'),
]);
});
Gate::define('removeTeamMember', function (User $user, Organization $team) { Gate::define('removeTeamMember', function (User $user, Organization $team) {
return false; return false;
}); });
@@ -79,205 +70,10 @@ class JetstreamServiceProvider extends ServiceProvider
{ {
Jetstream::defaultApiTokenPermissions([]); Jetstream::defaultApiTokenPermissions([]);
Jetstream::role(Role::Owner->value, 'Owner', [ foreach (PermissionStore::roleDefinitions() as $role => $definition) {
'charts:view:own', Jetstream::role($role, $definition['name'], $definition['permissions'])
'charts:view:all', ->description($definition['description']);
'projects:view', }
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'organizations:update',
'organizations:delete',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'organizations:update',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:delete',
'members:update',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'invitations:view',
'members:view',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [
'charts:view:own',
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
Jetstream::inertia() Jetstream::inertia()
->whenRendering( ->whenRendering(
@@ -304,28 +100,8 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [ 'owner' => [
'id' => $owner->getKey(), 'id' => $owner->getKey(),
'name' => $owner->name, 'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url, 'profile_photo_url' => $owner->profile_photo_url,
], ],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
], ],
'currencies' => array_map(function (Currency $currency): string { 'currencies' => array_map(function (Currency $currency): string {
return $currency->getName(); return $currency->getName();

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Support\Base64File;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class Base64ImageRule implements ValidationRule
{
private const array ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
];
/**
* Run the validation rule.
*
* @param Closure(string): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail(__('validation.string'));
return;
}
$file = Base64File::decode($value);
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
$fail(__('validation.mimes', ['values' => 'jpg, png']));
}
}
}

View File

@@ -8,9 +8,11 @@ use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException; use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail; use App\Mail\OrganizationInvitationMail;
use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Events\InvitingTeamMember; use Laravel\Jetstream\Events\InvitingTeamMember;
@@ -21,11 +23,7 @@ class InvitationService
*/ */
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{ {
if (Member::query() if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
->whereBelongsTo($organization, 'organization')
->whereRelation('user', 'email', '=', $email)
->where('role', '!=', Role::Placeholder->value)
->exists()) {
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
} }
@@ -48,4 +46,37 @@ class InvitationService
return $invitation; 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; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberAdded;
use App\Events\MemberAdding;
use App\Events\MemberRemoved; use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
@@ -36,7 +38,8 @@ class MemberService
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
{ {
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
AddingTeamMember::dispatch($organization, $user); MemberAdding::dispatch($user, $organization, $role);
AddingTeamMember::dispatch($organization, $user); // Legacy event
} }
$member = new Member; $member = new Member;
@@ -49,14 +52,37 @@ class MemberService
$user->currentOrganization()->associate($organization); $user->currentOrganization()->associate($organization);
$user->save(); $user->save();
}); });
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
TeamMemberAdded::dispatch($organization, $user); MemberAdded::dispatch($member, $organization, $user);
TeamMemberAdded::dispatch($organization, $user); // Legacy event
} }
return $member; 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 CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException * @throws EntityStillInUseApiException
@@ -209,4 +235,13 @@ class MemberService
$this->userService->makeSureUserHasCurrentOrganization($user); $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

@@ -4,14 +4,238 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Role;
class PermissionStore class PermissionStore
{ {
/**
* @var array<string, array{name: string, permissions: array<string>, description: string}>
*/
private const array ROLE_DEFINITIONS = [
'owner' => [
'name' => 'Owner',
'permissions' => [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'organizations:update',
'organizations:delete',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
],
'description' => 'Owner users can perform any action. There is only one owner per organization.',
],
'admin' => [
'name' => 'Administrator',
'permissions' => [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'organizations:update',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:delete',
'members:update',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
],
'description' => 'Administrator users can perform any action, except accessing the billing dashboard.',
],
'manager' => [
'name' => 'Manager',
'permissions' => [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'invitations:view',
'members:view',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
],
'description' => 'Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).',
],
'employee' => [
'name' => 'Employee',
'permissions' => [
'charts:view:own',
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
],
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
],
'placeholder' => [
'name' => 'Placeholder',
'permissions' => [],
'description' => 'Placeholders are used for importing data. They cannot log in and have no permissions.',
],
];
/**
* @var array<string, array<string>>
*/
private static array $customRolePermissions = [];
/** /**
* @var array<string, array<string>> * @var array<string, array<string>>
*/ */
@@ -22,6 +246,37 @@ class PermissionStore
$this->permissionCache = []; $this->permissionCache = [];
} }
/**
* @return array<string, array{name: string, permissions: array<string>, description: string}>
*/
public static function roleDefinitions(): array
{
return self::ROLE_DEFINITIONS;
}
/**
* @param array<string> $permissions
*/
public static function registerCustomRole(string $role, array $permissions): void
{
self::$customRolePermissions[$role] = $permissions;
}
public static function resetCustomRoles(): void
{
self::$customRolePermissions = [];
}
/**
* @return array<string>
*/
public static function permissionsForRole(string $role): array
{
return self::$customRolePermissions[$role]
?? self::ROLE_DEFINITIONS[$role]['permissions']
?? [];
}
public function has(Organization $organization, string $permission): bool public function has(Organization $organization, string $permission): bool
{ {
/** @var User|null $user */ /** @var User|null $user */
@@ -68,14 +323,11 @@ class PermissionStore
return []; return [];
} }
/** @var Role|null $roleObj */ $permissions = self::permissionsForRole($role);
$roleObj = Jetstream::findRole($role);
$permissions = $roleObj->permissions ?? [];
// If the organization allows employees to manage tasks and the user is an employee, // If the organization allows employees to manage tasks and the user is an employee,
// add the task management permissions for accessible projects // add the task management permissions for accessible projects
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) { if ($role === Role::Employee->value && $organization->employees_can_manage_tasks) {
$permissions = array_merge($permissions, [ $permissions = array_merge($permissions, [
'tasks:create', 'tasks:create',
'tasks:update', 'tasks:update',

View File

@@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\File; use Illuminate\Http\File;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use League\Csv\CannotInsertRecord;
use League\Csv\Exception;
use League\Csv\UnavailableStream;
use League\Csv\Writer; use League\Csv\Writer;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
@@ -58,9 +61,9 @@ abstract class CsvExport
abstract public function mapRow(Model $model): array; abstract public function mapRow(Model $model): array;
/** /**
* @throws \League\Csv\CannotInsertRecord * @throws CannotInsertRecord
* @throws \League\Csv\Exception * @throws Exception
* @throws \League\Csv\UnavailableStream * @throws UnavailableStream
*/ */
public function export(): void public function export(): void
{ {
@@ -72,6 +75,7 @@ abstract class CsvExport
$writer->insertOne(static::HEADER); $writer->insertOne(static::HEADER);
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void { $this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
/** @var T $model */
foreach ($models as $model) { foreach ($models as $model) {
$data = $this->mapRow($model); $data = $this->mapRow($model);
$row = $this->convertRow($data); $row = $this->convertRow($data);

View File

@@ -38,7 +38,7 @@ class UserService
): User { ): User {
$user = new User; $user = new User;
$user->name = $name; $user->name = $name;
$user->email = $email; $user->email = strtolower($email);
$user->password = Hash::make($password); $user->password = Hash::make($password);
$user->timezone = $timezone; $user->timezone = $timezone;
$user->week_start = $weekStart; $user->week_start = $weekStart;
@@ -47,6 +47,9 @@ class UserService
} }
$user->save(); $user->save();
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) {
$organization = app(OrganizationService::class)->createOrganization( $organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name), $this->getOrganizationNameForUserName($user->name),
$user, $user,
@@ -58,8 +61,8 @@ class UserService
$intervalFormat, $intervalFormat,
$timeFormat, $timeFormat,
); );
$user->ownedTeams()->save($organization); $user->ownedTeams()->save($organization);
}
return $user; return $user;
} }

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Symfony\Component\Mime\MimeTypes;
class Base64File
{
/**
* @return array{data: string, mime_type: string}|null
*/
public static function decode(string $value): ?array
{
if (str_contains($value, ',')) {
[, $value] = explode(',', $value, 2);
}
$value = preg_replace('/\s+/', '', $value);
if ($value === null || $value === '') {
return null;
}
$decoded = base64_decode($value, true);
if ($decoded === false) {
return null;
}
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
if ($mimeType === false) {
return null;
}
return [
'data' => $decoded,
'mime_type' => $mimeType,
];
}
public static function extension(string $mimeType): ?string
{
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
}
}

View File

@@ -1,6 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Exceptions\Handler;
use App\Http\Kernel;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Application;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -13,7 +17,7 @@ declare(strict_types=1);
| |
*/ */
$app = new Illuminate\Foundation\Application( $app = new Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
); );
@@ -30,7 +34,7 @@ $app = new Illuminate\Foundation\Application(
$app->singleton( $app->singleton(
Illuminate\Contracts\Http\Kernel::class, Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class Kernel::class
); );
$app->singleton( $app->singleton(
@@ -39,8 +43,8 @@ $app->singleton(
); );
$app->singleton( $app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class, ExceptionHandler::class,
App\Exceptions\Handler::class Handler::class
); );
/* /*

3383
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,13 @@ use App\Enums\DateFormat;
use App\Enums\IntervalFormat; use App\Enums\IntervalFormat;
use App\Enums\NumberFormat; use App\Enums\NumberFormat;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use App\Providers\AppServiceProvider;
use App\Providers\AuthServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\Filament\AdminPanelProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\JetstreamServiceProvider;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider; use Nwidart\Modules\LaravelModulesServiceProvider;
@@ -190,13 +197,13 @@ return [
/* /*
* Application Service Providers... * Application Service Providers...
*/ */
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
App\Providers\AuthServiceProvider::class, AuthServiceProvider::class,
App\Providers\EventServiceProvider::class, EventServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class, AdminPanelProvider::class,
App\Providers\RouteServiceProvider::class, RouteServiceProvider::class,
App\Providers\FortifyServiceProvider::class, FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class, JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider // Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class, LaravelModulesServiceProvider::class,
])->toArray(), ])->toArray(),

View File

@@ -1,6 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Extensions\Auditing\Resolvers\CustomIpAddressResolver;
use OwenIt\Auditing\Models\Audit;
use OwenIt\Auditing\Resolvers\UrlResolver;
use OwenIt\Auditing\Resolvers\UserAgentResolver;
use OwenIt\Auditing\Resolvers\UserResolver;
return [ return [
@@ -15,7 +20,7 @@ return [
| |
*/ */
'implementation' => OwenIt\Auditing\Models\Audit::class, 'implementation' => Audit::class,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -32,7 +37,7 @@ return [
'web', 'web',
'api', 'api',
], ],
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class, 'resolver' => UserResolver::class,
], ],
/* /*
@@ -44,9 +49,9 @@ return [
| |
*/ */
'resolvers' => [ 'resolvers' => [
'ip_address' => App\Extensions\Auditing\Resolvers\CustomIpAddressResolver::class, 'ip_address' => CustomIpAddressResolver::class,
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class, 'user_agent' => UserAgentResolver::class,
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class, 'url' => UrlResolver::class,
], ],
/* /*

View File

@@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Models\User;
return [ return [
@@ -69,7 +70,7 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => App\Models\User::class, 'model' => User::class,
], ],
], ],

View File

@@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use Maatwebsite\Excel\DefaultValueBinder;
use Maatwebsite\Excel\Excel; use Maatwebsite\Excel\Excel;
use PhpOffice\PhpSpreadsheet\Reader\Csv; use PhpOffice\PhpSpreadsheet\Reader\Csv;
@@ -226,7 +227,7 @@ return [
| |
*/ */
'value_binder' => [ 'value_binder' => [
'default' => Maatwebsite\Excel\DefaultValueBinder::class, 'default' => DefaultValueBinder::class,
], ],
'cache' => [ 'cache' => [

View File

@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
'email' => $this->faker->unique()->safeEmail(), 'email' => $this->faker->unique()->safeEmail(),
'role' => Role::Employee->value, 'role' => Role::Employee->value,
'organization_id' => Organization::factory(), '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 public function forOrganization(Organization $organization): self
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [

View File

@@ -9,6 +9,7 @@ use App\Enums\Weekday;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Http\FileHelpers;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -90,7 +91,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static public function withProfilePicture(): static
{ {
$profilePhoto = $this->faker->image(null, 500, 500); $profilePhoto = $this->faker->image(null, 500, 500);
/** @see \Illuminate\Http\FileHelpers::hashName */ /** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png'; $path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto); Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);

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,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('users', function (Blueprint $table): void {
$table->string('pending_email')->nullable()->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('pending_email');
});
}
};

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* @throws RuntimeException
*/
public function up(): void
{
$duplicateEmails = DB::table('users')
->selectRaw('LOWER(email) as normalized_email')
->selectRaw('COUNT(*) as user_count')
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
->where('is_placeholder', false)
->groupByRaw('LOWER(email)')
->havingRaw('COUNT(*) > 1')
->orderBy('normalized_email')
->get();
if ($duplicateEmails->isNotEmpty()) {
$duplicateEmailMessage = $duplicateEmails
->take(20)
->map(fn (stdClass $duplicateEmail): string => sprintf(
'%s (%d users: %s)',
$duplicateEmail->normalized_email,
$duplicateEmail->user_count,
$duplicateEmail->users,
))
->implode('; ');
$remainingDuplicateCount = $duplicateEmails->count() - 20;
$remainingDuplicateMessage = $remainingDuplicateCount > 0
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
: '';
throw new RuntimeException(
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
$duplicateEmailMessage.
$remainingDuplicateMessage
);
}
DB::table('users')
->whereRaw('email <> LOWER(email)')
->update([
'email' => DB::raw('LOWER(email)'),
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

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); expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID); 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(); expect(acceptUrlMatch).toBeTruthy();
return acceptUrlMatch![1].replace(/&amp;/g, '&'); return acceptUrlMatch![1].replace(/&amp;/g, '&');

View File

@@ -23,6 +23,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Service\Export\ExportException; use App\Service\Export\ExportException;
return [ return [
@@ -49,6 +50,7 @@ return [
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead', ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.', InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.', OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
UserResendEmailVerificationNoPendingEmailApiException::KEY => 'Resend email not possible, no pending email.',
], ],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
]; ];

2
package-lock.json generated
View File

@@ -7413,7 +7413,7 @@
}, },
"resources/js/packages/ui": { "resources/js/packages/ui": {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.17", "version": "0.0.21",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@types/chroma-js": "^3.1.0", "@types/chroma-js": "^3.1.0",

View File

@@ -14,5 +14,4 @@ parameters:
noEnvCallsOutsideOfConfig: true noEnvCallsOutsideOfConfig: true
ignoreErrors: ignoreErrors:
- '# is not subtype of native type Illuminate\\Database\\Eloquent\\Builder#'
- '# is not subtype of native type Illuminate\\Database\\Eloquent\\Relations\\Relation#' - '# is not subtype of native type Illuminate\\Database\\Eloquent\\Relations\\Relation#'

View File

@@ -1,36 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect } from 'vue'; import { ref } from 'vue';
import { usePage } from '@inertiajs/vue3'; import { usePage } from '@inertiajs/vue3';
const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const;
type BannerStyle = (typeof ALLOWED_STYLES)[number];
const page = usePage<{ const page = usePage<{
jetstream: {
flash: { flash: {
banner: string; bannerText?: string;
bannerStyle: string; bannerStyle?: string;
};
}; };
}>(); }>();
const show = ref(true); const rawStyle = page.props.flash?.bannerStyle;
const style = ref('success'); const message = page.props.flash?.bannerText ?? '';
const message = ref(''); const style: BannerStyle = (ALLOWED_STYLES as readonly string[]).includes(rawStyle ?? '')
? (rawStyle as BannerStyle)
: 'success';
watchEffect(async () => { const show = ref(true);
style.value = page.props.jetstream.flash?.bannerStyle || 'success';
message.value = page.props.jetstream.flash?.banner || '';
show.value = true;
});
</script> </script>
<template> <template>
<div> <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="mx-auto py-1 px-3 sm:px-6 lg:px-8">
<div class="flex items-center justify-between flex-wrap"> <div class="flex items-center justify-between flex-wrap">
<div class="w-0 flex-1 flex items-center min-w-0"> <div class="w-0 flex-1 flex items-center min-w-0">
<span class="flex"> <span class="flex">
<svg <svg
v-if="style == 'success'" v-if="style === 'success'"
class="h-6 w-6 text-text-secondary" class="h-6 w-6 text-text-secondary"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@@ -44,7 +46,7 @@ watchEffect(async () => {
</svg> </svg>
<svg <svg
v-if="style == 'danger'" v-if="style === 'danger'"
class="h-5 w-5 text-text-primary" class="h-5 w-5 text-text-primary"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@@ -56,6 +58,20 @@ watchEffect(async () => {
stroke-linejoin="round" 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" /> 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>
<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> </span>
<p class="ms-3 font-medium text-sm text-text-primary truncate"> <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 { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import AuthenticationCard from '@/Components/AuthenticationCard.vue'; import AuthenticationCard from '@/Components/AuthenticationCard.vue';
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue'; import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
import Banner from '@/Components/Banner.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field'; import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
@@ -36,6 +37,8 @@ const page = usePage<{
<template> <template>
<Head title="Log in" /> <Head title="Log in" />
<Banner />
<AuthenticationCard> <AuthenticationCard>
<template #logo> <template #logo>
<AuthenticationCardLogo /> <AuthenticationCardLogo />

View File

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

View File

@@ -1,448 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization, OrganizationInvitation, User } from '@/types/models';
import type { Membership, Permissions, Role } from '@/types/jetstream';
import { filterRoles } from '@/utils/roles';
type UserWithMembership = User & { membership: Membership };
const props = defineProps<{
team: Organization;
availableRoles: Role[];
userPermissions: Permissions;
}>();
const users = computed(() => {
return props.team.users as Array<UserWithMembership>;
});
const page = usePage<{
auth: {
user: User;
};
}>();
const addTeamMemberForm = useForm({
email: '',
role: null as string | null,
});
const updateRoleForm = useForm({
role: null as string | null,
});
const leaveTeamForm = useForm({});
const removeTeamMemberForm = useForm({});
const currentlyManagingRole = ref(false);
const managingRoleFor = ref<User | null>(null);
const confirmingLeavingTeam = ref(false);
const teamMemberBeingRemoved = ref<User | null>(null);
const addTeamMember = () => {
addTeamMemberForm.post(route('team-members.store', props.team.id), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => addTeamMemberForm.reset(),
});
};
const cancelTeamInvitation = (invitation: OrganizationInvitation) => {
router.delete(route('team-invitations.destroy', invitation.id), {
preserveScroll: true,
});
};
const manageRole = (teamMember: User & { membership: Membership }) => {
managingRoleFor.value = teamMember;
updateRoleForm.role = teamMember.membership.role;
currentlyManagingRole.value = true;
};
const updateRole = () => {
updateRoleForm.put(
route('team-members.update', {
team: props.team.id,
user: managingRoleFor.value?.id,
}),
{
preserveScroll: true,
onSuccess: () => (currentlyManagingRole.value = false),
}
);
};
const confirmLeavingTeam = () => {
confirmingLeavingTeam.value = true;
};
const leaveTeam = () => {
leaveTeamForm.delete(route('team-members.destroy', [props.team.id, page.props.auth.user.id]));
};
const confirmTeamMemberRemoval = (teamMember: User) => {
teamMemberBeingRemoved.value = teamMember;
};
const removeTeamMember = () => {
removeTeamMemberForm.delete(
route('team-members.destroy', {
team: props.team.id,
user: teamMemberBeingRemoved.value?.id,
}),
{
errorBag: 'removeTeamMember',
preserveScroll: true,
preserveState: true,
onSuccess: () => (teamMemberBeingRemoved.value = null),
}
);
};
const displayableRole = (role: string) => {
return props.availableRoles.find((r) => r.key === role)?.name;
};
</script>
<template>
<div>
<div v-if="userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Add Organization Member -->
<FormSection @submitted="addTeamMember">
<template #title> Add Organization Member</template>
<template #description>
Add a new member to your organization, allowing them to collaborate with you.
</template>
<template #form>
<div class="col-span-6">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you would like to add to
this organization.
</div>
</div>
<!-- Member Email -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="email">Email</FieldLabel>
<TextInput
id="email"
v-model="addTeamMemberForm.email"
type="email"
class="block w-full" />
<FieldError v-if="addTeamMemberForm.errors.email">{{
addTeamMemberForm.errors.email
}}</FieldError>
</Field>
<!-- Role -->
<div v-if="availableRoles.length > 0" class="col-span-6 lg:col-span-4">
<FieldLabel for="roles">Role</FieldLabel>
<FieldError v-if="addTeamMemberForm.errors.role">{{
addTeamMemberForm.errors.role
}}</FieldError>
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in filterRoles(availableRoles)"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
:class="{
'opacity-50':
addTeamMemberForm.role &&
addTeamMemberForm.role != role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-text-primary"
:class="{
'font-semibold': addTeamMemberForm.role == role.key,
}">
{{ role.name }}
</div>
<svg
v-if="addTeamMemberForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #actions>
<ActionMessage :on="addTeamMemberForm.recentlySuccessful" class="me-3">
Added.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': addTeamMemberForm.processing }"
:disabled="addTeamMemberForm.processing">
Add
</PrimaryButton>
</template>
</FormSection>
</div>
<div v-if="team.team_invitations.length > 0 && userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Organization Member Invitations -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Pending Organization Invitations</template>
<template #description>
These people have been invited to your organization and have been sent an
invitation email. They may join the organization by accepting the email
invitation.
</template>
<!-- Pending Organization Member Invitation List -->
<template #content>
<div class="space-y-6">
<div
v-for="invitation in team.team_invitations"
:key="invitation.id"
class="flex items-center justify-between">
<div class="text-muted">
{{ invitation.email }}
</div>
<div class="flex items-center">
<!-- Cancel Organization Invitation -->
<button
v-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500 focus:outline-none"
@click="cancelTeamInvitation(invitation)">
Cancel
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<div v-if="users.length > 0">
<SectionBorder />
<!-- Manage Organization Members -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Organization Members</template>
<template #description>
All of the people that are part of this organization.
</template>
<!-- Organization Member List -->
<template #content>
<div class="space-y-6">
<div
v-for="user in users"
:key="user.id"
class="flex items-center justify-between">
<div class="flex items-center">
<img
class="w-8 h-8 rounded-full object-cover"
:src="user.profile_photo_url"
:alt="user.name" />
<div class="ms-4 text-text-primary">
{{ user.name }}
</div>
</div>
<div class="flex items-center">
<!-- Manage Organization Member Role -->
<button
v-if="
userPermissions.canUpdateTeamMembers &&
availableRoles.length
"
class="ms-2 text-sm text-gray-400 underline"
@click="manageRole(user)">
{{ displayableRole(user.membership.role) }}
</button>
<div
v-else-if="availableRoles.length"
class="ms-2 text-sm text-gray-400">
{{ displayableRole(user.membership.role) }}
</div>
<!-- Leave Organization -->
<button
v-if="page.props.auth.user.id === user.id"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmLeavingTeam">
Leave
</button>
<!-- Remove Organization Member -->
<button
v-else-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmTeamMemberRemoval(user)">
Remove
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<!-- Role Management Modal -->
<DialogModal :show="currentlyManagingRole" @close="currentlyManagingRole = false">
<template #title> Manage Role</template>
<template #content>
<div v-if="managingRoleFor">
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in availableRoles"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i !== Object.keys(availableRoles).length - 1,
}"
@click="updateRoleForm.role = role.key">
<div
:class="{
'opacity-50':
updateRoleForm.role && updateRoleForm.role !== role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-muted"
:class="{
'font-semibold': updateRoleForm.role === role.key,
}">
{{ role.name }}
</div>
<svg
v-if="updateRoleForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="currentlyManagingRole = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': updateRoleForm.processing }"
:disabled="updateRoleForm.processing"
@click="updateRole">
Save
</PrimaryButton>
</template>
</DialogModal>
<!-- Leave Organization Confirmation Modal -->
<ConfirmationModal :show="confirmingLeavingTeam" @close="confirmingLeavingTeam = false">
<template #title> Leave Organization</template>
<template #content> Are you sure you would like to leave this organization? </template>
<template #footer>
<SecondaryButton @click="confirmingLeavingTeam = false"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': leaveTeamForm.processing }"
:disabled="leaveTeamForm.processing"
@click="leaveTeam">
Leave
</DangerButton>
</template>
</ConfirmationModal>
<!-- Remove Organization Member Confirmation Modal -->
<ConfirmationModal :show="!!teamMemberBeingRemoved" @close="teamMemberBeingRemoved = null">
<template #title> Remove Organization Member</template>
<template #content>
Are you sure you would like to remove this person from the organization?
</template>
<template #footer>
<SecondaryButton @click="teamMemberBeingRemoved = null"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': removeTeamMemberForm.processing }"
:disabled="removeTeamMemberForm.processing"
@click="removeTeamMember">
Remove
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -51,9 +51,6 @@ const updateTeamName = () => {
<div class="text-text-primary"> <div class="text-text-primary">
{{ team.owner.name }} {{ team.owner.name }}
</div> </div>
<div class="text-text-secondary text-sm">
{{ team.owner.email }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -35,7 +35,8 @@ watch(open, (isOpen) => {
sortedItems.value = [...props.items].sort((a, b) => { sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1; const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1; const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
return aSelected - bSelected; if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
}); });
} }
}); });

View File

@@ -22,9 +22,7 @@ export interface Organization {
currency: string; currency: string;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
owner: User; owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
users: User[];
team_invitations: OrganizationInvitation[];
} }
export interface OrganizationInvitation { export interface OrganizationInvitation {
id: string; id: string;

View File

@@ -29,9 +29,7 @@ export interface Organization {
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
// relations // relations
owner: User; owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
users: User[];
team_invitations: OrganizationInvitation[];
} }
export interface OrganizationInvitation { export interface OrganizationInvitation {

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,7 +1,8 @@
@component('mail::message') @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:') }} {{ __('You can create a new API token in your profile:') }}

View File

@@ -1,8 +1,7 @@
@component('mail::message') @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:') }} {{ __('You can create a new API token in your profile:') }}

View File

@@ -1,20 +1,6 @@
@component('mail::message') @component('mail::message')
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }} {{ __('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]) @component('mail::button', ['url' => $acceptUrl])
{{ __('Accept Invitation') }} {{ __('Accept Invitation') }}
@endcomponent @endcomponent

View File

@@ -0,0 +1,9 @@
@component('mail::message')
{{ __('Please verify your new email address for your solidtime account.') }}
@component('mail::button', ['url' => $verificationUrl])
{{ __('Verify Email Address') }}
@endcomponent
{{ __('If you did not request this change, you may discard this email.') }}
@endcomponent

View File

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

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\OrganizationInvitationController;
use App\Http\Controllers\Web\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -83,3 +85,14 @@ Route::middleware([
})->name('import'); })->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');
Route::get('/users/{user}/verify-email-change', [UserController::class, 'verifyEmailChange'])
->middleware(['auth:web', config('jetstream.auth_session'), 'signed:relative'])
->name('users.verify-email-change');

View File

@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
Mail::fake(); Mail::fake();
$placeholder = User::factory()->placeholder()->create(); $placeholder = User::factory()->placeholder()->create();
$owner = User::factory()->withPersonalOrganization()->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); $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);

View File

@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User; use App\Models\User;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Tests\TestCase; use Tests\TestCase;
class ProfileInformationTest extends TestCase class ProfileInformationTest extends TestCase
@@ -30,7 +33,9 @@ class ProfileInformationTest extends TestCase
public function test_profile_information_can_be_updated(): void public function test_profile_information_can_be_updated(): void
{ {
// Arrange // Arrange
$user = User::factory()->create(); $user = User::factory()->create([
'email' => 'test@example.com',
]);
$timezone = app(TimezoneService::class)->getTimezones()[0]; $timezone = app(TimezoneService::class)->getTimezones()[0];
$this->actingAs($user); $this->actingAs($user);
@@ -50,4 +55,120 @@ class ProfileInformationTest extends TestCase
$this->assertEquals($timezone, $user->timezone); $this->assertEquals($timezone, $user->timezone);
$this->assertEquals(Weekday::Sunday, $user->week_start); $this->assertEquals(Weekday::Sunday, $user->week_start);
} }
public function test_email_update_keeps_current_email_verified_until_new_email_is_verified(): void
{
// Arrange
Mail::fake();
$user = User::factory()->create([
'email' => 'current@example.com',
'email_verified_at' => now(),
]);
$timezone = app(TimezoneService::class)->getTimezones()[0];
$this->actingAs($user);
// Act
$response = $this->put('/user/profile-information', [
'name' => 'Test Name',
'email' => 'New.Email@Example.com',
'timezone' => $timezone,
'week_start' => Weekday::Sunday->value,
]);
// Assert
$response->assertValid(errorBag: 'updateProfileInformation');
$user = $user->fresh();
$this->assertEquals('current@example.com', $user->email);
$this->assertEquals('new.email@example.com', $user->pending_email);
$this->assertNotNull($user->email_verified_at);
Mail::assertSent(VerifyUpdatedEmailMail::class, function (VerifyUpdatedEmailMail $mail): bool {
return $mail->hasTo('new.email@example.com') && $mail->email === 'new.email@example.com';
});
}
public function test_pending_email_can_be_verified(): void
{
// Arrange
$user = User::factory()->create([
'email' => 'current@example.com',
'pending_email' => 'new.email@example.com',
]);
$this->actingAs($user);
$verificationUrl = URL::temporarySignedRoute(
'users.verify-email-change',
now()->addMinutes(60),
[
'user' => $user->getKey(),
'email' => 'new.email@example.com',
],
false
);
// Act
$response = $this->get($verificationUrl);
// Assert
$response->assertRedirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => 'Your email address has been updated successfully.',
]));
$user = $user->fresh();
$this->assertEquals('new.email@example.com', $user->email);
$this->assertNull($user->pending_email);
$this->assertNotNull($user->email_verified_at);
}
public function test_profile_update_does_not_clear_pending_email_when_email_is_unchanged(): void
{
// Arrange
$user = User::factory()->create([
'email' => 'current@example.com',
'pending_email' => 'new.email@example.com',
]);
$timezone = app(TimezoneService::class)->getTimezones()[0];
$this->actingAs($user);
// Act
$response = $this->put('/user/profile-information', [
'name' => 'Updated Name',
'email' => 'current@example.com',
'timezone' => $timezone,
'week_start' => Weekday::Sunday->value,
]);
// Assert
$response->assertValid(errorBag: 'updateProfileInformation');
$user = $user->fresh();
$this->assertEquals('Updated Name', $user->name);
$this->assertEquals('current@example.com', $user->email);
$this->assertEquals('new.email@example.com', $user->pending_email);
}
public function test_stale_pending_email_verification_link_is_rejected(): void
{
// Arrange
$user = User::factory()->create([
'email' => 'current@example.com',
'pending_email' => 'newer@example.com',
]);
$this->actingAs($user);
$verificationUrl = URL::temporarySignedRoute(
'users.verify-email-change',
now()->addMinutes(60),
[
'user' => $user->getKey(),
'email' => 'older@example.com',
],
false
);
// Act
$response = $this->get($verificationUrl);
// Assert
$response->assertForbidden();
$user = $user->fresh();
$this->assertEquals('current@example.com', $user->email);
$this->assertEquals('newer@example.com', $user->pending_email);
}
} }

Some files were not shown because too many files have changed in this diff Show More