Compare commits

..

32 Commits

Author SHA1 Message Date
Gregor Vostrak
fb4bb6ef33 add invoice copy to openapi client 2026-05-29 15:34:54 +02:00
Gregor Vostrak
dc5e8e7de2 move banners on login and register cards into the cards 2026-05-29 15:33:35 +02:00
Gregor Vostrak
5821f7c688 add pending email cancel button 2026-05-29 15:31:33 +02:00
Constantin Graf
22e865a69e Replaces all Jetstream model trait functions and relations 2026-05-29 12:40:06 +02:00
Constantin Graf
5391a7abc8 Add reset pending email endpoint to user controller 2026-05-28 20:45:27 +02:00
Gregor Vostrak
c8623b7e70 move user delete to api endpoint 2026-05-27 19:06:39 +02:00
Gregor Vostrak
3b1702221b use api routes for profile information updates 2026-05-27 18:20:10 +02:00
Gregor Vostrak
4432174439 show null billable rate as empty not as 0 to avoid confusion 2026-05-27 13:12:59 +02:00
Gregor Vostrak
ccb16118a9 fix e2e selectors to adapt to reka-ui change; 2026-05-27 13:12:10 +02:00
Gregor Vostrak
dad686d107 add pending email to UserResource and update openapi client 2026-05-26 18:02:15 +02:00
Gregor Vostrak
414b5d3294 update ui package dependencies; update lucide imports 2026-05-26 17:30:30 +02:00
Gregor Vostrak
e9217df338 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-05-26 17:21:40 +02:00
Gregor Vostrak
96a0c21b5e update npm dependencies 2026-05-26 17:19:42 +02:00
Gregor Vostrak
8e7c8a1e1b add profile page e2e tests 2026-05-26 17:11:28 +02:00
Gregor Vostrak
6299e242a9 update email address change info to use session based banners 2026-05-26 14:03:30 +02:00
Gregor Vostrak
c573d31ef9 add 1MB photo upload limit 2026-05-26 13:59:44 +02:00
Gregor Vostrak
00ffabe108 add photo delete logic to user update endpoint 2026-05-26 13:23:31 +02:00
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
223 changed files with 7385 additions and 9736 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
name: NPM Test Unit
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TZ: UTC
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

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

View File

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

1
.npmrc
View File

