Compare commits

..

41 Commits

Author SHA1 Message Date
Gregor Vostrak
3c9159f2d4 Conditionally show cost column in report tables; Task/Project Modal
Field cleanup; improve estimated time UX
2026-02-11 17:11:05 +01:00
Gregor Vostrak
abfa7cea0d improve format settings e2e test consistency; improve euro icon sizing
consistency
2026-02-10 17:51:37 +01:00
Gregor Vostrak
bfc33b48c1 make sure that 404 current time entry requests do not override local
state while preparing new time entry
2026-02-10 17:21:18 +01:00
Gregor Vostrak
dc98151d42 responsive time entry modal fixes 2026-02-10 17:18:49 +01:00
Gregor Vostrak
e038870bc4 fix reporting tab selectors in e2e test 2026-02-10 15:20:59 +01:00
Gregor Vostrak
6f8f46f375 use frankenphp in the playwright tests CI to handle parallel requests
better
2026-02-10 14:57:31 +01:00
Gregor Vostrak
6c319fafbc add e2e tests for employee restrictions 2026-02-10 14:41:04 +01:00
Gregor Vostrak
d06b0633d3 add sharding for e2e tests in CI 2026-02-10 13:21:05 +01:00
Gregor Vostrak
2c4af95ee3 Add Tag Edit Modal and UI 2026-02-10 13:19:30 +01:00
Gregor Vostrak
215957104f Add Euro Symbol as Billable Icon when EUR is the organization currency.
fixes #423
2026-02-10 12:47:27 +01:00
Gregor Vostrak
fd012e7c69 Add Field component system and migrate UI 2026-02-10 12:22:53 +01:00
Gregor Vostrak
1ecb332458 Expand e2e test coverage migrate to API-based data setup 2026-02-10 11:45:08 +01:00
Gregor Vostrak
bbe05ca0d8 improve time estimate input, responsive time entry create modal fixes,
fixes #460, #800
2026-02-06 14:35:52 +01:00
Gregor Vostrak
d2644112c5 Allow updating public_until on already-public reports 2026-02-05 15:48:06 +01:00
Gregor Vostrak
66681066bc migrate datepickers to shadcn, Fixes #877, #807 2026-02-05 15:02:01 +01:00
Gregor Vostrak
f82f5e780c fix desync of checkboxes on the reporting detailed page, fixes #892 2026-02-04 17:22:08 +01:00
Gregor Vostrak
22f3af2b79 Make sure that time entry billable status updates when project changes,
fixes #981
2026-02-04 17:07:46 +01:00
Gregor Vostrak
7d068fecae fix admin panel time entry save and update, fixes #997 2026-02-04 14:32:56 +01:00
Gregor Vostrak
9be97a8f84 Improve Time page responsiveness and compact tags, fixes #896 2026-02-03 19:21:57 +01:00
Gregor Vostrak
03e0377101 fix responsive issues in timetracker recently tracked entries dropdown 2026-02-03 14:30:56 +01:00
Gregor Vostrak
a58becc268 Add calendar query prefetch 2026-02-03 14:12:36 +01:00
Gregor Vostrak
09c3205680 Allow NONE filter value to shared reports and add shared-report tests 2026-02-02 20:42:07 +01:00
Gregor Vostrak
18989a9a8e Add Mailpit SMTP and refine Playwright tests 2026-02-02 16:06:56 +01:00
Gregor Vostrak
98634f4a19 fix Y-Label ui regression from echarts update 2026-02-02 14:55:09 +01:00
Gregor Vostrak
1597b5490a Enable npm workspaces and update dependencies 2026-02-02 03:20:45 +01:00
Gregor Vostrak
72662727c5 Add client_ids filter to time entry export 2026-02-02 01:57:40 +01:00
Gregor Vostrak
0d3978a55d Add reporting e2e helpers and detailed tests 2026-02-02 01:31:35 +01:00
Gregor Vostrak
fe8c7e9a7d Update openapi api client spec 2026-02-02 01:28:42 +01:00
Gregor Vostrak
66dfc511a9 add no project, no task, no client, no task, no tag support to the API 2026-02-02 01:16:28 +01:00
Gregor Vostrak
8524e01033 refactor: extract ReportingFilterBar and migrate reporting to TanStack Query 2026-02-02 01:03:00 +01:00
Gregor Vostrak
bca1e8b3b5 migrate select/multiselect components to Radix Vue primitives 2026-02-02 00:56:06 +01:00
Gregor Vostrak
44bcce97cf fix styling inconsistencies 2026-01-27 20:49:30 +01:00
Gregor Vostrak
99400ca655 fix: display custom billable rate correctly on project detail page 2026-01-27 20:11:35 +01:00
Gregor Vostrak
672c243c91 add command palette 2026-01-27 18:29:40 +01:00
Gregor Vostrak
3fb75ec3d5 add outline and secondary variants to TimeTrackerStartStop button to reduce visual complexity 2026-01-15 19:17:55 +01:00
Gregor Vostrak
79999fde28 remove redundant projects pinia store after tanstack query migration 2026-01-14 19:41:07 +01:00
Gregor Vostrak
b2a04c8de5 load time entries above pagination limit for calendar, fixes #995 2026-01-14 19:22:17 +01:00
Gregor Vostrak
900ee29a6f fix e2e project filtering in reporting e2e test 2026-01-14 18:58:28 +01:00
Gregor Vostrak
ebbc4e6837 use tanstack query in ProjectMultiselectDropdown, ClientTableRow and ProjectDropdown; fix e2e 2026-01-14 18:21:29 +01:00
Gregor Vostrak
6f68bbbd48 refactor timeentries queries and mutations, improve activitygraph, add dashboard reporting table 2026-01-14 17:01:45 +01:00
Gregor Vostrak
47c2d8e6de upgrade inertia v2; add prefetching; migrate queries to tanstack query
vue
2026-01-09 03:32:42 +01:00
434 changed files with 6408 additions and 20523 deletions