@@ -1 +0,0 @@
min-release-age=7

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ use App\Models\Organization;
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
@@ -25,6 +26,8 @@ class CreateOrganization implements CreatesTeams
*
* @throws AuthorizationException
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function create(User $user, array $input): Organization
{
@@ -48,10 +51,8 @@ class CreateOrganization implements CreatesTeams
$currency
);
$user->switchTeam($organization);
app(UserService::class)->switchCurrentOrganization($user, $organization);
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);
return $organization;

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ class UserCreateCommand extends Command
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
$organization = $user->ownedOrganizations->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}

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(),
// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
Textarea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static string $relationship = 'organizationInvitations';
protected static ?string $title = 'Invitations';

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat
use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService;
use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider;
use Exception;
use Filament\Forms;
@@ -47,17 +48,17 @@ class UserResource extends Resource
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('id')
TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
Forms\Components\TextInput::make('name')
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
TextInput::make('email')
->label('Email')
->required()
->rules($record?->is_placeholder ? [] : [
@@ -179,7 +180,7 @@ class UserResource extends Resource
])
->actions([
Impersonate::make()->before(function (User $record): void {
if ($record->currentTeam === null) {
if ($record->currentOrganization === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first();
if ($organization === null) {
$organization = $record->organizations()->first();
@@ -187,8 +188,7 @@ class UserResource extends Resource
if ($organization === null) {
throw new Exception('User has no organization');
}
$record->currentTeam()->associate($organization);
$record->save();
app(UserService::class)->switchCurrentOrganization($record, $organization);
}
}),
Tables\Actions\EditAction::make(),

View File

@@ -16,7 +16,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
{
protected static ?string $title = 'Owned Organizations';
protected static string $relationship = 'ownedTeams';
protected static string $relationship = 'ownedOrganizations';
public function form(Form $form): Form
{

View File

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

View File

@@ -40,7 +40,7 @@ class InvitationController extends Controller
{
$this->checkPermission($organization, 'invitations:view');
$invitations = $organization->teamInvitations()
$invitations = $organization->organizationInvitations()
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));

View File

@@ -5,11 +5,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class OrganizationController extends Controller
{
@@ -80,4 +87,46 @@ class OrganizationController extends Controller
return new OrganizationResource($organization, true);
}
/**
* Create organization
*
* @operationId createOrganization
*/
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
{
$user = $this->user();
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
$currency = $ipLookupResponse?->currency;
$organization = $organizationService->createOrganization(
$request->getName(),
$user,
false,
$currency
);
app(UserService::class)->switchCurrentOrganization($user, $organization);
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
{
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) {
return;

View File

@@ -4,15 +4,26 @@ declare(strict_types=1);
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\Mail\VerifyUpdatedEmailMail;
use App\Models\User;
use App\Service\DeletionService;
use App\Support\Base64File;
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
{
/**
* Get the current user
*
* This endpoint is independent of organization.
* This endpoint is independent of the organization.
*
* @operationId getMe
*
@@ -24,4 +35,140 @@ class UserController extends Controller
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->hasPhotoKey()) {
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto();
if ($newPhoto === null) {
$user->profile_photo_path = null;
} else {
$decoded = Base64File::decode($newPhoto);
assert($decoded !== null);
$extension = Base64File::extension($decoded['mime_type']);
assert($extension !== null);
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
Storage::disk($photoDisk)->put($photoPath, $decoded['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);
}
/**
* Reset the pending email for a user.
*
* This endpoint is independent of the organization.
*
* @operationId resetUserPendingEmail
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
*/
public function resetPendingEmail(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$user->pending_email = null;
$user->save();
return response()->json(null, 204);
}
/**
* 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
*
* This endpoint is independent of organization.
* This endpoint is independent of the organization.
*
* @operationId getMyMemberships
*

View File

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

View File

@@ -59,7 +59,7 @@ class Controller extends BaseController
protected function currentOrganization(): Organization
{
$user = $this->user();
$organization = $user->currentTeam;
$organization = $user->currentOrganization;
if ($organization === null) {
$organization = $user->organizations()->first();
}

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
<?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'))
->with('bannerStyle', 'danger')
->with('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'))
->with('bannerStyle', 'success')
->with('bannerText', __('Your email address has been updated successfully.'));
}
}

View File

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

View File

@@ -14,7 +14,7 @@ class ForceHttps
/**
* 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
{

View File

@@ -13,7 +13,7 @@ class ForceJsonResponse
/**
* 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
{

View File

@@ -46,7 +46,7 @@ class HandleInertiaRequests extends Middleware
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
$currentOrganization = $request->user()?->currentTeam;
$currentOrganization = $request->user()?->currentOrganization;
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),
'bannerText' => fn () => $request->session()->get('bannerText'),
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
],
]);
}

View File

@@ -15,7 +15,7 @@ class RedirectIfAuthenticated
/**
* 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
{

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -19,7 +20,7 @@ class MemberMergeIntoRequest extends BaseFormRequest
/**
* 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
{

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,95 @@
<?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 hasPhotoKey(): bool
{
return $this->has('photo');
}
public function getPhoto(): ?string
{
$value = $this->input('photo');
return is_string($value) ? $value : null;
}
}

View File

@@ -28,6 +28,8 @@ class UserResource extends BaseResource
'name' => $this->resource->name,
/** @var string $email Email of user */
'email' => $this->resource->email,
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
'pending_email' => $this->resource->pending_email,
/** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url,
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */

View File

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

View File

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

View File

@@ -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\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Team;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -36,12 +37,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Collection<int, User> $users
* @property Collection<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
* @property-read Collection<int, OrganizationInvitation> $organizationInvitations
* @property Member $membership
* @property NumberFormat $number_format
* @property CurrencyFormat $currency_format
@@ -49,7 +51,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format
* @property TimeFormat $time_format
*
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
@@ -109,23 +110,6 @@ class Organization extends JetstreamTeam implements AuditableContract
protected $attributes = [
];
/**
* Get all the non-placeholder users of the organization including its owner.
*
* @return Collection<int, User>
*/
public function allRealUsers(): Collection
{
return $this->realUsers->merge([$this->owner]);
}
public function hasRealUserWithEmail(string $email): bool
{
return $this->allRealUsers()->contains(function (User $user) use ($email): bool {
return $user->email === $email;
});
}
/**
* Get all the users that belong to the team.
*
@@ -170,13 +154,21 @@ class Organization extends JetstreamTeam implements AuditableContract
->where('is_placeholder', false);
}
/**
* @return HasMany<OrganizationInvitation, $this>
*/
public function organizationInvitations(): HasMany
{
return $this->hasMany(OrganizationInvitation::class, 'organization_id');
}
/**
* This method prevents an unhandled exception when the ID is not a UUID.
* Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
*
* @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)) {
throw (new ModelNotFoundException)->setModel(

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
@@ -36,6 +37,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $id
* @property string $name
* @property string $email
* @property string|null $pending_email
* @property Carbon|null $email_verified_at
* @property string|null $password
* @property string|null $two_factor_secret
@@ -51,6 +53,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property string|null $current_team_id
* @property Collection<int, Organization> $organizations
* @property Collection<int, Organization> $ownedOrganizations
* @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership
*
@@ -105,6 +108,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
protected $casts = [
'name' => 'string',
'email' => 'string',
'pending_email' => 'string',
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
@@ -129,16 +133,39 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
{
return Attribute::get(function (): string {
return $this->profile_photo_path
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
? Storage::disk(config('filesystems.public'))->url($this->profile_photo_path)
: $this->defaultProfilePhotoUrl();
});
}
/**
* Get the default profile photo URL if no profile photo has been uploaded.
*/
protected function defaultProfilePhotoUrl(): string
{
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
return mb_substr($segment, 0, 1);
})->join(' '));
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
}
public function canAccessPanel(Panel $panel): bool
{
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function isMemberOfOrganization(Organization $organization): bool
{
if ($this->relationLoaded('organizations')) {
return $this->organizations->contains(function (Organization $o) use ($organization): bool {
return $o->getKey() === $organization->getKey();
});
}
return $this->organizations()->whereKey($organization->getKey())->exists();
}
public function canBeImpersonated(): bool
{
return $this->is_placeholder === false;
@@ -159,6 +186,14 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
->as('membership');
}
/**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
*/
public function ownedOrganizations(): BelongsToMany
{
return $this->organizations()->wherePivot('role', Role::Owner->value);
}
/**
* @return HasMany<TimeEntry, $this>
*/
@@ -213,12 +248,8 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/
public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder
{
return $builder->where(function (Builder $builder) use ($organization): Builder {
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey());
})->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey());
});
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey());
});
}
}

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Responses\LoginResponse;
@@ -41,6 +42,14 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::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 {
/** @var User|null $user */
$user = User::query()

View File

@@ -13,20 +13,18 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\PermissionStore;
use App\Service\TimezoneService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream;
@@ -60,13 +58,6 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::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) {
return false;
});
@@ -79,205 +70,10 @@ class JetstreamServiceProvider extends ServiceProvider
{
Jetstream::defaultApiTokenPermissions([]);
Jetstream::role(Role::Owner->value, 'Owner', [
'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.');
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.');
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
Jetstream::role($role, $definition['name'], $definition['permissions'])
->description($definition['description']);
}
Jetstream::inertia()
->whenRendering(
@@ -304,28 +100,8 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'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 {
return $currency->getName();

View File

@@ -0,0 +1,45 @@
<?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',
];
private const int MAX_BYTES = 1024 * 1024;
/**
* 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']));
return;
}
if (strlen($file['data']) > self::MAX_BYTES) {
$fail(__('validation.max.file', ['max' => (string) (self::MAX_BYTES / 1024)]));
}
}
}

View File

@@ -173,7 +173,7 @@ class DeletionService
$user->authCodes()->delete();
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto();
$this->userService->deleteProfilePhoto($user);
$user->delete();

View File

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

View File

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

View File

@@ -4,14 +4,238 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Role;
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>>
*/
@@ -22,6 +246,37 @@ class PermissionStore
$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
{
/** @var User|null $user */
@@ -36,7 +291,7 @@ class PermissionStore
public function userHas(Organization $organization, User $user, string $permission): bool
{
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
if (! $user->belongsToTeam($organization)) {
if (! $user->isMemberOfOrganization($organization)) {
return false;
}
@@ -54,7 +309,7 @@ class PermissionStore
*/
private function getPermissionsByUser(Organization $organization, User $user): array
{
if (! $user->belongsToTeam($organization)) {
if (! $user->isMemberOfOrganization($organization)) {
return [];
}
@@ -68,14 +323,11 @@ class PermissionStore
return [];
}
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
$permissions = $roleObj->permissions ?? [];
$permissions = self::permissionsForRole($role);
// If the organization allows employees to manage tasks and the user is an employee,
// 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, [
'tasks:create',
'tasks:update',

View File

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

View File

@@ -62,7 +62,7 @@ class TimeEntryFilter
if ($start === null) {
return $this;
}
$this->builder->where('start', '>=', $start);
$this->builder->where('start', '>', $start);
return $this;
}

View File

@@ -19,6 +19,7 @@ use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
class UserService
{
@@ -38,7 +39,7 @@ class UserService
): User {
$user = new User;
$user->name = $name;
$user->email = $email;
$user->email = strtolower($email);
$user->password = Hash::make($password);
$user->timezone = $timezone;
$user->week_start = $weekStart;
@@ -47,19 +48,21 @@ class UserService
}
$user->save();
$organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name),
$user,
true,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
$user->ownedTeams()->save($organization);
if ($organizations->isEmpty()) {
$organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name),
$user,
true,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
}
return $user;
}
@@ -100,13 +103,17 @@ class UserService
true
);
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
$user->save();
$this->switchCurrentOrganization($user, $organization);
AfterCreateOrganization::dispatch($organization);
}
public function switchCurrentOrganization(User $user, Organization $organization): void
{
$user->currentOrganization()->associate($organization);
$user->save();
}
public function getOrganizationNameForUserName(string $username): string
{
return explode(' ', $username, 2)[0]."'s Organization";
@@ -154,4 +161,16 @@ class UserService
$oldOwner->save();
}
}
public function deleteProfilePhoto(User $user): void
{
if ($user->profile_photo_path === null) {
return;
}
Storage::disk(config('filesystems.public'))->delete($user->profile_photo_path);
$user->profile_photo_path = null;
$user->save();
}
}

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
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__)
);
@@ -30,7 +34,7 @@ $app = new Illuminate\Foundation\Application(
$app->singleton(
Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class
Kernel::class
);
$app->singleton(
@@ -39,8 +43,8 @@ $app->singleton(
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
ExceptionHandler::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\NumberFormat;
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\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider;
@@ -190,13 +197,13 @@ return [
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
AppServiceProvider::class,
AuthServiceProvider::class,
EventServiceProvider::class,
AdminPanelProvider::class,
RouteServiceProvider::class,
FortifyServiceProvider::class,
JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Http\FileHelpers;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -27,6 +28,7 @@ class UserFactory extends Factory
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'pending_email' => null,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'two_factor_secret' => null,
@@ -90,7 +92,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static
{
$profilePhoto = $this->faker->image(null, 500, 500);
/** @see \Illuminate\Http\FileHelpers::hashName */
/** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
@@ -118,7 +120,7 @@ class UserFactory extends Factory
$organization->owner()->associate($user);
$organization->users()->attach($user, ['role' => Role::Owner->value]);
$user->currentTeam()->associate($organization);
$user->currentOrganization()->associate($organization);
$user->save();
});
}

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

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.58.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

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

@@ -907,7 +907,7 @@ test.describe('Employee Sidebar Navigation', () => {
// Visible links
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time', exact: true })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();

View File

@@ -1,30 +1,374 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import {
countEmailsWithSubject,
getEmailChangeVerificationUrl,
waitForEmailCount,
} from './utils/mailpit';
import { getCurrentUserViaApi } from './utils/api';
import { registerUser } from './utils/members';
import type { Page } from '@playwright/test';
import path from 'path';
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
test('test that user name can be updated', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
function profileInformationForm(page: Page) {
return page
.getByRole('heading', { name: 'Profile Information', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click();
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
}
test('user name can be updated', async ({ page }) => {
await goToProfilePage(page);
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
await Promise.all([
page.getByRole('button', { name: 'Save' }).first().click(),
page.waitForResponse('**/user/profile-information'),
]);
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
});
test.skip('test that user email can be updated', async ({ page }) => {
// this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', { name: 'Save' }).first().click();
test('timezone change persists across reload', async ({ page }) => {
await goToProfilePage(page);
await page.getByLabel('Timezone').selectOption('America/New_York');
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York');
});
test('week-start change persists across reload', async ({ page }) => {
await goToProfilePage(page);
await page.getByLabel('Start of the week').selectOption('sunday');
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
});
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
page,
}) => {
await goToProfilePage(page);
const form = profileInformationForm(page);
const profilePhoto = form.getByRole('img', { name: 'John Doe' });
await expect(profilePhoto).toBeVisible();
await expect(profilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
await expect(form.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await form.locator('#photo').setInputFiles(path.resolve('resources/testfiles/test.png'));
await saveProfileForm(page);
await expect(profilePhoto).toHaveAttribute('src', /profile-photos/);
await expect(form.getByRole('button', { name: 'Remove Photo' })).toBeVisible();
await page.reload();
const reloadedForm = profileInformationForm(page);
const reloadedProfilePhoto = reloadedForm.getByRole('img', { name: 'John Doe' });
await expect(reloadedProfilePhoto).toHaveAttribute('src', /profile-photos/);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
reloadedForm.getByRole('button', { name: 'Remove Photo' }).click(),
]);
await expect(reloadedProfilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
await expect(reloadedForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await page.reload();
const finalForm = profileInformationForm(page);
await expect(finalForm.getByRole('img', { name: 'John Doe' })).toHaveAttribute(
'src',
/ui-avatars\.com/
);
await expect(finalForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
});
test('field-level validation errors render inline when the server returns 422', async ({
page,
}) => {
await goToProfilePage(page);
const form = profileInformationForm(page);
await form.getByLabel('Name').fill('a'.repeat(256));
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 422
),
form.getByRole('button', { name: 'Save' }).click(),
]);
await expect(form.getByRole('alert').filter({ hasText: /255 characters/i })).toBeVisible();
});
test('submitting a new email keeps the current email displayed after reload', async ({
page,
ctx,
}) => {
const { email: oldEmail } = await getCurrentUserViaApi(ctx);
const newEmail = `newemail+${Date.now()}@test.com`;
await goToProfilePage(page);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(oldEmail);
});
test('submitting a new email sends a verification email to the new address', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `newemail+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
expect(await waitForEmailCount(request, newEmail, 'Verify Email Address', 1)).toBeGreaterThan(
0
);
});
test('mixed-case email is lower-cased before the verification mail is sent', async ({
page,
request,
}) => {
await goToProfilePage(page);
const stamp = Date.now();
const mixedCase = `MixedCase+${stamp}@Example.COM`;
const lowerCased = `mixedcase+${stamp}@example.com`;
await page.getByLabel('Email').fill(mixedCase);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(request, lowerCased);
expect(new URL(verifyUrl).searchParams.get('email')).toBe(lowerCased);
});
test('re-submitting the current email does not send a verification email', async ({
page,
ctx,
request,
}) => {
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
const beforeCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address');
await goToProfilePage(page);
await page.getByLabel('Email').fill(currentEmail);
await saveProfileForm(page);
await new Promise((r) => setTimeout(r, 1000));
const afterCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address');
expect(afterCount).toBe(beforeCount);
});
test('after submitting a new email the pending-email banner is shown with a resend button', async ({
page,
}) => {
await goToProfilePage(page);
const newEmail = `pending+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await expect(page.getByText(`A verification link was sent to`)).toBeVisible();
await expect(page.getByText(newEmail)).toBeVisible();
await expect(page.getByRole('button', { name: 'Resend verification email' })).toBeVisible();
});
test('clicking resend sends a second verification email and shows confirmation', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `resend+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const beforeCount = await waitForEmailCount(request, newEmail, 'Verify Email Address', 1);
await page.getByRole('button', { name: 'Resend verification email' }).click();
await expect(page.getByText('Verification email sent.')).toBeVisible();
const afterCount = await waitForEmailCount(
request,
newEmail,
'Verify Email Address',
beforeCount + 1
);
expect(afterCount).toBeGreaterThan(beforeCount);
});
test('cancelling a pending email change clears it and hides the banner', async ({ page, ctx }) => {
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
const newEmail = `cancel+${Date.now()}@test.com`;
await goToProfilePage(page);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
// The pending-email banner is shown with the cancel control.
await expect(page.getByText('A verification link was sent to')).toBeVisible();
await expect(page.getByText(newEmail)).toBeVisible();
const cancelButton = page.getByRole('button', { name: 'Cancel email change' });
await expect(cancelButton).toBeVisible();
// Cancelling clears the pending email server-side (204).
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reset-pending-email') &&
response.request().method() === 'POST' &&
response.status() === 204
),
cancelButton.click(),
]);
// The banner disappears and the email field still shows the current address.
await expect(page.getByText('A verification link was sent to')).toBeHidden();
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
// The cancellation is persistent — still gone after a reload.
await page.reload();
await expect(page.getByText('A verification link was sent to')).toBeHidden();
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
});
test('re-submitting the same pending email does not send another verification email', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `dup+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const beforeCount = await waitForEmailCount(request, newEmail, 'Verify Email Address', 1);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await new Promise((r) => setTimeout(r, 1000));
const afterCount = await countEmailsWithSubject(request, newEmail, 'Verify Email Address');
expect(afterCount).toBe(beforeCount);
});
test('clicking the verification link swaps the email and shows a success banner', async ({
page,
}) => {
await goToProfilePage(page);
const newEmail = `verify+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
await page.goto(verifyUrl);
await page.waitForURL(/\/dashboard/);
const banner = page.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText('Your email address has been updated successfully.');
await goToProfilePage(page);
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
});
test('visiting another users verification link is forbidden', async ({ page, browser }) => {
await goToProfilePage(page);
const newEmail = `victim+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
const other = await registerUser(browser, 'Other User', `other+${Date.now()}@test.com`);
try {
const response = await other.page.goto(verifyUrl);
expect(response?.status()).toBe(403);
} finally {
await other.close();
}
});
test('a stale verification link from a previous submission is rejected', async ({ page }) => {
await goToProfilePage(page);
const stamp = Date.now();
const olderEmail = `older+${stamp}@test.com`;
const newerEmail = `newer+${stamp}@test.com`;
await page.getByLabel('Email').fill(olderEmail);
await saveProfileForm(page);
const staleUrl = await getEmailChangeVerificationUrl(page.request, olderEmail);
await page.getByLabel('Email').fill(newerEmail);
await saveProfileForm(page);
const response = await page.goto(staleUrl);
expect(response?.status()).toBe(403);
});
test('visiting the verification link while logged out redirects to login', async ({
page,
browser,
}) => {
await goToProfilePage(page);
const newEmail = `loggedout+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
const anonContext = await browser.newContext();
try {
const anonPage = await anonContext.newPage();
await anonPage.goto(verifyUrl);
await anonPage.waitForURL(/\/login/);
} finally {
await anonContext.close();
}
});
test('delete account shows an error when the password is wrong', async ({ page }) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/confirm-password') &&
response.request().method() === 'POST' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await expect(dialog.getByRole('alert')).toBeVisible();
await expect(dialog).toBeVisible();
});
test('delete account succeeds with the correct password and logs the user out', async ({
page,
}) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await page.waitForURL(/\/login/);
});
async function createNewApiToken(page) {

View File

@@ -640,7 +640,7 @@ test('test that creating a project with estimated time in human-readable format
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using human-readable format
const estimatedTimeInput = page.getByLabel('Time Estimated');
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('2h 30m');
await estimatedTimeInput.press('Tab');
@@ -668,7 +668,7 @@ test('test that creating a project with estimated time using decimal notation wo
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
const estimatedTimeInput = page.getByLabel('Time Estimated');
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('1.5');
await estimatedTimeInput.press('Tab');
@@ -696,7 +696,7 @@ test('test that creating a project with estimated time using comma decimal notat
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
const estimatedTimeInput = page.getByLabel('Time Estimated');
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('2,5');
await estimatedTimeInput.press('Tab');
@@ -727,7 +727,7 @@ test('test that updating estimated time on existing project works', async ({ pag
await page.getByRole('menuitem').getByText('Edit').first().click();
// Fill in estimated time
const estimatedTimeInput = page.getByLabel('Time Estimated');
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('4h 15m');
await estimatedTimeInput.press('Tab');
@@ -748,7 +748,7 @@ test('test that estimated time input displays formatted value after blur', async
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
const estimatedTimeInput = page.getByLabel('Time Estimated');
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
// Enter time in various formats and check the displayed value
await estimatedTimeInput.fill('90');

View File

@@ -469,7 +469,7 @@ test('test that creating a report with an expiration date works', async ({ page,
await datePicker.click();
// Select a date in the next month
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
@@ -547,7 +547,7 @@ test('test that editing a report to make it public with expiration date works',
await datePicker.click();
// Select a date in the next month
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click();
@@ -741,7 +741,7 @@ test('test that updating expiration date on already-public report works', async
await datePicker.click();
// Select the 25th of next month
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^25$/ }).first().click();

View File

@@ -462,7 +462,7 @@ test('test that setting a date in the create modal works', async ({ page }) => {
await startDatePicker.click();
// Wait for calendar to appear
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Navigate to previous month and select the 15th (a day that's always in the middle of the month)
@@ -515,7 +515,7 @@ test('test that updating the date via the time entry row range selector works',
await startDatePicker.click();
// Wait for the calendar to appear and select a day
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Navigate to previous month and select the 5th
@@ -568,7 +568,7 @@ test('test that updating the end date via the time entry row range selector work
await endDatePicker.click();
// Wait for the calendar to appear
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Navigate to next month and select the 20th (to ensure end > start)

View File

@@ -1,437 +0,0 @@
/**
* E2E coverage for the timesheet overlap-prevention logic introduced
* in `useTimesheetCellMutations` (Phase 1+2+3 of the overlap fix).
*
* Each test:
* 1. Pre-creates entries via the API to set up a deterministic
* day-of-work scenario,
* 2. Triggers ONE cell edit through the UI,
* 3. Reads the resulting entries back via the API and asserts on
* the start/end placement.
*
* Pre-creating rows (rather than driving the "Add row" + project picker
* UI) keeps the tests focused on the placement logic and out of the
* project-dropdown's flake surface.
*/
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
import {
createProjectViaApi,
createTimeEntryAtHourViaApi,
getTimeEntriesViaApi,
} from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
async function waitForTimesheetLoad(page: Page) {
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
const HOUR = 3600;
function utcHourOf(iso: string): number {
return new Date(iso).getUTCHours();
}
function utcMinuteOf(iso: string): number {
return new Date(iso).getUTCMinutes();
}
function sortByStart<T extends { start: string }>(entries: T[]): T[] {
return [...entries].sort((a, b) => a.start.localeCompare(b.start));
}
/**
* Returns the locator for the row whose project name matches the given
* substring. Robust against ordering changes.
*/
function rowByProject(page: Page, projectName: string) {
return page.locator('[data-testid="timesheet_row"]').filter({ hasText: projectName });
}
/**
* Returns the locator for the input in the (row, dayIndex) cell, where
* the row is identified by project name.
*/
function cellInputByProject(page: Page, projectName: string, dayIndex: number) {
return rowByProject(page, projectName)
.locator('[data-testid="timesheet_cell"]')
.nth(dayIndex)
.locator('input');
}
/** Asserts that no entries in the list overlap each other. */
function expectNoOverlaps(entries: Array<{ start: string; end: string | null }>) {
const sorted = sortByStart(entries.filter((e) => e.end !== null));
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const curr = sorted[i]!;
expect(
curr.start >= prev.end!,
`entries overlap: ${prev.start}${prev.end} vs ${curr.start}${curr.end}`
).toBe(true);
}
}
// ──────────────────────────────────────────────────
// Phase 1: createCell — overlap avoidance when cell is empty
// ──────────────────────────────────────────────────
test('extendCell on a row that has no entries on the day yet places after another row (Scenario #4)', async ({
page,
ctx,
}) => {
// Setup: project A has Monday 09:0010:00, project B has Tuesday
// 09:0010:00. The B row is therefore visible on the timesheet but
// has an EMPTY cell on Monday. Typing into B's Monday cell exercises
// the createCell path (cell empty → place a new entry).
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectA = await createProjectViaApi(ctx, { name: 'OverlapAlpha' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBravo' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into project B's Monday cell. The createCell path should
// place it AFTER project A's 09:0010:00 (i.e. at 10:00 or later),
// not at 09:00.
const input = cellInputByProject(page, 'OverlapBravo', 0);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const bMondayEntry = entries.find(
(e) =>
e.project_id === projectB.id &&
new Date(e.start).getTime() >= monday.getTime() &&
new Date(e.start).getTime() < tuesday.getTime()
)!;
expect(bMondayEntry).toBeDefined();
// 09:00 is blocked → must be at 10:00 or later.
expect(utcHourOf(bMondayEntry.start)).toBeGreaterThanOrEqual(10);
expectNoOverlaps(entries);
});
test('createCell refuses to cross midnight when day is full (Scenario #3)', async ({
page,
ctx,
}) => {
// Setup: fill Monday 01:0023:00 (22 hours, leaving 1h before and
// 1h after — neither big enough for a 3h ask). Project B is on
// Tuesday so the B row exists with an empty Monday cell. Typing 3h
// into B's Monday cell should be refused.
//
// We start at 01:00 (not 00:00) because the API's time-entry
// filter excludes entries whose `start` equals the query's `start`
// bound exactly. Using 01:00 avoids that boundary condition.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectFull = await createProjectViaApi(ctx, { name: 'OverlapFull' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapNoRoom' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 1,
durationSeconds: 22 * HOUR,
projectId: projectFull.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapNoRoom', 0);
const seenMutationRequests: string[] = [];
const onRequest = (request: Request) => {
if (request.url().includes('/time-entries') && request.method() !== 'GET') {
seenMutationRequests.push(request.method());
}
};
page.on('request', onRequest);
await input.click();
await input.fill('3');
await input.press('Enter');
await expect(page.getByText("This day can't fit any more work")).toBeVisible();
page.off('request', onRequest);
const entries = await getTimeEntriesViaApi(ctx);
// The new project should still only have its Tuesday entry.
const newEntries = entries.filter((e) => e.project_id === projectNew.id);
expect(seenMutationRequests).toEqual([]);
expect(newEntries).toHaveLength(1);
expect(utcHourOf(newEntries[0]!.start)).toBe(9);
// The Tuesday entry's date is unchanged (still Tuesday).
expect(new Date(newEntries[0]!.start).getUTCDay()).toBe(2);
});
// ──────────────────────────────────────────────────
// Phase 2: extendCell — collision detection + split
// ──────────────────────────────────────────────────
test('extendCell splits the extension when another row blocks the path (Scenario #5)', async ({
page,
ctx,
}) => {
// Setup:
// - project A on Monday 09:0010:00 (1h)
// - project B on Monday 10:3011:30 (1h, blocker)
// Bumping A's Monday cell from 1h to 3h (+2h) should:
// - extend A to 09:0010:30 (filling the 30min gap)
// - place a new A entry at 11:3013:00 (the remaining 90min)
const monday = getCurrentWeekMonday();
const projectA = await createProjectViaApi(ctx, { name: 'OverlapExtend' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBlocker' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
startMinute: 30,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapExtend', 0);
await input.click();
await input.fill('3');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const aEntries = entries.filter((e) => e.project_id === projectA.id);
const bEntries = entries.filter((e) => e.project_id === projectB.id);
// The blocker is unchanged.
expect(bEntries).toHaveLength(1);
expect(utcHourOf(bEntries[0]!.start)).toBe(10);
expect(utcMinuteOf(bEntries[0]!.start)).toBe(30);
// Project A should now have 2 entries.
expect(aEntries).toHaveLength(2);
const sortedA = sortByStart(aEntries);
// Extended entry: 09:00 → 10:30
expect(utcHourOf(sortedA[0]!.start)).toBe(9);
expect(utcHourOf(sortedA[0]!.end!)).toBe(10);
expect(utcMinuteOf(sortedA[0]!.end!)).toBe(30);
// Split remainder: 11:30 → 13:00
expect(utcHourOf(sortedA[1]!.start)).toBe(11);
expect(utcMinuteOf(sortedA[1]!.start)).toBe(30);
// No overlaps anywhere on the day.
expectNoOverlaps(entries);
});
test('extendCell prefers latest-end (not latest-start) when nested entries exist (Scenario #6)', async ({
page,
ctx,
}) => {
// Pre-existing nested overlap on the same project:
// - outer: 09:00 → 12:00 (3h)
// - inner: 10:00 → 11:00 (1h, contained inside outer)
// The cell total is 3h + 1h = 4h. Bumping to 5h (+1h) should grow
// the OUTER entry's end to 13:00, not the inner.
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'OverlapNested' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: 3 * HOUR,
projectId: project.id,
description: 'outer',
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
durationSeconds: HOUR,
projectId: project.id,
description: 'inner',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(1);
const input = cellInputByProject(page, 'OverlapNested', 0);
await input.click();
await input.fill('5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const outer = entries.find((e) => e.description === 'outer')!;
const inner = entries.find((e) => e.description === 'inner')!;
expect(utcHourOf(outer.start)).toBe(9);
expect(utcHourOf(outer.end!)).toBe(13); // extended from 12:00 → 13:00
expect(utcHourOf(inner.start)).toBe(10);
expect(utcHourOf(inner.end!)).toBe(11); // unchanged
});
// ──────────────────────────────────────────────────
// Phase 1+2 spillover from previous day
// ──────────────────────────────────────────────────
test('createCell handles intra-week spillover from previous day (Scenario #2)', async ({
page,
ctx,
}) => {
// Setup: an entry that starts on Monday 22:00 and ends Tuesday 03:00
// (5h, crosses midnight INTO Tuesday). This spillover starts inside
// the loaded week, so the timesheet query loads it.
//
// Then we try to place 1h on Tuesday for a different project. The
// expected behavior: the new entry must NOT overlap the spillover.
// Tuesday 09:00 is well clear of the [00:00, 03:00) spillover, so
// 09:00 is the correct placement.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const wednesday = new Date(monday);
wednesday.setUTCDate(monday.getUTCDate() + 2);
const projectSpill = await createProjectViaApi(ctx, { name: 'OverlapSpill' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapToday' });
// Monday 22:00 → Tuesday 03:00 (5h spillover into Tuesday).
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 22,
durationSeconds: 5 * HOUR,
projectId: projectSpill.id,
});
// Stub Wednesday entry on the new project so its row is visible
// even before we type anything in Tuesday's cell.
await createTimeEntryAtHourViaApi(ctx, {
date: wednesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into the new project's Tuesday cell (day index 1).
const input = cellInputByProject(page, 'OverlapToday', 1);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const newTuesdayEntry = entries.find(
(e) =>
e.project_id === projectNew.id &&
new Date(e.start).getTime() >= tuesday.getTime() &&
new Date(e.start).getTime() < wednesday.getTime()
)!;
expect(newTuesdayEntry).toBeDefined();
// 09:00 is well past the spillover end (03:00) → should land at 09:00.
expect(utcHourOf(newTuesdayEntry.start)).toBe(9);
expectNoOverlaps(entries);
});

View File

@@ -1,641 +0,0 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
function getLastWeekMonday(): Date {
const monday = getCurrentWeekMonday();
monday.setUTCDate(monday.getUTCDate() - 7);
return monday;
}
function getDayOfWeek(weekStart: Date, dayOffset: number): Date {
const date = new Date(weekStart);
date.setUTCDate(date.getUTCDate() + dayOffset);
return date;
}
async function waitForTimesheetLoad(page: Page) {
await page.waitForURL(/\/timesheet(?:$|\?)/);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
function addRowButton(page: Page) {
return page.getByRole('button', { name: /Add row/i }).first();
}
async function chooseRowIdentity(page: Page, optionName: string) {
await addRowButton(page).click();
const dialog = page.getByRole('dialog', { name: /Add row/i });
const dialogVisible = await dialog
.waitFor({ state: 'visible', timeout: 1000 })
.then(() => true)
.catch(() => false);
if (dialogVisible) {
await dialog.getByRole('option', { name: optionName }).click();
return;
}
if (optionName === 'No Project') return;
const row = page.locator('[data-testid="timesheet_row"]').first();
await row.getByText('No Project').click();
await page.getByText(optionName).click();
}
// ──────────────────────────────────────────────────
// Navigation & Page Load
// ──────────────────────────────────────────────────
test('timesheet renders empty with add row + copy last week actions', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
await expect(addRowButton(page)).toBeVisible();
await expect(page.getByRole('button', { name: /Copy last week/i })).toBeVisible();
});
// ──────────────────────────────────────────────────
// Display Existing Time Entries
// ──────────────────────────────────────────────────
test('timesheet displays existing time entries grouped by project', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const tuesday = getDayOfWeek(monday, 1);
const wednesday = getDayOfWeek(monday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Project Alpha' });
const projectB = await createProjectViaApi(ctx, { name: 'Project Beta' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: wednesday,
duration: '1h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: tuesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// Check that the grand total is shown
await expect(page.getByTestId('timesheet_grand_total')).toBeVisible();
});
test('timesheet groups entries by project and task combination', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Task Project' });
const taskA = await createTaskViaApi(ctx, { name: 'Task A', project_id: project.id });
const taskB = await createTaskViaApi(ctx, { name: 'Task B', project_id: project.id });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
});
// ──────────────────────────────────────────────────
// Enter Duration in Cell
// ──────────────────────────────────────────────────
test('entering duration in empty cell creates a time entry', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Duration Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Duration Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Click the first day cell and enter duration
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayCell = cells.first();
const mondayInput = mondayCell.locator('input');
await mondayInput.click();
await mondayInput.fill('2');
// Submit and wait for create response
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(201);
// Verify the cell shows the duration
await expect(mondayInput).not.toHaveValue('');
});
// ──────────────────────────────────────────────────
// Edit Duration (Increase)
// ──────────────────────────────────────────────────
test('increasing duration in cell extends the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Increase Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Click and change to 3 hours
await mondayInput.click();
await mondayInput.fill('3');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Edit Duration (Decrease)
// ──────────────────────────────────────────────────
test('decreasing duration in cell shortens the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Decrease Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '3h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Clear Cell
// ──────────────────────────────────────────────────
test('clearing a cell deletes all time entries for that project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Clear Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('0');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
test('Escape during cell edit reverts the displayed value without an API call', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Escape Cancel Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Capture the formatted display value before editing.
const originalValue = await mondayInput.inputValue();
expect(originalValue).toMatch(/2/);
let mutationFired = false;
page.on('request', (req) => {
if (req.url().includes('/time-entries') && req.method() !== 'GET') {
mutationFired = true;
}
});
await mondayInput.click();
await mondayInput.fill('5');
await mondayInput.press('Escape');
// The Escape handler reverts the displayed value synchronously, so
// once this assertion passes we know the handler ran. Any mutation
// request would have been queued by then.
await expect(mondayInput).toHaveValue(originalValue);
expect(mutationFired).toBe(false);
});
// ──────────────────────────────────────────────────
// Week Navigation
// ──────────────────────────────────────────────────
test('navigating to previous week shows entries from that week', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Last Week Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no entries
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Go to previous week — the row-count assertion below auto-retries
// until the new week's data arrives.
await page.getByTestId('timesheet_prev_week').click();
// Should now see the entry
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
});
test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the
// header label flips.
await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week
await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
});
// ──────────────────────────────────────────────────
// Copy Last Week
// ──────────────────────────────────────────────────
test('copy last week adds project rows from previous week without hours', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const lastWednesday = getDayOfWeek(lastMonday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Copy Project A' });
const projectB = await createProjectViaApi(ctx, { name: 'Copy Project B' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: lastWednesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should now show 2 rows (one per project)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// All row totals should be 0
const rowTotals = page.locator('[data-testid="timesheet_row_total"]');
const count = await rowTotals.count();
for (let i = 0; i < count; i++) {
await expect(rowTotals.nth(i)).toContainText('-');
}
});
test('copy last week does not duplicate rows that already exist', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const thisMonday = getCurrentWeekMonday();
const thisTuesday = getDayOfWeek(thisMonday, 1);
const project = await createProjectViaApi(ctx, { name: 'No Dup Project' });
// Create entry for last week
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
// Create entry for current week
await createTimeEntryOnDateViaApi(ctx, {
date: thisTuesday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should have 1 row (from current week entry)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should still have only 1 row (not duplicated)
await expect(rows).toHaveCount(1);
});
test('copy last week with time entries creates rows and entries', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Copy Time Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows and time entries"
await page.getByRole('button', { name: /Copy last week/i }).click();
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
page.getByText('Copy rows and time entries').click(),
]);
// Should now show 1 row with time entries
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Row total should not be 0 (entries were copied)
const rowTotal = page.locator('[data-testid="timesheet_row_total"]').first();
await expect(rowTotal).not.toContainText('0 h');
});
// ──────────────────────────────────────────────────
// Row Removal
// ──────────────────────────────────────────────────
test('can remove an empty project row without confirmation', async ({ page, ctx }) => {
const project = await createProjectViaApi(ctx, { name: 'Empty Remove Project' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, project.name);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover the row to reveal the X button, then click it
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Row should be removed immediately (no dialog)
await expect(rows).toHaveCount(0);
});
test('removing a row with entries shows confirmation dialog and deletes entries', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Delete Row Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover and click X
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Confirmation dialog should appear
await expect(page.getByRole('alertdialog')).toBeVisible();
await expect(page.getByText('Remove timesheet row?')).toBeVisible();
// Click Delete
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
page
.getByRole('alertdialog')
.getByRole('button', { name: /Delete/i })
.click(),
]);
// Row should be gone
await expect(rows).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Multiple Entries Same Cell
// ──────────────────────────────────────────────────
test('cell correctly sums multiple entries for same project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Sum Test' });
// Create 2 entries for the same project on Monday
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
description: 'Entry 1',
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
description: 'Entry 2',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should be 1 row (both entries grouped)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// The Monday cell should show 3h total
const cells = rows.first().locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// The value should contain "3" (for 3h in some format)
await expect(mondayInput).toHaveValue(/3/);
});
// ──────────────────────────────────────────────────
// Duration Input Formats
// ──────────────────────────────────────────────────
test('cell accepts various duration input formats', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Format Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Format Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Test entering "1.5" (should be 1h 30min)
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1.5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
// 1.5 hours = 1h 30min
await expect(mondayInput).toHaveValue('1h 30min');
});

View File

@@ -293,7 +293,7 @@ test('test that setting an end time with a different date via the timetracker ra
await endDatePicker.click();
// Calendar should appear
const calendarGrid = page.getByRole('grid');
const calendarGrid = page.getByRole('gridcell').first();
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Navigate to the next month and select a day to ensure end > start

View File

@@ -170,24 +170,10 @@ function parseDurationToSeconds(duration: string): number {
return totalSeconds;
}
/**
* Builds a start/end pair anchored to 09:00 UTC on today's UTC date.
*
* Intentionally pinned to UTC (rather than the runner's local time) so
* the produced timestamps are identical regardless of where the suite
* runs. Playwright test users default to UTC, so this matches what the
* app will see and keeps day-of-week / "this week" assertions stable
* for developers running the suite locally in non-UTC timezones.
*/
function createTimestamps(duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const now = new Date();
const start = createUtcTimestampFromDateParts(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
9
);
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
@@ -200,32 +186,6 @@ function formatTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function createUtcTimestampFromDateParts(
year: number,
month: number,
date: number,
hours: number,
minutes: number = 0,
seconds: number = 0
): Date {
return new Date(Date.UTC(year, month, date, hours, minutes, seconds));
}
function createTimestampsOnDate(date: Date, duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const start = createUtcTimestampFromDateParts(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
start: formatTimestamp(start),
end: formatTimestamp(end),
};
}
function randomColor(): string {
const colors = [
'#ef5350',
@@ -415,39 +375,6 @@ export async function createTimeEntryViaApi(
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createTimeEntryOnDateViaApi(
ctx: TestContext,
data: {
date: Date;
duration: string;
description?: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const { start, end } = createTimestampsOnDate(data.date, data.duration);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createProjectMemberViaApi(
ctx: TestContext,
projectId: string,
@@ -686,72 +613,6 @@ export async function getInvitationsViaApi(ctx: TestContext) {
// Timestamp-based time entry helpers
// ──────────────────────────────────────────────────
/**
* Creates a time entry on `date` at a specific UTC hour with a duration
* in seconds. Playwright test users default to the UTC timezone, so this
* keeps time-placement scenarios stable across runner locales.
*/
export async function createTimeEntryAtHourViaApi(
ctx: TestContext,
data: {
date: Date;
startHour: number;
startMinute?: number;
durationSeconds: number;
projectId?: string | null;
taskId?: string | null;
description?: string;
}
) {
const start = createUtcTimestampFromDateParts(
data.date.getUTCFullYear(),
data.date.getUTCMonth(),
data.date.getUTCDate(),
data.startHour,
data.startMinute ?? 0
);
const end = new Date(start.getTime() + data.durationSeconds * 1000);
return createTimeEntryWithTimestampsViaApi(ctx, {
start: formatTimestamp(start),
end: formatTimestamp(end),
projectId: data.projectId ?? null,
taskId: data.taskId ?? null,
description: data.description ?? '',
});
}
/**
* Reads time entries for the current member, optionally filtered to a
* date range. Returns the raw API objects (id, start, end, project_id,
* etc.) so tests can assert on the database state after a UI action.
*/
export async function getTimeEntriesViaApi(
ctx: TestContext,
filters: { start?: string; end?: string } = {}
): Promise<
Array<{
id: string;
start: string;
end: string | null;
duration: number | null;
project_id: string | null;
task_id: string | null;
description: string;
}>
> {
const params = new URLSearchParams();
params.set('member_id', ctx.memberId);
if (filters.start) params.set('start', filters.start);
if (filters.end) params.set('end', filters.end);
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries?${params.toString()}`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function createTimeEntryWithTimestampsViaApi(
ctx: TestContext,
data: {
@@ -788,6 +649,19 @@ export async function createTimeEntryWithTimestampsViaApi(
// User profile helpers
// ──────────────────────────────────────────────────
export async function getCurrentUserViaApi(ctx: TestContext) {
const response = await ctx.request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me`);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as {
id: string;
name: string;
email: string;
timezone: string;
week_start: string;
};
}
export async function updateUserProfileViaWeb(
page: Page,
settings: { timezone?: string; week_start?: string }

View File

@@ -46,7 +46,9 @@ export async function getInvitationAcceptUrl(
expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID);
const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/);
const acceptUrlMatch = message.HTML.match(
/href="([^"]*(?:organization-invitations|team-invitations)[^"]*)"/
);
expect(acceptUrlMatch).toBeTruthy();
return acceptUrlMatch![1].replace(/&amp;/g, '&');
@@ -79,3 +81,64 @@ export async function getPasswordResetUrl(
return resetUrlMatch![1].replace(/&amp;/g, '&');
}
/**
* Count emails matching the given subject sent to the given address.
*/
export async function countEmailsWithSubject(
request: APIRequestContext,
recipientEmail: string,
subject: string
): Promise<number> {
const searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"${subject}"`
);
return searchResult.messages.length;
}
/**
* Poll Mailpit until the count of matching emails reaches `min`, or 5 attempts
* (~2.5s) elapse. Returns the final count.
*/
export async function waitForEmailCount(
request: APIRequestContext,
recipientEmail: string,
subject: string,
min: number
): Promise<number> {
let count = 0;
for (let attempt = 0; attempt < 5; attempt++) {
count = await countEmailsWithSubject(request, recipientEmail, subject);
if (count >= min) break;
await new Promise((r) => setTimeout(r, 500));
}
return count;
}
/**
* Find the email-change verification URL from a Mailpit email sent to the given address.
* Retries a few times to allow for email delivery delay.
*/
export async function getEmailChangeVerificationUrl(
request: APIRequestContext,
recipientEmail: string
): Promise<string> {
let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };
for (let attempt = 0; attempt < 5; attempt++) {
searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"Verify Email Address"`
);
if (searchResult.messages.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID);
const verifyUrlMatch = message.HTML.match(/href="([^"]*verify-email-change[^"]*)"/);
expect(verifyUrlMatch).toBeTruthy();
return verifyUrlMatch![1].replace(/&amp;/g, '&');
}

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