View File

@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
TELESCOPE_ENABLED=false TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://localhost:3000 GOTENBERG_URL=http://0.0.0.0:3000
# Octane # Octane
OCTANE_SERVER=frankenphp OCTANE_SERVER=frankenphp

View File

@@ -77,9 +77,6 @@ TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://gotenberg:3000 GOTENBERG_URL=http://gotenberg:3000
# Octane
OCTANE_SERVER=frankenphp
# Local setup # Local setup
NGINX_HOST_NAME=solidtime.test NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing NETWORK_NAME=reverse-proxy-docker-traefik_routing

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardIndex: [1, 2, 3, 4]
shardTotal: [8] shardTotal: [4]
services: services:
mailpit: mailpit:
@@ -35,10 +35,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'

1
.gitignore vendored
View File

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

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source TimeTracker # solidtime - The modern Open-Source Time Tracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md) [![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime) [![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)
@@ -37,8 +37,6 @@ If you have a **feature request**, please [**create a discussion**](https://gith
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons. Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides. We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

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

View File

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

View File

@@ -4,9 +4,18 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException; use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\MemberService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Contracts\AddsTeamMembers;
class AddOrganizationMember implements AddsTeamMembers class AddOrganizationMember implements AddsTeamMembers
@@ -16,6 +25,70 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{ {
throw new MovedToApiException; 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.')
);
};
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
<?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

@@ -1,28 +0,0 @@
<?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

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{
public const string KEY = 'user_resend_email_verification_no_pending_email';
}

View File

@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
TextInput::make('queue')->disabled(), TextInput::make('queue')->disabled(),
// make text a little bit smaller because often a complete Stack Trace is shown: // make text a little bit smaller because often a complete Stack Trace is shown:
Textarea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']), TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4), PrettyJsonField::make('payload')->disabled()->columnSpan(4),
])->columns(4); ])->columns(4);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
/** /**
* List all api token of the currently authenticated user * List all api token of the currently authenticated user
* *
* This endpoint is independent of the organization. * This endpoint is independent of organization.
* *
* @operationId getApiTokens * @operationId getApiTokens
* *
@@ -35,7 +35,6 @@ class ApiTokenController extends Controller
/** @var Builder<Client> $query */ /** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access'); $query->whereJsonContains('grant_types', 'personal_access');
}) })
->orderBy('created_at', 'desc')
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);

View File

@@ -41,7 +41,6 @@ class InvitationController extends Controller
$this->checkPermission($organization, 'invitations:view'); $this->checkPermission($organization, 'invitations:view');
$invitations = $organization->teamInvitations() $invitations = $organization->teamInvitations()
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations); return InvitationCollection::make($invitations);

View File

@@ -60,7 +60,6 @@ class MemberController extends Controller
$members = Member::query() $members = Member::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with(['user']) ->with(['user'])
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members); return MemberCollection::make($members);

View File

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

View File

@@ -60,9 +60,7 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at'); $projectsQuery->whereNull('archived_at');
} }
$projects = $projectsQuery $projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
@@ -78,7 +76,7 @@ class ProjectController extends Controller
*/ */
public function show(Organization $organization, Project $project): JsonResource public function show(Organization $organization, Project $project): JsonResource
{ {
$this->checkPermission($organization, 'projects:view:all', $project); $this->checkPermission($organization, 'projects:view', $project);
// Note: There is currently no need to check if a user is a member of the project, // Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint. // since this is only relevant for users with the role "employee" and they can not access this endpoint.

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection; use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
@@ -42,13 +41,12 @@ class ProjectMemberController extends Controller
* *
* @operationId getProjectMembers * @operationId getProjectMembers
*/ */
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection public function index(Organization $organization, Project $project): ProjectMemberCollection
{ {
$this->checkPermission($organization, 'project-members:view', $project); $this->checkPermission($organization, 'project-members:view', $project);
$projectMembers = ProjectMember::query() $projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers); return new ProjectMemberCollection($projectMembers);

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\Report\ReportIndexRequest;
use App\Http\Requests\V1\Report\ReportStoreRequest; use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\DetailedReportResource;
@@ -41,7 +40,7 @@ class ReportController extends Controller
* *
* @operationId getReports * @operationId getReports
*/ */
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection public function index(Organization $organization): ReportCollection
{ {
$this->checkPermission($organization, 'reports:view'); $this->checkPermission($organization, 'reports:view');

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagIndexRequest;
use App\Http\Requests\V1\Tag\TagStoreRequest; use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest; use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Tag\TagCollection; use App\Http\Resources\V1\Tag\TagCollection;
@@ -35,7 +34,7 @@ class TagController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function index(Organization $organization, TagIndexRequest $request): TagCollection public function index(Organization $organization): TagCollection
{ {
$this->checkPermission($organization, 'tags:view'); $this->checkPermission($organization, 'tags:view');

View File

@@ -82,9 +82,7 @@ class TaskController extends Controller
$query->whereNull('done_at'); $query->whereNull('done_at');
} }
$tasks = $query $tasks = $query->paginate(config('app.pagination_per_page_default'));
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks); return new TaskCollection($tasks);
} }

View File

@@ -53,13 +53,12 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{ {
if (! $organization->prevent_overlapping_time_entries) { if (! $organization->prevent_overlapping_time_entries) {
return; return;
@@ -247,7 +246,7 @@ class TimeEntryController extends Controller
'user', 'user',
'tagsRelation', 'tagsRelation',
]); ]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension(); $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization); $localizationService = LocalizationService::forOrganization($organization);
@@ -470,7 +469,7 @@ class TimeEntryController extends Controller
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization); $localizationService = LocalizationService::forOrganization($organization);
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension(); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
@@ -629,9 +628,9 @@ class TimeEntryController extends Controller
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry); $this->checkPermission($organization, 'time-entries:update:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry); $this->checkPermission($organization, 'time-entries:update:all');
} }
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -4,26 +4,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Http\Requests\V1\User\UserUpdateRequest;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User;
use App\Service\DeletionService;
use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller class UserController extends Controller
{ {
/** /**
* Get the current user * Get the current user
* *
* This endpoint is independent of the organization. * This endpoint is independent of organization.
* *
* @operationId getMe * @operationId getMe
* *
@@ -35,119 +24,4 @@ class UserController extends Controller
return new UserResource($user); return new UserResource($user);
} }
/**
* Update the current user
*
* This endpoint is independent of the organization.
*
* @operationId updateUser
*/
public function update(User $user, UserUpdateRequest $request): UserResource
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($request->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);
}
/**
* Resend the pending email update verification email.
*
* This endpoint is independent of the organization.
*
* @operationId resendUserEmailVerification
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
*/
public function resendEmailVerification(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($user->pending_email === null) {
throw new UserResendEmailVerificationNoPendingEmailApiException;
}
Mail::to($user->pending_email)
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
return response()->json(null, 204);
}
/**
* Handles the deletion of a user.
*
* This endpoint is independent of the organization.
*
* @operationId deleteUser
*
* @param User $user The user instance to be deleted.
* @param DeletionService $deletionService The service responsible for performing the user deletion.
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/
public function destroy(User $user, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$deletionService->deleteUser($user);
return response()->json(null, 204);
}
} }

View File

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

View File

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

View File

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

View File

@@ -1,75 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,11 +21,6 @@ class InvitationIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
]; ];
} }
} }

View File

@@ -21,11 +21,6 @@ class MemberIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
]; ];
} }
} }

View File

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

View File

@@ -1,36 +0,0 @@
<?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

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ProjectMemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ReportIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class TagIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -26,11 +26,6 @@ class TaskIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
'project_id' => [ 'project_id' => [
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */

View File

@@ -1,95 +0,0 @@
<?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

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Client; namespace App\Http\Resources\V1\Client;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection class ClientCollection extends ResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Tag; namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection implements PaginatedResourceCollection class TagCollection extends ResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -28,8 +28,6 @@ class UserResource extends BaseResource
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string $email Email of user */ /** @var string $email Email of user */
'email' => $this->resource->email, '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 */ /** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url, 'profile_photo_url' => $this->resource->profile_photo_url,
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */ /** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ namespace App\Policies;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
@@ -59,7 +58,19 @@ class OrganizationPolicy
return true; return true;
} }
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); return $user->ownsTeam($organization);
}
/**
* Determine whether the user can add team members.
*/
public function addTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return true;
} }
/** /**

View File

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

View File

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

View File

@@ -13,18 +13,20 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization; use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion; use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole; use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion; use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -58,6 +60,13 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useTeamInvitationModel(OrganizationInvitation::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class); app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'),
]);
});
Gate::define('removeTeamMember', function (User $user, Organization $team) { Gate::define('removeTeamMember', function (User $user, Organization $team) {
return false; return false;
}); });
@@ -70,10 +79,205 @@ class JetstreamServiceProvider extends ServiceProvider
{ {
Jetstream::defaultApiTokenPermissions([]); Jetstream::defaultApiTokenPermissions([]);
foreach (PermissionStore::roleDefinitions() as $role => $definition) { Jetstream::role(Role::Owner->value, 'Owner', [
Jetstream::role($role, $definition['name'], $definition['permissions']) 'charts:view:own',
->description($definition['description']); '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.');
Jetstream::inertia() Jetstream::inertia()
->whenRendering( ->whenRendering(
@@ -100,8 +304,28 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [ 'owner' => [
'id' => $owner->getKey(), 'id' => $owner->getKey(),
'name' => $owner->name, 'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url, 'profile_photo_url' => $owner->profile_photo_url,
], ],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
], ],
'currencies' => array_map(function (Currency $currency): string { 'currencies' => array_map(function (Currency $currency): string {
return $currency->getName(); return $currency->getName();

View File

@@ -1,45 +0,0 @@
<?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

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

View File

@@ -96,30 +96,6 @@ class LocalizationService
} }
} }
/**
* Format a duration for reporting contexts (PDF reports, places that display duration
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
* so totals stay narrow and reconcile with cost, which is always computed to the second.
*/
public function formatIntervalForReporting(CarbonInterval $interval): string
{
$promoted = [
IntervalFormat::HoursMinutes,
IntervalFormat::HoursMinutesColonSeparated,
];
if (! in_array($this->intervalFormat, $promoted, true)) {
return $this->formatInterval($interval);
}
$previous = $this->intervalFormat;
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
try {
return $this->formatInterval($interval);
} finally {
$this->intervalFormat = $previous;
}
}
public function formatCurrency(Money $money): string public function formatCurrency(Money $money): string
{ {
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);

View File

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

View File

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

View File

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

View File

@@ -31,17 +31,12 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0'); throw new LogicException('Rounding minutes must be greater than 0');
} }
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')'; $end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) { if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')'; return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) { } elseif ($roundingType === TimeEntryRoundingType::Up) {
// If end is already on a boundary, keep it; otherwise round up to next boundary return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) { } elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')'; return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} }
} }
} }

View File

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

View File

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

4553
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
<?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

@@ -1,30 +0,0 @@
<?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

@@ -1,64 +0,0 @@
<?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

@@ -1,689 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
}
async function openSettingsPopover(page: Page) {
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
}
async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
}
function getCalendarTitle(page: Page) {
return page.getByTestId('calendar-title');
}
async function scrollCalendarToTime(page: Page, time: string) {
await page.evaluate((t) => {
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
if (slot) slot.scrollIntoView({ block: 'start' });
}, time);
await page.waitForTimeout(300);
}
async function getSlotHeight(page: Page): Promise<number> {
return await page.evaluate(() => {
const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane'));
for (let i = 0; i < slots.length; i++) {
const h = slots[i].getBoundingClientRect().height;
if (h > 0) return h;
}
return 20;
});
}
test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('settings popover shows all fields with correct defaults', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
});
test('snap interval can be changed and persists across reload', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change snap interval to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
// Close the popover by pressing Escape
await page.keyboard.press('Escape');
// Verify localStorage was updated
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
);
expect(stored.snapMinutes).toBe(30);
// Reload and verify persistence
await page.reload();
await expect(page.locator('.fc')).toBeVisible();
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
});
test('start time change is applied to calendar and rejects invalid values', async ({
page,
}) => {
await goToCalendar(page);
// Verify 7 AM slot exists with default start (00:00)
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set end time to 6 PM first
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Try to set start time to 6 PM (invalid: equals end time) — should be rejected
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00)
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set start time to 8 AM first
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Change end time to 6 PM (valid)
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Try to set end time to 8 AM (invalid: equals start time) — should be rejected
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects number of calendar slots', async ({ page }) => {
await goToCalendar(page);
// Count slots with default 15-min scale
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change to 30 min scale (should halve the slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
// Navigate away and back to get a clean calendar mount
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Change to 5 min scale (many more slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeGreaterThan(largerSlotCount);
}).toPass({ timeout: 5000 });
});
test('all settings persist across navigation', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change every setting
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 AM' }).click();
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
// Close the popover
await page.keyboard.press('Escape');
// Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Verify all settings persisted
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
});
});
test.describe('Calendar Toolbar', () => {
test('prev and next buttons navigate the calendar', async ({ page }) => {
await goToCalendar(page);
// Use column headers to detect navigation (title only shows month which may not change)
const getHeaderTexts = async () => {
const headers = page.locator('.fc-col-header-cell');
return headers.allTextContents();
};
const initialHeaders = await getHeaderTexts();
// Click next
await page.getByRole('button', { name: 'Next', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const nextHeaders = await getHeaderTexts();
expect(nextHeaders).not.toEqual(initialHeaders);
// Click prev — should go back to original
await page.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const backHeaders = await getHeaderTexts();
expect(backHeaders).toEqual(initialHeaders);
});
test('today button returns to current week', async ({ page }) => {
await goToCalendar(page);
// Use column headers to detect navigation (title only shows month which may not change)
const getHeaderTexts = async () => {
const headers = page.locator('.fc-col-header-cell');
return headers.allTextContents();
};
const initialHeaders = await getHeaderTexts();
// Navigate away
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByRole('button', { name: 'Next', exact: true }).click();
const awayHeaders = await getHeaderTexts();
expect(awayHeaders).not.toEqual(initialHeaders);
// Click today
await page.getByRole('button', { name: 'today', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const todayHeaders = await getHeaderTexts();
expect(todayHeaders).toEqual(initialHeaders);
});
test('view switcher toggles between week and day views', async ({ page }) => {
await goToCalendar(page);
// Default should be week view — verify multiple day columns exist
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
// Switch to day view
await page.getByRole('tab', { name: 'day', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Day view should show exactly 1 day column
await expect(page.locator('.fc-col-header-cell')).toHaveCount(1);
// Switch back to week view
await page.getByRole('tab', { name: 'week', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Week view should show multiple day columns again
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
});
});
test.describe('Visual Snapping', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 1 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot at a non-15-min boundary time
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event to a position offset from the 15-min boundary
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 1-min snap, any minute value is valid (0-59)
expect(minutes).toBeGreaterThanOrEqual(0);
expect(minutes).toBeLessThanOrEqual(59);
});
test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 60 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 60-min snap, minutes should be 0 (on the hour)
expect(minutes).toBe(0);
});
test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h');
await goToCalendar(page);
// Set snap to 15 min
await openSettingsPopover(page);
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '15 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Drag event to 14:00 area
const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox14 = await targetSlot14.boundingBox();
expect(targetBox14).not.toBeNull();
const putResponsePromise1 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse1 = await putResponsePromise1;
expect(putResponse1.status()).toBe(200);
const body1 = await putResponse1.json();
const startDate1 = new Date(body1.data.start);
expect(startDate1.getMinutes() % 15).toBe(0);
// Wait for query re-fetch/re-renders to fully settle after drag
await page.waitForTimeout(1500);
// Change snap to 30 min
// Use Escape first to ensure no stale popover is open, then re-open
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await openSettingsPopover(page);
await page.waitForTimeout(300);
await page.getByLabel('Snap Interval').click({ force: true });
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 10:00 target area is visible
await scrollCalendarToTime(page, '09:00:00');
// Drag event to 10:00 area
const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
const targetBox10 = await targetSlot10.boundingBox();
expect(targetBox10).not.toBeNull();
const putResponsePromise2 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse2 = await putResponsePromise2;
expect(putResponse2.status()).toBe(200);
const body2 = await putResponse2.json();
const startDate2 = new Date(body2.data.start);
expect(startDate2.getMinutes() % 30).toBe(0);
});
test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set grid scale to 30 min, snap to 5 min
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Wait for re-render with 30-min grid
await expect(async () => {
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// 24 hours * 2 slots/hour = 48 slots for 30-min grid
expect(slotCount).toBeLessThanOrEqual(48);
}).toPass({ timeout: 5000 });
// Verify grid is 30-min (fewer slots than default 15-min)
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// Default 15-min grid has 96 slots; 30-min grid should have 48
expect(slotCount).toBeLessThanOrEqual(48);
// Create a 1h time entry and go to calendar
await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h');
await goToCalendar(page);
// Re-apply settings since goToCalendar navigates
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Scroll so both the event (9:00) and target (14:00) are in viewport
await scrollCalendarToTime(page, '08:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Capture target coordinates after scroll is settled
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
// Snap is 5 min, so minutes should be divisible by 5
expect(startDate.getMinutes() % 5).toBe(0);
});
});
test.describe('Calendar Settings Effects', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('start/end time hides slots outside visible range', async ({ page, ctx }) => {
// Create a time entry at 6 AM today
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0);
const end = new Date(start.getTime() + 3600 * 1000); // 7 AM
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Early morning entry',
start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'),
end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'),
});
await goToCalendar(page);
// Verify 6 AM slot is visible with default settings
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0);
// Set start time to 8 AM
await openSettingsPopover(page);
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// 6 AM slot should be hidden
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0);
// 8 AM slot should be visible
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects event visual height proportionally', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Height test', '1h');
await goToCalendar(page);
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
await event.scrollIntoViewIfNeeded();
// Get event height with default 15-min grid scale
const box15 = await event.boundingBox();
expect(box15).not.toBeNull();
const height15 = box15!.height;
// Change grid scale to 60 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Wait for re-render and scroll event into view
await event.scrollIntoViewIfNeeded();
await expect(async () => {
const box = await event.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).not.toBe(height15);
}).toPass({ timeout: 5000 });
const box60 = await event.boundingBox();
expect(box60).not.toBeNull();
const height60 = box60!.height;
// Event should appear smaller with larger grid scale
expect(height15).toBeGreaterThan(height60);
});
test('snap interval affects drag granularity', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 30-min snap, minutes should be 0 or 30
expect(minutes % 30).toBe(0);
});
test('settings apply immediately without page reload', async ({ page }) => {
await goToCalendar(page);
// Count slots with default grid scale (15 min)
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change grid scale to 30 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Verify slot count changed without navigation
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
// Wait for FullCalendar to fully stabilize after re-render
await page.waitForTimeout(2000);
await expect(page.locator('.fc')).toBeVisible();
// Change start time to 8 AM
// FullCalendar re-render from grid scale change can make popover elements unstable.
// Retry the open+click sequence if it fails.
await expect(async () => {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
const startTimeBtn = page.getByLabel('Start Time');
await expect(startTimeBtn).toBeVisible();
await startTimeBtn.click({ timeout: 3000 });
}).toPass({ timeout: 10000 });
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// Verify 7 AM slot is hidden without reload
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count();
expect(count).toBe(0);
}).toPass({ timeout: 5000 });
});
});

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