mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 13:12:16 +01:00
Compare commits
85 Commits
feature/e2
...
dad686d107
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dad686d107 | ||
|
|
414b5d3294 | ||
|
|
e9217df338 | ||
|
|
96a0c21b5e | ||
|
|
8e7c8a1e1b | ||
|
|
6299e242a9 | ||
|
|
c573d31ef9 | ||
|
|
00ffabe108 | ||
|
|
5b756be058 | ||
|
|
dc70eb7130 | ||
|
|
c2a8eac65f | ||
|
|
28ecfc63a3 | ||
|
|
433a6f3770 | ||
|
|
0ba20fd24c | ||
|
|
3267acb161 | ||
|
|
7d9ecd9526 | ||
|
|
3a17f80f99 | ||
|
|
e29ea2ea42 | ||
|
|
fb6e4639ce | ||
|
|
69bc41988a | ||
|
|
f7663b1c8b | ||
|
|
793bd11dcf | ||
|
|
77a62afd69 | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c | ||
|
|
797fddf638 | ||
|
|
d07294ae7c | ||
|
|
1f49940805 | ||
|
|
6be6a48e0d | ||
|
|
b94a04dca0 | ||
|
|
bd3b8f265f | ||
|
|
c19a0f9acc | ||
|
|
5c6d84dc38 | ||
|
|
5c67709746 | ||
|
|
a2b0828c54 | ||
|
|
b94872b07b | ||
|
|
12bbbf64e9 | ||
|
|
c07ac4b0e4 | ||
|
|
a58566d002 | ||
|
|
57ed6036e6 | ||
|
|
ef7569b63b | ||
|
|
19c789b78e | ||
|
|
49548037b3 | ||
|
|
97df779d1e | ||
|
|
a1d5563fc4 | ||
|
|
c94ca804f8 | ||
|
|
189682cfaf | ||
|
|
8d16503541 | ||
|
|
e43ce477b8 | ||
|
|
5646aedb25 | ||
|
|
2b46e568e0 | ||
|
|
89a4a1962a | ||
|
|
c581ad8854 | ||
|
|
bce6cb9395 | ||
|
|
1cdae98ed9 | ||
|
|
02f6436fd0 | ||
|
|
452acca942 | ||
|
|
192c8c3b88 | ||
|
|
6218ffceb5 | ||
|
|
ba32be0543 | ||
|
|
bd817db06f | ||
|
|
97f4bce676 | ||
|
|
6962b668fb | ||
|
|
be8091296c |
2
.env.ci
2
.env.ci
@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
|
|||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
GOTENBERG_URL=http://localhost:3000
|
||||||
|
|
||||||
# Octane
|
# Octane
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ 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
|
||||||
|
|||||||
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|||||||
10
.github/workflows/build-private.yml
vendored
10
.github/workflows/build-private.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -68,12 +68,12 @@ jobs:
|
|||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-billing
|
repository: solidtime-io/extension-billing
|
||||||
path: extensions/Billing
|
path: extensions/Billing
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
run: cd extensions/Billing && npm ci
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
- name: "Checkout services extension"
|
- name: "Checkout services extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-services
|
repository: solidtime-io/extension-services
|
||||||
path: extensions/Services
|
path: extensions/Services
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-invoicing
|
repository: solidtime-io/extension-invoicing
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
|
|||||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|||||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: "Run composer install"
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v5.4.3
|
uses: codecov/codecov-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.5
|
uses: aglipanci/laravel-pint-action@2.6
|
||||||
with:
|
with:
|
||||||
configPath: "pint.json"
|
configPath: "pint.json"
|
||||||
|
|||||||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ yarn-error.log
|
|||||||
/data
|
/data
|
||||||
/config/caddy
|
/config/caddy
|
||||||
/config/composer
|
/config/composer
|
||||||
|
/AGENTS.md
|
||||||
|
|||||||
15
SECURITY.md
15
SECURITY.md
@@ -3,3 +3,18 @@
|
|||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
|
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
|
||||||
|
Reports we typically won't issue an advisory for:
|
||||||
|
|
||||||
|
* Theoretical findings without a working PoC
|
||||||
|
* Raw scanner output without manual validation
|
||||||
|
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
|
||||||
|
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
|
||||||
|
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
|
||||||
|
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
|
||||||
|
* Org owners or admins acting destructively within their own organization
|
||||||
|
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
|
||||||
|
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
@@ -24,6 +27,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
if (isset($input['email']) && is_string($input['email'])) {
|
||||||
|
$input['email'] = Str::lower($input['email']);
|
||||||
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -58,16 +65,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
$user->updateProfilePhoto($input['photo']);
|
$user->updateProfilePhoto($input['photo']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($input['email'] !== $user->email) {
|
$email = Str::lower((string) $input['email']);
|
||||||
|
|
||||||
|
if ($email !== Str::lower($user->email)) {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'pending_email' => $email,
|
||||||
'email_verified_at' => null,
|
|
||||||
'timezone' => $input['timezone'],
|
'timezone' => $input['timezone'],
|
||||||
'week_start' => $input['week_start'],
|
'week_start' => $input['week_start'],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$user->sendEmailVerificationNotification();
|
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
|
||||||
} else {
|
} else {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
|||||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Exceptions\MovedToApiException;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\Rules\In;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
|
||||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
class AddOrganizationMember implements AddsTeamMembers
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
throw new MovedToApiException;
|
||||||
|
|
||||||
$this->validate($organization, $email, $role);
|
|
||||||
|
|
||||||
$newOrganizationMember = User::query()
|
|
||||||
->where('email', $email)
|
|
||||||
->where('is_placeholder', '=', false)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the add member operation.
|
|
||||||
*/
|
|
||||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
|
||||||
{
|
|
||||||
Validator::make([
|
|
||||||
'email' => $email,
|
|
||||||
'role' => $role,
|
|
||||||
], $this->rules())->after(
|
|
||||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
|
||||||
)->validateWithBag('addTeamMember');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules for adding a team member.
|
|
||||||
*
|
|
||||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
|
||||||
*/
|
|
||||||
protected function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email' => [
|
|
||||||
'required',
|
|
||||||
'email',
|
|
||||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
|
||||||
/** @var Builder<User> $builder */
|
|
||||||
return $builder->where('is_placeholder', '=', false);
|
|
||||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
|
||||||
],
|
|
||||||
'role' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
Rule::in([
|
|
||||||
Role::Admin->value,
|
|
||||||
Role::Manager->value,
|
|
||||||
Role::Employee->value,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure that the user is not already on the team.
|
|
||||||
*/
|
|
||||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
|
||||||
{
|
|
||||||
return function ($validator) use ($team, $email): void {
|
|
||||||
$validator->errors()->addIf(
|
|
||||||
$team->hasRealUserWithEmail($email),
|
|
||||||
'email',
|
|
||||||
__('This user already belongs to the team.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function create(User $user, array $input): Organization
|
public function create(User $user, array $input): Organization
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $organization): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
|||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
|||||||
* @param Organization $organization Organization to be deleted
|
* @param Organization $organization Organization to be deleted
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function validate(User $user, Organization $organization): void
|
public function validate(User $user, Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class MemberAdded
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public Member $member;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public function __construct(Member $member, Organization $organization, User $user)
|
||||||
|
{
|
||||||
|
$this->member = $member;
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class MemberAdding
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public Role $role;
|
||||||
|
|
||||||
|
public function __construct(User $user, Organization $organization, Role $role)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->role = $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class OrganizationInvitationResource extends Resource
|
|||||||
->required(),
|
->required(),
|
||||||
Select::make('role')
|
Select::make('role')
|
||||||
->options(Role::class),
|
->options(Role::class),
|
||||||
Forms\Components\Select::make('organization_id')
|
Select::make('organization_id')
|
||||||
->label('Organization')
|
->label('Organization')
|
||||||
->relationship(name: 'organization', titleAttribute: 'name')
|
->relationship(name: 'organization', titleAttribute: 'name')
|
||||||
->searchable(['name'])
|
->searchable(['name'])
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class OrganizationResource extends Resource
|
|||||||
->label('Is personal?')
|
->label('Is personal?')
|
||||||
->hiddenOn(['create'])
|
->hiddenOn(['create'])
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\Select::make('user_id')
|
Select::make('user_id')
|
||||||
->label('Owner')
|
->label('Owner')
|
||||||
->relationship(name: 'owner', titleAttribute: 'email')
|
->relationship(name: 'owner', titleAttribute: 'email')
|
||||||
->searchable(['name', 'email'])
|
->searchable(['name', 'email'])
|
||||||
@@ -76,7 +76,7 @@ class OrganizationResource extends Resource
|
|||||||
Select::make('time_format')
|
Select::make('time_format')
|
||||||
->options(TimeFormat::toSelectArray())
|
->options(TimeFormat::toSelectArray())
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\Select::make('currency')
|
Select::make('currency')
|
||||||
->label('Currency')
|
->label('Currency')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||||
@@ -114,22 +114,22 @@ class OrganizationResource extends Resource
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\IconColumn::make('personal_team')
|
Tables\Columns\IconColumn::make('personal_team')
|
||||||
->boolean()
|
->boolean()
|
||||||
->label('Is personal?')
|
->label('Is personal?')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('owner.email')
|
TextColumn::make('owner.email')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('currency'),
|
TextColumn::make('currency'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('billable_rate')
|
||||||
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('updated_at')
|
TextColumn::make('updated_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
@@ -223,7 +223,7 @@ class OrganizationResource extends Resource
|
|||||||
|
|
||||||
return $select;
|
return $select;
|
||||||
}),
|
}),
|
||||||
Forms\Components\Select::make('timezone')
|
Select::make('timezone')
|
||||||
->label('Timezone')
|
->label('Timezone')
|
||||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||||
->searchable()
|
->searchable()
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ class UsersRelationManager extends RelationManager
|
|||||||
return $table
|
return $table
|
||||||
->recordTitleAttribute('name')
|
->recordTitleAttribute('name')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name'),
|
TextColumn::make('name'),
|
||||||
Tables\Columns\TextColumn::make('role'),
|
TextColumn::make('role'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('billable_rate')
|
||||||
->money($organization->currency, divideBy: 100),
|
->money($organization->currency, divideBy: 100),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Tables\Actions\AttachAction::make()
|
AttachAction::make()
|
||||||
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||||
->form(fn (AttachAction $action): array => [
|
->form(fn (AttachAction $action): array => [
|
||||||
$action->getRecordSelect(),
|
$action->getRecordSelect(),
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ class ReportResource extends Resource
|
|||||||
return $record->getRawOriginal('properties');
|
return $record->getRawOriginal('properties');
|
||||||
})
|
})
|
||||||
->disabled(),
|
->disabled(),
|
||||||
Forms\Components\DateTimePicker::make('created_at')
|
DateTimePicker::make('created_at')
|
||||||
->label('Created At')
|
->label('Created At')
|
||||||
->hiddenOn(['create'])
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
Forms\Components\DateTimePicker::make('updated_at')
|
DateTimePicker::make('updated_at')
|
||||||
->label('Updated At')
|
->label('Updated At')
|
||||||
->hiddenOn(['create'])
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
@@ -78,10 +78,10 @@ class ReportResource extends Resource
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('description')
|
TextColumn::make('description')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
ToggleColumn::make('is_public')
|
ToggleColumn::make('is_public')
|
||||||
@@ -90,10 +90,10 @@ class ReportResource extends Resource
|
|||||||
TextColumn::make('organization.name')
|
TextColumn::make('organization.name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('updated_at')
|
TextColumn::make('updated_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ class TimeEntryResource extends Resource
|
|||||||
($record->end?->toDateTimeString('minute') ?? '...').')';
|
($record->end?->toDateTimeString('minute') ?? '...').')';
|
||||||
})
|
})
|
||||||
->label('Time'),
|
->label('Time'),
|
||||||
Tables\Columns\TextColumn::make('organization.name')
|
TextColumn::make('organization.name')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('updated_at')
|
TextColumn::make('updated_at')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ class UserResource extends Resource
|
|||||||
return $form
|
return $form
|
||||||
->columns(1)
|
->columns(1)
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\TextInput::make('id')
|
TextInput::make('id')
|
||||||
->label('ID')
|
->label('ID')
|
||||||
->disabled()
|
->disabled()
|
||||||
->visibleOn(['update', 'show'])
|
->visibleOn(['update', 'show'])
|
||||||
->readOnly()
|
->readOnly()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\TextInput::make('name')
|
TextInput::make('name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\TextInput::make('email')
|
TextInput::make('email')
|
||||||
->label('Email')
|
->label('Email')
|
||||||
->required()
|
->required()
|
||||||
->rules($record?->is_placeholder ? [] : [
|
->rules($record?->is_placeholder ? [] : [
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
|||||||
/**
|
/**
|
||||||
* List all api token of the currently authenticated user
|
* List all api token of the currently authenticated user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getApiTokens
|
* @operationId getApiTokens
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\AfterCreateOrganization;
|
||||||
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
return new OrganizationResource($organization, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create organization
|
||||||
|
*
|
||||||
|
* @operationId createOrganization
|
||||||
|
*/
|
||||||
|
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||||
|
|
||||||
|
$currency = $ipLookupResponse?->currency;
|
||||||
|
|
||||||
|
$organization = $organizationService->createOrganization(
|
||||||
|
$request->getName(),
|
||||||
|
$user,
|
||||||
|
false,
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->switchTeam($organization);
|
||||||
|
|
||||||
|
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||||
|
$organization = $organization->refresh();
|
||||||
|
AfterCreateOrganization::dispatch($organization);
|
||||||
|
|
||||||
|
return new OrganizationResource($organization, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete organization
|
||||||
|
*
|
||||||
|
* @operationId deleteOrganization
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'organizations:delete');
|
||||||
|
|
||||||
|
$deletionService->deleteOrganization($organization);
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,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', $project);
|
$this->checkPermission($organization, 'projects:view:all', $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.
|
||||||
|
|||||||
@@ -53,12 +53,13 @@ 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, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
|
||||||
{
|
{
|
||||||
if (! $organization->prevent_overlapping_time_entries) {
|
if (! $organization->prevent_overlapping_time_entries) {
|
||||||
return;
|
return;
|
||||||
@@ -246,7 +247,7 @@ class TimeEntryController extends Controller
|
|||||||
'user',
|
'user',
|
||||||
'tagsRelation',
|
'tagsRelation',
|
||||||
]);
|
]);
|
||||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
||||||
$folderPath = 'exports';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
$localizationService = LocalizationService::forOrganization($organization);
|
$localizationService = LocalizationService::forOrganization($organization);
|
||||||
@@ -469,7 +470,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').'.'.$format->getFileExtension();
|
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
||||||
$folderPath = 'exports';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
|
|
||||||
@@ -628,9 +629,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');
|
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all');
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||||
|
|||||||
@@ -4,15 +4,26 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
|
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
|
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Support\Base64File;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the current user
|
* Get the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getMe
|
* @operationId getMe
|
||||||
*
|
*
|
||||||
@@ -24,4 +35,119 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get the memberships of the current user
|
* Get the memberships of the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getMyMemberships
|
* @operationId getMyMemberships
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get the active time entry of the current user
|
* Get the active time entry of the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getMyActiveTimeEntry
|
* @operationId getMyActiveTimeEntry
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Service\DashboardService;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function dashboard(): Response
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
|
||||||
$organization = $this->currentOrganization();
|
|
||||||
|
|
||||||
$latestTeamActivity = null;
|
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OrganizationInvitationController extends Controller
|
||||||
|
{
|
||||||
|
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = strtolower($invitation->email);
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
|
||||||
|
throw new RuntimeException('Invalid role');
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
$invitee = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// No account yet — finish on registration.
|
||||||
|
if ($invitee === null) {
|
||||||
|
if ($invitation->accepted_at === null) {
|
||||||
|
$invitation->accepted_at = now();
|
||||||
|
$invitation->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('register'))
|
||||||
|
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadyMember = $memberService->isEmailAlreadyMember($organization, $email);
|
||||||
|
if (! $alreadyMember) {
|
||||||
|
$memberService->addMember($invitee, $organization, $role);
|
||||||
|
$invitation->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged out — banner on /login.
|
||||||
|
if (! Auth::check()) {
|
||||||
|
return redirect(route('login'))
|
||||||
|
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization. Please log in to access it.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged in — banner on /dashboard.
|
||||||
|
if ($alreadyMember) {
|
||||||
|
return redirect(route('dashboard'))
|
||||||
|
->with('bannerText', __('You are already a member of the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('dashboard'))
|
||||||
|
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Http/Controllers/Web/UserController.php
Normal file
53
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function verifyEmailChange(Request $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $request->query('email');
|
||||||
|
if (! is_string($email)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = Str::lower($email);
|
||||||
|
|
||||||
|
if ($user->pending_email !== $email) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailAlreadyInUse = User::query()
|
||||||
|
->where('email', '=', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->whereKeyNot($user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($emailAlreadyInUse) {
|
||||||
|
return redirect(route('dashboard'))
|
||||||
|
->with('bannerStyle', 'danger')
|
||||||
|
->with('bannerText', __('The email address is already in use.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->email = $email;
|
||||||
|
$user->pending_email = null;
|
||||||
|
$user->email_verified_at = Carbon::now();
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return redirect(route('dashboard'))
|
||||||
|
->with('bannerStyle', 'success')
|
||||||
|
->with('bannerText', __('Your email address has been updated successfully.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,37 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http;
|
namespace App\Http;
|
||||||
|
|
||||||
|
use App\Http\Middleware\Authenticate;
|
||||||
use App\Http\Middleware\CheckOrganizationBlocked;
|
use App\Http\Middleware\CheckOrganizationBlocked;
|
||||||
|
use App\Http\Middleware\EncryptCookies;
|
||||||
|
use App\Http\Middleware\EnsureEmailIsVerified;
|
||||||
|
use App\Http\Middleware\ForceHttps;
|
||||||
use App\Http\Middleware\ForceJsonResponse;
|
use App\Http\Middleware\ForceJsonResponse;
|
||||||
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||||
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
|
use App\Http\Middleware\ShareInertiaData;
|
||||||
|
use App\Http\Middleware\TrimStrings;
|
||||||
|
use App\Http\Middleware\TrustProxies;
|
||||||
|
use App\Http\Middleware\ValidateSignature;
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
|
||||||
|
use Illuminate\Auth\Middleware\Authorize;
|
||||||
|
use Illuminate\Auth\Middleware\RequirePassword;
|
||||||
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
|
||||||
|
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||||
|
use Illuminate\Http\Middleware\HandleCors;
|
||||||
|
use Illuminate\Http\Middleware\SetCacheHeaders;
|
||||||
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||||
|
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
use Laravel\Passport\Http\Middleware\CreateFreshApiToken;
|
||||||
|
|
||||||
class Kernel extends HttpKernel
|
class Kernel extends HttpKernel
|
||||||
{
|
{
|
||||||
@@ -18,13 +46,13 @@ class Kernel extends HttpKernel
|
|||||||
* @var array<int, class-string|string>
|
* @var array<int, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
\App\Http\Middleware\ForceHttps::class,
|
ForceHttps::class,
|
||||||
\App\Http\Middleware\TrustProxies::class,
|
TrustProxies::class,
|
||||||
\Illuminate\Http\Middleware\HandleCors::class,
|
HandleCors::class,
|
||||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
PreventRequestsDuringMaintenance::class,
|
||||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
ValidatePostSize::class,
|
||||||
\App\Http\Middleware\TrimStrings::class,
|
TrimStrings::class,
|
||||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
ConvertEmptyStringsToNull::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,21 +62,21 @@ class Kernel extends HttpKernel
|
|||||||
*/
|
*/
|
||||||
protected $middlewareGroups = [
|
protected $middlewareGroups = [
|
||||||
'web' => [
|
'web' => [
|
||||||
\App\Http\Middleware\EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
\Illuminate\Session\Middleware\StartSession::class,
|
StartSession::class,
|
||||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
ShareErrorsFromSession::class,
|
||||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
\App\Http\Middleware\ShareInertiaData::class,
|
ShareInertiaData::class,
|
||||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
|
CreateFreshApiToken::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'api' => [
|
'api' => [
|
||||||
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
ThrottleRequests::class.':api',
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
ForceJsonResponse::class,
|
ForceJsonResponse::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -64,17 +92,17 @@ class Kernel extends HttpKernel
|
|||||||
* @var array<string, class-string|string>
|
* @var array<string, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $middlewareAliases = [
|
protected $middlewareAliases = [
|
||||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
'auth' => Authenticate::class,
|
||||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
'auth.basic' => AuthenticateWithBasicAuth::class,
|
||||||
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
'auth.session' => AuthenticateSession::class,
|
||||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
'cache.headers' => SetCacheHeaders::class,
|
||||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
'can' => Authorize::class,
|
||||||
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
'guest' => RedirectIfAuthenticated::class,
|
||||||
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
'password.confirm' => RequirePassword::class,
|
||||||
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
'precognitive' => HandlePrecognitiveRequests::class,
|
||||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
'signed' => ValidateSignature::class,
|
||||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
'throttle' => ThrottleRequests::class,
|
||||||
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
'verified' => EnsureEmailIsVerified::class,
|
||||||
'check-organization-blocked' => CheckOrganizationBlocked::class,
|
'check-organization-blocked' => CheckOrganizationBlocked::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class ForceHttps
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class ForceJsonResponse
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
|
|||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
|
'bannerText' => fn () => $request->session()->get('bannerText'),
|
||||||
|
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class RedirectIfAuthenticated
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class ShareInertiaData
|
|||||||
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
||||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
||||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
||||||
'flash' => $request->session()->get('flash', []),
|
|
||||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
||||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
||||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\Member;
|
|||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use Illuminate\Contracts\Validation\Rule;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
@@ -19,7 +20,7 @@ class MemberMergeIntoRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|Rule>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Organization;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Illuminate\Contracts\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Organization $organization Organization from model binding
|
||||||
|
*/
|
||||||
|
class OrganizationStoreRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|Rule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return (string) $this->input('name');
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
95
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\User;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Rules\Base64ImageRule;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property User $user User from model binding
|
||||||
|
*/
|
||||||
|
class UserUpdateRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('email') && is_string($this->input('email'))) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower((string) $this->input('email')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
return $query->where('is_placeholder', '=', false);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'photo' => [
|
||||||
|
'nullable',
|
||||||
|
new Base64ImageRule,
|
||||||
|
],
|
||||||
|
'timezone' => [
|
||||||
|
'timezone:all',
|
||||||
|
],
|
||||||
|
'week_start' => [
|
||||||
|
Rule::enum(Weekday::class),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('name') ? (string) $this->input('name') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimezone(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('timezone') ? (string) $this->input('timezone') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeekStart(): ?Weekday
|
||||||
|
{
|
||||||
|
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPhotoKey(): bool
|
||||||
|
{
|
||||||
|
return $this->has('photo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPhoto(): ?string
|
||||||
|
{
|
||||||
|
$value = $this->input('photo');
|
||||||
|
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ 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) */
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\MemberService;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class RemovePlaceholder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*/
|
|
||||||
public function handle(TeamMemberAdded $event): void
|
|
||||||
{
|
|
||||||
$memberService = app(MemberService::class);
|
|
||||||
$member = Member::query()
|
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->whereBelongsTo($event->user, 'user')
|
|
||||||
->firstOrFail();
|
|
||||||
$placeholders = Member::query()
|
|
||||||
->whereHas('user', function (Builder $query) use ($event): void {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
$query->where('is_placeholder', '=', true)
|
|
||||||
->where('email', '=', $event->user->email);
|
|
||||||
})
|
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->with(['user'])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($placeholders as $placeholder) {
|
|
||||||
/** @var Member $placeholder */
|
|
||||||
$placeholderUser = $placeholder->user;
|
|
||||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
|
||||||
$placeholder->delete();
|
|
||||||
$placeholderUser->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
|||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class OrganizationInvitationMail extends Mailable
|
class OrganizationInvitationMail extends Mailable
|
||||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
|||||||
public function build(): self
|
public function build(): self
|
||||||
{
|
{
|
||||||
return $this->markdown('emails.organization-invitation', [
|
return $this->markdown('emails.organization-invitation', [
|
||||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
'acceptUrl' => URL::to(URL::signedRoute(
|
||||||
'invitation' => $this->invitation,
|
'organization-invitations.accept',
|
||||||
]),
|
['invitation' => $this->invitation->getKey()],
|
||||||
|
Carbon::now()->addDays(90),
|
||||||
|
false
|
||||||
|
)),
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class VerifyUpdatedEmailMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public string $email;
|
||||||
|
|
||||||
|
public function __construct(User $user, string $email)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->email = Str::lower($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the message.
|
||||||
|
*/
|
||||||
|
public function build(): self
|
||||||
|
{
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'users.verify-email-change',
|
||||||
|
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||||
|
[
|
||||||
|
'user' => $this->user->getKey(),
|
||||||
|
'email' => $this->email,
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->markdown('emails.verify-updated-email', [
|
||||||
|
'verificationUrl' => URL::to($verificationUrl),
|
||||||
|
])->subject(__('Verify Email Address'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ use Illuminate\Support\Str;
|
|||||||
use Laravel\Jetstream\Events\TeamCreated;
|
use Laravel\Jetstream\Events\TeamCreated;
|
||||||
use Laravel\Jetstream\Events\TeamDeleted;
|
use Laravel\Jetstream\Events\TeamDeleted;
|
||||||
use Laravel\Jetstream\Events\TeamUpdated;
|
use Laravel\Jetstream\Events\TeamUpdated;
|
||||||
|
use Laravel\Jetstream\Team;
|
||||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
use Laravel\Jetstream\Team as JetstreamTeam;
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
* @property bool $employees_can_manage_tasks
|
* @property bool $employees_can_manage_tasks
|
||||||
|
* @property bool $prevent_overlapping_time_entries
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
@@ -176,7 +178,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
*
|
*
|
||||||
* @param array<string> $columns
|
* @param array<string> $columns
|
||||||
*/
|
*/
|
||||||
public function findOrFail(string $id, array $columns = ['*']): \Laravel\Jetstream\Team
|
public function findOrFail(string $id, array $columns = ['*']): Team
|
||||||
{
|
{
|
||||||
if (! Str::isUuid($id)) {
|
if (! Str::isUuid($id)) {
|
||||||
throw (new ModelNotFoundException)->setModel(
|
throw (new ModelNotFoundException)->setModel(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string $role
|
* @property string $role
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
|
* @property Carbon|null $accepted_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
protected $table = 'organization_invitations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
public function casts(): array
|
||||||
'email',
|
{
|
||||||
'role',
|
return [
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $id
|
* @property string $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $email
|
* @property string $email
|
||||||
|
* @property string|null $pending_email
|
||||||
* @property Carbon|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string|null $password
|
* @property string|null $password
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
@@ -105,6 +106,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'email' => 'string',
|
'email' => 'string',
|
||||||
|
'pending_email' => 'string',
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
|
|||||||
@@ -62,18 +62,6 @@ class OrganizationPolicy
|
|||||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can add team members.
|
|
||||||
*/
|
|
||||||
public function addTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can update team member permissions.
|
* Determine whether the user can update team member permissions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Listeners\RemovePlaceholder;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
TeamMemberAdded::class => [
|
|
||||||
RemovePlaceholder::class,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
use Laravel\Fortify\Http\Responses\LoginResponse;
|
use Laravel\Fortify\Http\Responses\LoginResponse;
|
||||||
@@ -41,6 +42,14 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
|
||||||
|
Fortify::registerView(function () {
|
||||||
|
return Inertia::render('Auth/Register', [
|
||||||
|
'terms_url' => config('auth.terms_url'),
|
||||||
|
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||||
|
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
|
|||||||
@@ -13,20 +13,18 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
|
|||||||
use App\Actions\Jetstream\UpdateMemberRole;
|
use App\Actions\Jetstream\UpdateMemberRole;
|
||||||
use App\Actions\Jetstream\UpdateOrganization;
|
use App\Actions\Jetstream\UpdateOrganization;
|
||||||
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Brick\Money\Currency;
|
use Brick\Money\Currency;
|
||||||
use Brick\Money\ISOCurrencyProvider;
|
use Brick\Money\ISOCurrencyProvider;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Laravel\Fortify\Fortify;
|
|
||||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||||
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -60,13 +58,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||||
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
||||||
Fortify::registerView(function () {
|
|
||||||
return Inertia::render('Auth/Register', [
|
|
||||||
'terms_url' => config('auth.terms_url'),
|
|
||||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
|
||||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -79,205 +70,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
Jetstream::defaultApiTokenPermissions([]);
|
Jetstream::defaultApiTokenPermissions([]);
|
||||||
|
|
||||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
|
||||||
'charts:view:own',
|
Jetstream::role($role, $definition['name'], $definition['permissions'])
|
||||||
'charts:view:all',
|
->description($definition['description']);
|
||||||
'projects:view',
|
}
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'organizations:update',
|
|
||||||
'organizations:delete',
|
|
||||||
'import',
|
|
||||||
'export',
|
|
||||||
'invitations:view',
|
|
||||||
'invitations:create',
|
|
||||||
'invitations:resend',
|
|
||||||
'invitations:remove',
|
|
||||||
'members:view',
|
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:change-ownership',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:update',
|
|
||||||
'members:delete',
|
|
||||||
'billing',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'organizations:update',
|
|
||||||
'import',
|
|
||||||
'export',
|
|
||||||
'invitations:view',
|
|
||||||
'invitations:create',
|
|
||||||
'invitations:resend',
|
|
||||||
'invitations:remove',
|
|
||||||
'members:view',
|
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:delete',
|
|
||||||
'members:update',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'invitations:view',
|
|
||||||
'members:view',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
|
||||||
'charts:view:own',
|
|
||||||
'projects:view',
|
|
||||||
'tags:view',
|
|
||||||
'tasks:view',
|
|
||||||
'clients:view',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'organizations:view',
|
|
||||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
|
||||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
|
||||||
|
|
||||||
Jetstream::inertia()
|
Jetstream::inertia()
|
||||||
->whenRendering(
|
->whenRendering(
|
||||||
@@ -304,28 +100,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'owner' => [
|
'owner' => [
|
||||||
'id' => $owner->getKey(),
|
'id' => $owner->getKey(),
|
||||||
'name' => $owner->name,
|
'name' => $owner->name,
|
||||||
'email' => $owner->email,
|
|
||||||
'profile_photo_url' => $owner->profile_photo_url,
|
'profile_photo_url' => $owner->profile_photo_url,
|
||||||
],
|
],
|
||||||
'users' => $teamModel->users->map(function (User $user): array {
|
|
||||||
return [
|
|
||||||
'id' => $user->getKey(),
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'profile_photo_url' => $user->profile_photo_url,
|
|
||||||
'membership' => [
|
|
||||||
'id' => $user->membership->id,
|
|
||||||
'role' => $user->membership->role,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
|
|
||||||
return [
|
|
||||||
'id' => $invitation->getKey(),
|
|
||||||
'email' => $invitation->email,
|
|
||||||
'role' => $invitation->role,
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
'currencies' => array_map(function (Currency $currency): string {
|
'currencies' => array_map(function (Currency $currency): string {
|
||||||
return $currency->getName();
|
return $currency->getName();
|
||||||
|
|||||||
45
app/Rules/Base64ImageRule.php
Normal file
45
app/Rules/Base64ImageRule.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Support\Base64File;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||||
|
|
||||||
|
class Base64ImageRule implements ValidationRule
|
||||||
|
{
|
||||||
|
private const array ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const int MAX_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the validation rule.
|
||||||
|
*
|
||||||
|
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||||
|
*/
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
$fail(__('validation.string'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = Base64File::decode($value);
|
||||||
|
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
$fail(__('validation.mimes', ['values' => 'jpg, png']));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($file['data']) > self::MAX_BYTES) {
|
||||||
|
$fail(__('validation.max.file', ['max' => (string) (self::MAX_BYTES / 1024)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
|||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||||
|
|
||||||
@@ -21,11 +23,7 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (Member::query()
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereRelation('user', 'email', '=', $email)
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->exists()) {
|
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +46,37 @@ class InvitationService
|
|||||||
|
|
||||||
return $invitation;
|
return $invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Organization>
|
||||||
|
*/
|
||||||
|
public function processAcceptedInvitations(User $user): Collection
|
||||||
|
{
|
||||||
|
$organizations = new Collection;
|
||||||
|
|
||||||
|
$invitations = OrganizationInvitation::query()
|
||||||
|
->where('email', $user->email)
|
||||||
|
->whereNotNull('accepted_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($invitations as $invitation) {
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null) {
|
||||||
|
Log::error('Invalid role in invitation', [
|
||||||
|
'invitation' => $invitation->getKey(),
|
||||||
|
'role' => $invitation->role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
app(MemberService::class)->addMember($user, $organization, $role);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
$organizations->push($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organizations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ 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);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\MemberAdded;
|
||||||
|
use App\Events\MemberAdding;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
@@ -36,7 +38,8 @@ class MemberService
|
|||||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
AddingTeamMember::dispatch($organization, $user);
|
MemberAdding::dispatch($user, $organization, $role);
|
||||||
|
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -49,14 +52,37 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
|
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
TeamMemberAdded::dispatch($organization, $user);
|
MemberAdded::dispatch($member, $organization, $user);
|
||||||
|
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
return $member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||||
|
{
|
||||||
|
$placeholders = Member::query()
|
||||||
|
->whereHas('user', function (Builder $query) use ($user): void {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
$query->where('is_placeholder', '=', true)
|
||||||
|
->where('email', '=', $user->email);
|
||||||
|
})
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->with(['user'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($placeholders as $placeholder) {
|
||||||
|
/** @var Member $placeholder */
|
||||||
|
$placeholderUser = $placeholder->user;
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
||||||
|
$placeholder->delete();
|
||||||
|
$placeholderUser->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws CanNotRemoveOwnerFromOrganization
|
* @throws CanNotRemoveOwnerFromOrganization
|
||||||
* @throws EntityStillInUseApiException
|
* @throws EntityStillInUseApiException
|
||||||
@@ -209,4 +235,13 @@ class MemberService
|
|||||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||||
|
{
|
||||||
|
return Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereRelation('user', 'email', '=', $email)
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,238 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
use Laravel\Jetstream\Role;
|
|
||||||
|
|
||||||
class PermissionStore
|
class PermissionStore
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{name: string, permissions: array<string>, description: string}>
|
||||||
|
*/
|
||||||
|
private const array ROLE_DEFINITIONS = [
|
||||||
|
'owner' => [
|
||||||
|
'name' => 'Owner',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'organizations:update',
|
||||||
|
'organizations:delete',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'invitations:view',
|
||||||
|
'invitations:create',
|
||||||
|
'invitations:resend',
|
||||||
|
'invitations:remove',
|
||||||
|
'members:view',
|
||||||
|
'members:invite-placeholder',
|
||||||
|
'members:change-ownership',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:update',
|
||||||
|
'members:delete',
|
||||||
|
'billing',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
],
|
||||||
|
'description' => 'Owner users can perform any action. There is only one owner per organization.',
|
||||||
|
],
|
||||||
|
'admin' => [
|
||||||
|
'name' => 'Administrator',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'organizations:update',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'invitations:view',
|
||||||
|
'invitations:create',
|
||||||
|
'invitations:resend',
|
||||||
|
'invitations:remove',
|
||||||
|
'members:view',
|
||||||
|
'members:invite-placeholder',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:delete',
|
||||||
|
'members:update',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
],
|
||||||
|
'description' => 'Administrator users can perform any action, except accessing the billing dashboard.',
|
||||||
|
],
|
||||||
|
'manager' => [
|
||||||
|
'name' => 'Manager',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'invitations:view',
|
||||||
|
'members:view',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
],
|
||||||
|
'description' => 'Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).',
|
||||||
|
],
|
||||||
|
'employee' => [
|
||||||
|
'name' => 'Employee',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'projects:view',
|
||||||
|
'tags:view',
|
||||||
|
'tasks:view',
|
||||||
|
'clients:view',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'organizations:view',
|
||||||
|
],
|
||||||
|
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
|
||||||
|
],
|
||||||
|
'placeholder' => [
|
||||||
|
'name' => 'Placeholder',
|
||||||
|
'permissions' => [],
|
||||||
|
'description' => 'Placeholders are used for importing data. They cannot log in and have no permissions.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string>>
|
||||||
|
*/
|
||||||
|
private static array $customRolePermissions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array<string>>
|
* @var array<string, array<string>>
|
||||||
*/
|
*/
|
||||||
@@ -22,6 +246,37 @@ class PermissionStore
|
|||||||
$this->permissionCache = [];
|
$this->permissionCache = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{name: string, permissions: array<string>, description: string}>
|
||||||
|
*/
|
||||||
|
public static function roleDefinitions(): array
|
||||||
|
{
|
||||||
|
return self::ROLE_DEFINITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string> $permissions
|
||||||
|
*/
|
||||||
|
public static function registerCustomRole(string $role, array $permissions): void
|
||||||
|
{
|
||||||
|
self::$customRolePermissions[$role] = $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resetCustomRoles(): void
|
||||||
|
{
|
||||||
|
self::$customRolePermissions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function permissionsForRole(string $role): array
|
||||||
|
{
|
||||||
|
return self::$customRolePermissions[$role]
|
||||||
|
?? self::ROLE_DEFINITIONS[$role]['permissions']
|
||||||
|
?? [];
|
||||||
|
}
|
||||||
|
|
||||||
public function has(Organization $organization, string $permission): bool
|
public function has(Organization $organization, string $permission): bool
|
||||||
{
|
{
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
@@ -68,14 +323,11 @@ class PermissionStore
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Role|null $roleObj */
|
$permissions = self::permissionsForRole($role);
|
||||||
$roleObj = Jetstream::findRole($role);
|
|
||||||
|
|
||||||
$permissions = $roleObj->permissions ?? [];
|
|
||||||
|
|
||||||
// If the organization allows employees to manage tasks and the user is an employee,
|
// If the organization allows employees to manage tasks and the user is an employee,
|
||||||
// add the task management permissions for accessible projects
|
// add the task management permissions for accessible projects
|
||||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
if ($role === Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||||
$permissions = array_merge($permissions, [
|
$permissions = array_merge($permissions, [
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Http\File;
|
use Illuminate\Http\File;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use League\Csv\CannotInsertRecord;
|
||||||
|
use League\Csv\Exception;
|
||||||
|
use League\Csv\UnavailableStream;
|
||||||
use League\Csv\Writer;
|
use League\Csv\Writer;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
|
||||||
@@ -58,9 +61,9 @@ abstract class CsvExport
|
|||||||
abstract public function mapRow(Model $model): array;
|
abstract public function mapRow(Model $model): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws \League\Csv\CannotInsertRecord
|
* @throws CannotInsertRecord
|
||||||
* @throws \League\Csv\Exception
|
* @throws Exception
|
||||||
* @throws \League\Csv\UnavailableStream
|
* @throws UnavailableStream
|
||||||
*/
|
*/
|
||||||
public function export(): void
|
public function export(): void
|
||||||
{
|
{
|
||||||
@@ -72,6 +75,7 @@ abstract class CsvExport
|
|||||||
$writer->insertOne(static::HEADER);
|
$writer->insertOne(static::HEADER);
|
||||||
|
|
||||||
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
|
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
|
||||||
|
/** @var T $model */
|
||||||
foreach ($models as $model) {
|
foreach ($models as $model) {
|
||||||
$data = $this->mapRow($model);
|
$data = $this->mapRow($model);
|
||||||
$row = $this->convertRow($data);
|
$row = $this->convertRow($data);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class UserService
|
|||||||
): User {
|
): User {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = $email;
|
$user->email = strtolower($email);
|
||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
@@ -47,6 +47,9 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
|
|
||||||
|
if ($organizations->isEmpty()) {
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
$this->getOrganizationNameForUserName($user->name),
|
||||||
$user,
|
$user,
|
||||||
@@ -58,8 +61,8 @@ class UserService
|
|||||||
$intervalFormat,
|
$intervalFormat,
|
||||||
$timeFormat,
|
$timeFormat,
|
||||||
);
|
);
|
||||||
|
|
||||||
$user->ownedTeams()->save($organization);
|
$user->ownedTeams()->save($organization);
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/Support/Base64File.php
Normal file
45
app/Support/Base64File.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Symfony\Component\Mime\MimeTypes;
|
||||||
|
|
||||||
|
class Base64File
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{data: string, mime_type: string}|null
|
||||||
|
*/
|
||||||
|
public static function decode(string $value): ?array
|
||||||
|
{
|
||||||
|
if (str_contains($value, ',')) {
|
||||||
|
[, $value] = explode(',', $value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = preg_replace('/\s+/', '', $value);
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($value, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
|
||||||
|
if ($mimeType === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $decoded,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function extension(string $mimeType): ?string
|
||||||
|
{
|
||||||
|
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
use App\Exceptions\Handler;
|
||||||
|
use App\Http\Kernel;
|
||||||
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -13,7 +17,7 @@ declare(strict_types=1);
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$app = new Illuminate\Foundation\Application(
|
$app = new Application(
|
||||||
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ $app = new Illuminate\Foundation\Application(
|
|||||||
|
|
||||||
$app->singleton(
|
$app->singleton(
|
||||||
Illuminate\Contracts\Http\Kernel::class,
|
Illuminate\Contracts\Http\Kernel::class,
|
||||||
App\Http\Kernel::class
|
Kernel::class
|
||||||
);
|
);
|
||||||
|
|
||||||
$app->singleton(
|
$app->singleton(
|
||||||
@@ -39,8 +43,8 @@ $app->singleton(
|
|||||||
);
|
);
|
||||||
|
|
||||||
$app->singleton(
|
$app->singleton(
|
||||||
Illuminate\Contracts\Debug\ExceptionHandler::class,
|
ExceptionHandler::class,
|
||||||
App\Exceptions\Handler::class
|
Handler::class
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
3677
composer.lock
generated
3677
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,13 @@ use App\Enums\DateFormat;
|
|||||||
use App\Enums\IntervalFormat;
|
use App\Enums\IntervalFormat;
|
||||||
use App\Enums\NumberFormat;
|
use App\Enums\NumberFormat;
|
||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\AuthServiceProvider;
|
||||||
|
use App\Providers\EventServiceProvider;
|
||||||
|
use App\Providers\Filament\AdminPanelProvider;
|
||||||
|
use App\Providers\FortifyServiceProvider;
|
||||||
|
use App\Providers\JetstreamServiceProvider;
|
||||||
|
use App\Providers\RouteServiceProvider;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||||
@@ -190,13 +197,13 @@ return [
|
|||||||
/*
|
/*
|
||||||
* Application Service Providers...
|
* Application Service Providers...
|
||||||
*/
|
*/
|
||||||
App\Providers\AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
App\Providers\AuthServiceProvider::class,
|
AuthServiceProvider::class,
|
||||||
App\Providers\EventServiceProvider::class,
|
EventServiceProvider::class,
|
||||||
App\Providers\Filament\AdminPanelProvider::class,
|
AdminPanelProvider::class,
|
||||||
App\Providers\RouteServiceProvider::class,
|
RouteServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
JetstreamServiceProvider::class,
|
||||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||||
LaravelModulesServiceProvider::class,
|
LaravelModulesServiceProvider::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
use App\Extensions\Auditing\Resolvers\CustomIpAddressResolver;
|
||||||
|
use OwenIt\Auditing\Models\Audit;
|
||||||
|
use OwenIt\Auditing\Resolvers\UrlResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UserAgentResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UserResolver;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -15,7 +20,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'implementation' => OwenIt\Auditing\Models\Audit::class,
|
'implementation' => Audit::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -32,7 +37,7 @@ return [
|
|||||||
'web',
|
'web',
|
||||||
'api',
|
'api',
|
||||||
],
|
],
|
||||||
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class,
|
'resolver' => UserResolver::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -44,9 +49,9 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'resolvers' => [
|
'resolvers' => [
|
||||||
'ip_address' => App\Extensions\Auditing\Resolvers\CustomIpAddressResolver::class,
|
'ip_address' => CustomIpAddressResolver::class,
|
||||||
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class,
|
'user_agent' => UserAgentResolver::class,
|
||||||
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class,
|
'url' => UrlResolver::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ return [
|
|||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => App\Models\User::class,
|
'model' => User::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\DefaultValueBinder;
|
||||||
use Maatwebsite\Excel\Excel;
|
use Maatwebsite\Excel\Excel;
|
||||||
use PhpOffice\PhpSpreadsheet\Reader\Csv;
|
use PhpOffice\PhpSpreadsheet\Reader\Csv;
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'value_binder' => [
|
'value_binder' => [
|
||||||
'default' => Maatwebsite\Excel\DefaultValueBinder::class,
|
'default' => DefaultValueBinder::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'cache' => [
|
'cache' => [
|
||||||
|
|||||||
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
|||||||
'email' => $this->faker->unique()->safeEmail(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'role' => Role::Employee->value,
|
'role' => Role::Employee->value,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
|
'accepted_at' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function role(Role $role): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'role' => $role->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepted(): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'accepted_at' => $this->faker->dateTime(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function forOrganization(Organization $organization): self
|
public function forOrganization(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Enums\Weekday;
|
|||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Http\FileHelpers;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ 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,
|
||||||
@@ -90,7 +92,7 @@ class UserFactory extends Factory
|
|||||||
public function withProfilePicture(): static
|
public function withProfilePicture(): static
|
||||||
{
|
{
|
||||||
$profilePhoto = $this->faker->image(null, 500, 500);
|
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||||
/** @see \Illuminate\Http\FileHelpers::hashName */
|
/** @see FileHelpers::hashName */
|
||||||
$path = 'profile-photos/'.Str::random(40).'.png';
|
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||||
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
|
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('accepted_at')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('accepted_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->string('pending_email')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('pending_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$duplicateEmails = DB::table('users')
|
||||||
|
->selectRaw('LOWER(email) as normalized_email')
|
||||||
|
->selectRaw('COUNT(*) as user_count')
|
||||||
|
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
|
||||||
|
->where('is_placeholder', false)
|
||||||
|
->groupByRaw('LOWER(email)')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->orderBy('normalized_email')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($duplicateEmails->isNotEmpty()) {
|
||||||
|
$duplicateEmailMessage = $duplicateEmails
|
||||||
|
->take(20)
|
||||||
|
->map(fn (stdClass $duplicateEmail): string => sprintf(
|
||||||
|
'%s (%d users: %s)',
|
||||||
|
$duplicateEmail->normalized_email,
|
||||||
|
$duplicateEmail->user_count,
|
||||||
|
$duplicateEmail->users,
|
||||||
|
))
|
||||||
|
->implode('; ');
|
||||||
|
|
||||||
|
$remainingDuplicateCount = $duplicateEmails->count() - 20;
|
||||||
|
$remainingDuplicateMessage = $remainingDuplicateCount > 0
|
||||||
|
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
|
||||||
|
$duplicateEmailMessage.
|
||||||
|
$remainingDuplicateMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereRaw('email <> LOWER(email)')
|
||||||
|
->update([
|
||||||
|
'email' => DB::raw('LOWER(email)'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import { expect } from '@playwright/test';
|
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
async function goToCalendar(page: Page) {
|
async function goToCalendar(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettingsPopover(page: Page) {
|
async function openSettingsPopover(page: Page) {
|
||||||
@@ -17,6 +18,29 @@ async function clearCalendarSettings(page: Page) {
|
|||||||
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
|
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.describe('Calendar Settings', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await clearCalendarSettings(page);
|
await clearCalendarSettings(page);
|
||||||
@@ -39,7 +63,9 @@ test.describe('Calendar Settings', () => {
|
|||||||
// Change snap interval to 30 min
|
// Change snap interval to 30 min
|
||||||
await page.getByLabel('Snap Interval').click();
|
await page.getByLabel('Snap Interval').click();
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
await page.locator('.fc-toolbar-title').click();
|
|
||||||
|
// Close the popover by pressing Escape
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify localStorage was updated
|
// Verify localStorage was updated
|
||||||
const stored = await page.evaluate(() =>
|
const stored = await page.evaluate(() =>
|
||||||
@@ -54,7 +80,7 @@ test.describe('Calendar Settings', () => {
|
|||||||
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
|
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('start time change is applied to calendar and rejects values >= end time', async ({
|
test('start time change is applied to calendar and rejects invalid values', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
@@ -71,24 +97,23 @@ test.describe('Calendar Settings', () => {
|
|||||||
// Change start time to 8 AM (valid)
|
// Change start time to 8 AM (valid)
|
||||||
await page.getByLabel('Start Time').click();
|
await page.getByLabel('Start Time').click();
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
await page.locator('.fc-toolbar-title').click();
|
|
||||||
|
|
||||||
// Calendar should no longer show hours before 8 AM
|
// Try to set start time to 6 PM (invalid: equals end time) — should be rejected
|
||||||
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);
|
|
||||||
|
|
||||||
// Try to set start time to 6 PM (invalid: equals end time)
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Start Time').click();
|
await page.getByLabel('Start Time').click();
|
||||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||||
|
|
||||||
// Should be rejected — start time stays at 8 AM
|
// Should be rejected — start time stays at 8 AM
|
||||||
await expect(page.getByLabel('Start Time')).toContainText('8:00 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 values <= start time', async ({
|
test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
// Verify 19:00 slot exists with default end (24:00)
|
// Verify 19:00 slot exists with default end (24:00)
|
||||||
@@ -103,19 +128,20 @@ test.describe('Calendar Settings', () => {
|
|||||||
// Change end time to 6 PM (valid)
|
// Change end time to 6 PM (valid)
|
||||||
await page.getByLabel('End Time').click();
|
await page.getByLabel('End Time').click();
|
||||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||||
await page.locator('.fc-toolbar-title').click();
|
|
||||||
|
|
||||||
// Calendar should no longer show hours at or after 6 PM
|
// Try to set end time to 8 AM (invalid: equals start time) — should be rejected
|
||||||
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);
|
|
||||||
|
|
||||||
// Try to set end time to 8 AM (invalid: equals start time)
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('End Time').click();
|
await page.getByLabel('End Time').click();
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
|
|
||||||
// Should be rejected — end time stays at 6 PM
|
// Should be rejected — end time stays at 6 PM
|
||||||
await expect(page.getByLabel('End Time')).toContainText('6:00 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 }) => {
|
test('grid scale affects number of calendar slots', async ({ page }) => {
|
||||||
@@ -128,19 +154,31 @@ test.describe('Calendar Settings', () => {
|
|||||||
await openSettingsPopover(page);
|
await openSettingsPopover(page);
|
||||||
await page.getByLabel('Grid Scale').click();
|
await page.getByLabel('Grid Scale').click();
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
await page.locator('.fc-toolbar-title').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();
|
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||||
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
|
|
||||||
|
|
||||||
// Change to 5 min scale (should have many more slots)
|
// 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 openSettingsPopover(page);
|
||||||
await page.getByLabel('Grid Scale').click();
|
await page.getByLabel('Grid Scale').click();
|
||||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||||
await page.locator('.fc-toolbar-title').click();
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
// Wait for FullCalendar to re-render with new slot count
|
||||||
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
|
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 }) => {
|
test('all settings persist across navigation', async ({ page }) => {
|
||||||
@@ -156,7 +194,9 @@ test.describe('Calendar Settings', () => {
|
|||||||
await page.getByRole('option', { name: '10:00 PM' }).click();
|
await page.getByRole('option', { name: '10:00 PM' }).click();
|
||||||
await page.getByLabel('Grid Scale').click();
|
await page.getByLabel('Grid Scale').click();
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
await page.locator('.fc-toolbar-title').click();
|
|
||||||
|
// Close the popover
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Navigate away and back
|
// Navigate away and back
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
@@ -170,3 +210,480 @@ test.describe('Calendar Settings', () => {
|
|||||||
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
2566
e2e/calendar.spec.ts
2566
e2e/calendar.spec.ts
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,80 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
|
|||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that client context menu edit updates the client', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'CtxEditClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedName = 'CtxUpdatedClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Client Name').fill(updatedName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Client' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client context menu archive archives the client', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'CtxArchiveClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Archive' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client context menu delete deletes the client', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'CtxDeleteClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Sorting Tests
|
// Sorting Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
158
e2e/invitation-accept.spec.ts
Normal file
158
e2e/invitation-accept.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||||
|
import { getInvitationAcceptUrl } from './utils/mailpit';
|
||||||
|
import { registerUser } from './utils/members';
|
||||||
|
|
||||||
|
// Invitation acceptance flows touch mail delivery + redirects.
|
||||||
|
test.describe.configure({ timeout: 45000 });
|
||||||
|
|
||||||
|
test.describe('invitation accept banners', () => {
|
||||||
|
test('shows success banner on dashboard when a logged-in registered user accepts an invitation', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `success+${memberId}@invite-banner.test`;
|
||||||
|
|
||||||
|
// Invitee already has an account and is logged in.
|
||||||
|
const invitee = await registerUser(browser, 'Banner Success', memberEmail);
|
||||||
|
|
||||||
|
// Owner sends the invitation.
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await page.getByLabel('Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Invitee clicks the email link.
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(invitee.page.request, memberEmail);
|
||||||
|
await invitee.page.goto(acceptUrl);
|
||||||
|
await invitee.page.waitForURL(/\/dashboard$/);
|
||||||
|
|
||||||
|
const banner = invitee.page.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/Great! You have accepted the invitation to join the .* organization\./
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitee.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows info banner on login screen when a registered-but-logged-out invitee clicks the accept link', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `loggedout+${memberId}@invite-banner.test`;
|
||||||
|
|
||||||
|
// Invitee has an account, but the context that clicks the link has no session.
|
||||||
|
const invitee = await registerUser(browser, 'Banner Loggedout', memberEmail);
|
||||||
|
await invitee.close();
|
||||||
|
|
||||||
|
// Owner sends the invitation.
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await page.getByLabel('Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Open the accept link in a fresh browser context (no session).
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const inviteePage = await context.newPage();
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||||
|
await inviteePage.goto(acceptUrl);
|
||||||
|
await inviteePage.waitForURL(/\/login$/);
|
||||||
|
|
||||||
|
const banner = inviteePage.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/Great! You have accepted the invitation to join the .* organization\. Please log in to access it\./
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logging in lands the invitee on the dashboard — they were already added silently
|
||||||
|
// by the accept controller, so the inviter's members list shows them.
|
||||||
|
await inviteePage.getByLabel('Email').fill(memberEmail);
|
||||||
|
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||||
|
await inviteePage.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await inviteePage.waitForURL(/\/dashboard/);
|
||||||
|
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Loggedout' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows info banner on register screen when an unregistered email accepts an invitation, then auto-joins on registration', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `info+${memberId}@invite-banner.test`;
|
||||||
|
|
||||||
|
// Owner invites an email that has no account yet.
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await page.getByLabel('Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Open the accept link in a fresh browser context (no session).
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const inviteePage = await context.newPage();
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||||
|
await inviteePage.goto(acceptUrl);
|
||||||
|
await inviteePage.waitForURL(/\/register$/);
|
||||||
|
|
||||||
|
const banner = inviteePage.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/Please create an account to finish joining the .* organization\./
|
||||||
|
);
|
||||||
|
|
||||||
|
// Complete registration — the invitee should auto-join the inviter's org
|
||||||
|
// (no fresh personal organization is created on top).
|
||||||
|
await inviteePage.getByLabel('Name').fill('Banner Info');
|
||||||
|
await inviteePage.getByLabel('Email').fill(memberEmail);
|
||||||
|
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||||
|
await inviteePage.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await inviteePage.getByLabel('I agree to the Terms of').click();
|
||||||
|
await inviteePage.getByRole('button', { name: 'Register' }).click();
|
||||||
|
await inviteePage.waitForURL(/\/dashboard/);
|
||||||
|
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Info' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -496,6 +496,158 @@ test('test that organization owner cannot be deleted', async ({ page }) => {
|
|||||||
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
|
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that member context menu edit updates the member billable rate', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const memberName = 'CtxEditMember ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Change billable rate from default to custom
|
||||||
|
const billableRateSelect = page.getByRole('dialog').getByRole('combobox').last();
|
||||||
|
await billableRateSelect.click();
|
||||||
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
||||||
|
|
||||||
|
// Set a custom billable rate
|
||||||
|
await page.getByPlaceholder('Billable Rate').fill('150');
|
||||||
|
|
||||||
|
// Click Update Member — confirmation dialog should appear
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm the billable rate change
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify dialog closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member context menu merge merges the member', async ({ page, ctx }) => {
|
||||||
|
const memberName = 'CtxMergeMember ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Merge' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Select the first available member as merge target
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
|
||||||
|
const firstOption = page.getByRole('option').first();
|
||||||
|
await expect(firstOption).toBeVisible({ timeout: 10000 });
|
||||||
|
await firstOption.click();
|
||||||
|
|
||||||
|
// Submit merge
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Merge Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/member/') &&
|
||||||
|
response.url().includes('/merge-into') &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify placeholder member is no longer visible
|
||||||
|
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member context menu deactivate deactivates the member', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `member+${memberId}@deactivate.test`;
|
||||||
|
const memberName = 'Deactivate Target';
|
||||||
|
|
||||||
|
// Invite and accept a new Employee member
|
||||||
|
await inviteAndAcceptMember(page, browser, memberName, memberEmail, 'Employee');
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Open context menu and click Deactivate
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Deactivate' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Deactivate User' })).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deactivation
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Deactivate' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/make-placeholder') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify dialog closed and member role changed to Placeholder
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(row.getByText('Placeholder', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member context menu delete deletes the member', async ({ page, ctx }) => {
|
||||||
|
const memberName = 'CtxDeleteMember ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Check the confirmation checkbox
|
||||||
|
await page.getByRole('checkbox').click();
|
||||||
|
|
||||||
|
// Click Delete Member button and wait for API response
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Delete Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify modal closed and member removed from table
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Invitations Tab Tests
|
// Invitations Tab Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -1,30 +1,187 @@
|
|||||||
import { test, expect } from '../playwright/fixtures';
|
import { test, expect } from '../playwright/fixtures';
|
||||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||||
|
import {
|
||||||
|
countEmailsWithSubject,
|
||||||
|
getEmailChangeVerificationUrl,
|
||||||
|
waitForEmailCount,
|
||||||
|
} from './utils/mailpit';
|
||||||
|
import { getCurrentUserViaApi } from './utils/api';
|
||||||
|
import { registerUser } from './utils/members';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
async function goToProfilePage(page: Page) {
|
async function goToProfilePage(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
test('test that user name can be updated', async ({ page }) => {
|
async function saveProfileForm(page: Page): Promise<void> {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
|
||||||
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(resp) =>
|
||||||
|
resp.url().includes('/user/profile-information') &&
|
||||||
|
resp.request().method() === 'POST'
|
||||||
|
),
|
||||||
page.getByRole('button', { name: 'Save' }).first().click(),
|
page.getByRole('button', { name: 'Save' }).first().click(),
|
||||||
page.waitForResponse('**/user/profile-information'),
|
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('user name can be updated', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
|
||||||
|
await saveProfileForm(page);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
|
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('test that user email can be updated', async ({ page }) => {
|
test('timezone change persists across reload', async ({ page }) => {
|
||||||
// this does not work because of email verification currently
|
await goToProfilePage(page);
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.getByLabel('Timezone').selectOption('America/New_York');
|
||||||
const emailId = Math.round(Math.random() * 10000);
|
await saveProfileForm(page);
|
||||||
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
|
||||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
|
await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('week-start change persists across reload', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
await page.getByLabel('Start of the week').selectOption('sunday');
|
||||||
|
await saveProfileForm(page);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitting a new email keeps the current email displayed after reload', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const { email: oldEmail } = await getCurrentUserViaApi(ctx);
|
||||||
|
const newEmail = `newemail+${Date.now()}@test.com`;
|
||||||
|
|
||||||
|
await goToProfilePage(page);
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(oldEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitting a new email sends a verification email to the new address', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const newEmail = `newemail+${Date.now()}@test.com`;
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
|
||||||
|
expect(await waitForEmailCount(request, newEmail, 'Verify Email Address', 1)).toBeGreaterThan(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixed-case email is lower-cased before the verification mail is sent', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const mixedCase = `MixedCase+${stamp}@Example.COM`;
|
||||||
|
const lowerCased = `mixedcase+${stamp}@example.com`;
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(mixedCase);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
|
||||||
|
const verifyUrl = await getEmailChangeVerificationUrl(request, lowerCased);
|
||||||
|
expect(new URL(verifyUrl).searchParams.get('email')).toBe(lowerCased);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-submitting the current email does not send a verification email', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
|
||||||
|
const beforeCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address');
|
||||||
|
|
||||||
|
await goToProfilePage(page);
|
||||||
|
await page.getByLabel('Email').fill(currentEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
const afterCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address');
|
||||||
|
expect(afterCount).toBe(beforeCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the verification link swaps the email and shows a success banner', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const newEmail = `verify+${Date.now()}@test.com`;
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
|
||||||
|
|
||||||
|
await page.goto(verifyUrl);
|
||||||
|
await page.waitForURL(/\/dashboard/);
|
||||||
|
|
||||||
|
const banner = page.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText('Your email address has been updated successfully.');
|
||||||
|
|
||||||
|
await goToProfilePage(page);
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting another user’s verification link is forbidden', async ({ page, browser }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const newEmail = `victim+${Date.now()}@test.com`;
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
|
||||||
|
|
||||||
|
const other = await registerUser(browser, 'Other User', `other+${Date.now()}@test.com`);
|
||||||
|
try {
|
||||||
|
const response = await other.page.goto(verifyUrl);
|
||||||
|
expect(response?.status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await other.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale verification link from a previous submission is rejected', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const olderEmail = `older+${stamp}@test.com`;
|
||||||
|
const newerEmail = `newer+${stamp}@test.com`;
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(olderEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
const staleUrl = await getEmailChangeVerificationUrl(page.request, olderEmail);
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(newerEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
|
||||||
|
const response = await page.goto(staleUrl);
|
||||||
|
expect(response?.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting the verification link while logged out redirects to login', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const newEmail = `loggedout+${Date.now()}@test.com`;
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
|
||||||
|
|
||||||
|
const anonContext = await browser.newContext();
|
||||||
|
try {
|
||||||
|
const anonPage = await anonContext.newPage();
|
||||||
|
await anonPage.goto(verifyUrl);
|
||||||
|
await anonPage.waitForURL(/\/login/);
|
||||||
|
} finally {
|
||||||
|
await anonContext.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createNewApiToken(page) {
|
async function createNewApiToken(page) {
|
||||||
@@ -230,6 +387,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
|||||||
await expect(page.getByText('System default:')).toBeVisible();
|
await expect(page.getByText('System default:')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Group similar time entries
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that group similar time entries setting can be toggled', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Get the checkbox
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
|
||||||
|
// Get initial value and verify it is checked (default is true)
|
||||||
|
const initialValue = await checkbox.isChecked();
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
// Toggle the checkbox
|
||||||
|
await checkbox.click();
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the value is toggled
|
||||||
|
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
|
||||||
|
expect(afterValue).toBe(!initialValue);
|
||||||
|
|
||||||
|
// Verify localStorage persists the setting
|
||||||
|
const storedValue = await page.evaluate(() =>
|
||||||
|
localStorage.getItem('group-similar-time-entries')
|
||||||
|
);
|
||||||
|
expect(storedValue).toBe(String(!initialValue));
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Two Factor Authentication Tests
|
// Two Factor Authentication Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -800,6 +800,81 @@ test('test that editing a task name on the project detail page works', async ({
|
|||||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that project context menu edit updates the project', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'CtxEditProject ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedName = 'CtxUpdatedProject ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: projectName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Project Name').fill(updatedName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('project_table')).toContainText(updatedName);
|
||||||
|
await expect(page.getByTestId('project_table')).not.toContainText(projectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that project context menu archive archives the project', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'CtxArchiveProject ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: projectName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Archive' }).click(),
|
||||||
|
]);
|
||||||
|
// After archiving, the project stays visible (default filter is 'all') but status changes to 'Archived'
|
||||||
|
await expect(row).toContainText('Archived');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that project context menu delete deletes the project', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'CtxDeleteProject ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: projectName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('project_table')).not.toContainText(projectName);
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
|
|||||||
|
|
||||||
// Verify the time entry is shown with all details
|
// Verify the time entry is shown with all details
|
||||||
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
|
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
|
||||||
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify the new duration is displayed
|
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
|
||||||
await expect(durationInput).toHaveValue(updatedDuration);
|
await expect(durationInput).toHaveValue('2:30:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify the report only shows 1h (task1's duration)
|
// Verify the report only shows 1h (task1's duration)
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
||||||
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify only time entries with tag1 are shown
|
// Verify only time entries with tag1 are shown
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
||||||
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
|
|||||||
waitForReportingUpdate(page),
|
waitForReportingUpdate(page),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable filter can switch between all three states', async ({ page }) => {
|
test('test that billable filter can switch between all three states', async ({ page }) => {
|
||||||
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
|
|||||||
|
|
||||||
// Employee's data should be visible (1h)
|
// Employee's data should be visible (1h)
|
||||||
await expect(
|
await expect(
|
||||||
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
|
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc.js';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
import {
|
import {
|
||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createClientViaApi,
|
createClientViaApi,
|
||||||
@@ -11,6 +15,7 @@ import {
|
|||||||
createBillableProjectViaApi,
|
createBillableProjectViaApi,
|
||||||
createTimeEntryWithBillableStatusViaApi,
|
createTimeEntryWithBillableStatusViaApi,
|
||||||
createTagViaApi,
|
createTagViaApi,
|
||||||
|
createReportViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import {
|
import {
|
||||||
goToReporting,
|
goToReporting,
|
||||||
@@ -287,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
||||||
@@ -364,8 +369,8 @@ test('test that shared report respects tag filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
||||||
@@ -420,7 +425,7 @@ test('test that shared report with billable filter only shows billable entries',
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify only 1h shows before saving
|
// Verify only 1h shows before saving
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
|
|
||||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
@@ -430,8 +435,8 @@ test('test that shared report with billable filter only shows billable entries',
|
|||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
@@ -766,6 +771,97 @@ test('test that updating expiration date on already-public report works', async
|
|||||||
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
|
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that clearing the expiration date on a report works', async ({ page, ctx }) => {
|
||||||
|
const reportName = 'ClearExpReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a public report with an expiration date via API
|
||||||
|
await createReportViaApi(ctx, {
|
||||||
|
name: reportName,
|
||||||
|
is_public: true,
|
||||||
|
public_until: dayjs().add(1, 'month').utc().format('YYYY-MM-DDTHH:mm:ss[Z]'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to shared reports and edit the report
|
||||||
|
await goToReportingShared(page);
|
||||||
|
await expect(page.getByText(reportName)).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// The date picker should show a date (not "Pick a date")
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Click the clear button (X icon) to remove the expiration date
|
||||||
|
const clearButton = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.locator('[role="button"]')
|
||||||
|
.filter({ has: page.locator('svg.lucide-x') });
|
||||||
|
await expect(clearButton).toBeVisible();
|
||||||
|
await clearButton.click();
|
||||||
|
|
||||||
|
// The date picker should now show "Pick a date"
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// The clear button should no longer be visible
|
||||||
|
await expect(clearButton).not.toBeVisible();
|
||||||
|
|
||||||
|
// Update the report and verify public_until is null
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/reports/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Report' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.public_until).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that date picker clear button is not visible when no date is set', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const reportName = 'NoClearReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a public report without an expiration date via API
|
||||||
|
await createReportViaApi(ctx, {
|
||||||
|
name: reportName,
|
||||||
|
is_public: true,
|
||||||
|
public_until: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to shared reports and edit the report
|
||||||
|
await goToReportingShared(page);
|
||||||
|
await expect(page.getByText(reportName)).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// The date picker should show "Pick a date"
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// The clear button should NOT be visible
|
||||||
|
const clearButton = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.locator('[role="button"]')
|
||||||
|
.filter({ has: page.locator('svg.lucide-x') });
|
||||||
|
await expect(clearButton).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Shared Report Cost Column Tests
|
// Shared Report Cost Column Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -90,6 +90,59 @@ test('test that multiple tags can be created via API and displayed in the table'
|
|||||||
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that tag context menu edit updates the tag', async ({ page, ctx }) => {
|
||||||
|
const tagName = 'CtxEditTag ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedName = 'CtxUpdatedTag ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createTagViaApi(ctx, { name: tagName });
|
||||||
|
await goToTagsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: tagName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Tag Name').fill(updatedName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Tag' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/tags') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('tag_table')).toContainText(updatedName);
|
||||||
|
await expect(page.getByTestId('tag_table')).not.toContainText(tagName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that tag context menu delete deletes the tag', async ({ page, ctx }) => {
|
||||||
|
const tagName = 'CtxDeleteTag ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createTagViaApi(ctx, { name: tagName });
|
||||||
|
await goToTagsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: tagName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/tags') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('tag_table')).not.toContainText(tagName);
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Sorting Tests
|
// Sorting Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
772
e2e/time.spec.ts
772
e2e/time.spec.ts
@@ -15,6 +15,7 @@ import {
|
|||||||
createBareTimeEntryViaApi,
|
createBareTimeEntryViaApi,
|
||||||
createTimeEntryViaApi,
|
createTimeEntryViaApi,
|
||||||
updateOrganizationCurrencyViaWeb,
|
updateOrganizationCurrencyViaWeb,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
@@ -38,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
|
|||||||
return new Date(timestamp).getUTCMonth() + 1;
|
return new Date(timestamp).getUTCMonth() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToProfilePage(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
}
|
||||||
|
|
||||||
async function goToTimeOverview(page: Page) {
|
async function goToTimeOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
const isChecked = await checkbox.isChecked();
|
||||||
|
if (isChecked !== enabled) await checkbox.click();
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
}
|
||||||
|
|
||||||
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -332,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
|
|||||||
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that Group similar time entries option is affected', async ({ page }) => {
|
||||||
|
// Enable grouping
|
||||||
|
await setTimeEntriesGrouping(page, true);
|
||||||
|
|
||||||
|
// Create 2 similar time entries
|
||||||
|
await createEmptyTimeEntry(page);
|
||||||
|
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
|
||||||
|
await createEmptyTimeEntry(page);
|
||||||
|
|
||||||
|
// Verify similar time entries are grouped
|
||||||
|
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable grouping
|
||||||
|
await setTimeEntriesGrouping(page, false);
|
||||||
|
|
||||||
|
// Verify similar time entries are not grouped
|
||||||
|
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
|
||||||
|
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Test that updating the time entry start / end times works while it is running
|
// TODO: Test that updating the time entry start / end times works while it is running
|
||||||
|
|
||||||
// TODO: Test for project update
|
// TODO: Test for project update
|
||||||
@@ -963,7 +1000,12 @@ test('test that natural language duration input works in create modal', async ({
|
|||||||
expect(createBody.data.duration).toBe(9000);
|
expect(createBody.data.duration).toBe(9000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that decimal duration input works in create modal', async ({ page }) => {
|
test('test that decimal duration input works in create modal', async ({ page, ctx }) => {
|
||||||
|
// Ensure comma-point format so "1.5h" uses period as decimal
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
// Open the create modal
|
// Open the create modal
|
||||||
@@ -978,7 +1020,6 @@ test('test that decimal duration input works in create modal', async ({ page })
|
|||||||
.fill('Decimal duration test');
|
.fill('Decimal duration test');
|
||||||
|
|
||||||
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes)
|
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes)
|
||||||
// Note: parse-duration library requires a unit suffix for decimal values
|
|
||||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
await durationInput.fill('1.5h');
|
await durationInput.fill('1.5h');
|
||||||
await durationInput.press('Tab');
|
await durationInput.press('Tab');
|
||||||
@@ -997,6 +1038,508 @@ test('test that decimal duration input works in create modal', async ({ page })
|
|||||||
expect(createBody.data.duration).toBe(5400);
|
expect(createBody.data.duration).toBe(5400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that decimal duration with comma number format does not corrupt on blur in edit modal', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set organization to decimal interval format with European number format (comma as decimal separator)
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'point-comma',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a 1-hour time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Decimal blur test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal via the actions dropdown
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// The duration input should show "1,00 h" (decimal format with comma)
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await expect(durationInput).toHaveValue('1,00 h');
|
||||||
|
|
||||||
|
// Click on the duration input and blur it without changing the value
|
||||||
|
await durationInput.click();
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// After blur, the value should remain "1,00 h" and NOT become "100,00 h"
|
||||||
|
await expect(durationInput).toHaveValue('1,00 h');
|
||||||
|
|
||||||
|
// Submit and verify the duration is still 3600 seconds (1 hour)
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(3600);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that typing bare decimal 1,5 in edit modal is interpreted as 1.5 hours', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set organization to decimal interval format with European number format
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'point-comma',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a 1-hour time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Bare decimal test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Type "1,5" (bare decimal without "h" suffix) — should be interpreted as 1.5 hours
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('1,5');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// Should display as "1,50 h" (1.5 hours formatted in point-comma locale)
|
||||||
|
await expect(durationInput).toHaveValue('1,50 h');
|
||||||
|
|
||||||
|
// Submit and verify the duration is 5400 seconds (1.5 hours)
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(5400);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that typing bare decimal 1.5 in edit modal is interpreted as 1.5 hours', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set organization to decimal interval format with default number format
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a 1-hour time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Bare decimal dot test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Type "1.5" (bare decimal with period) — should be interpreted as 1.5 hours
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('1.5');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// Should display as "1.50 h" (1.5 hours formatted in comma-point locale)
|
||||||
|
await expect(durationInput).toHaveValue('1.50 h');
|
||||||
|
|
||||||
|
// Submit and verify the duration is 5400 seconds (1.5 hours)
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(5400);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that decimal duration with space-comma number format does not corrupt on blur in edit modal', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set organization to decimal interval format with space-comma number format
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'space-comma',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a 1-hour time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Space-comma blur test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// The duration input should show "1,00 h" (space-comma uses comma as decimal)
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await expect(durationInput).toHaveValue('1,00 h');
|
||||||
|
|
||||||
|
// Blur without changing the value
|
||||||
|
await durationInput.click();
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// Should remain "1,00 h"
|
||||||
|
await expect(durationInput).toHaveValue('1,00 h');
|
||||||
|
|
||||||
|
// Submit and verify the duration is still 3600 seconds
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(3600);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that bare integer in edit modal is interpreted as minutes', async ({ page, ctx }) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Bare integer test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Type "30" — should be interpreted as 30 minutes
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('30');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// Should display as "0h 30min"
|
||||||
|
await expect(durationInput).toHaveValue('0h 30min');
|
||||||
|
|
||||||
|
// Submit and verify the duration is 1800 seconds (30 minutes)
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that bare integer in edit modal with decimal format is interpreted as hours', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Bare integer decimal test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Type "2" — with decimal format, should be interpreted as 2 hours
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('2');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// Should display as "2.00 h"
|
||||||
|
await expect(durationInput).toHaveValue('2.00 h');
|
||||||
|
|
||||||
|
// Submit and verify the duration is 7200 seconds (2 hours)
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(7200);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that HH:MM input in edit modal works', async ({ page, ctx }) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'HH:MM test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const actionsDropdown = newTimeEntry
|
||||||
|
.getByRole('button', { name: 'Actions for the time entry' })
|
||||||
|
.first();
|
||||||
|
await actionsDropdown.click();
|
||||||
|
await page.getByTestId('time_entry_edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Type "1:30" — should be interpreted as 1 hour 30 minutes
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('1:30');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
// Should display as "1h 30min"
|
||||||
|
await expect(durationInput).toHaveValue('1h 30min');
|
||||||
|
|
||||||
|
// Submit and verify the duration is 5400 seconds (1.5 hours)
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(5400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that bare integer in inline duration input is interpreted as minutes', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Inline bare integer test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Type "45" in the inline duration input — should be 45 minutes
|
||||||
|
const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first();
|
||||||
|
await durationInput.click();
|
||||||
|
await durationInput.fill('45');
|
||||||
|
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
durationInput.press('Tab'),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(2700);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that bare integer in inline duration input with decimal format is interpreted as hours', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Inline bare integer decimal test', '1h');
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||||
|
const newTimeEntry = timeEntryRows.first();
|
||||||
|
|
||||||
|
// Type "3" in the inline duration input — with decimal format, should be 3 hours
|
||||||
|
const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first();
|
||||||
|
await durationInput.click();
|
||||||
|
await durationInput.fill('3');
|
||||||
|
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
durationInput.press('Tab'),
|
||||||
|
]);
|
||||||
|
const updateBody = await updateResponse.json();
|
||||||
|
expect(updateBody.data.duration).toBe(10800);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that bare integer in create modal is interpreted as minutes', async ({ page, ctx }) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
// Open the create modal
|
||||||
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('textbox', { name: 'Description' })
|
||||||
|
.fill('Bare integer create test');
|
||||||
|
|
||||||
|
// Type "30" — should be interpreted as 30 minutes
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('30');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
await expect(durationInput).toHaveValue('0h 30min');
|
||||||
|
|
||||||
|
const [createResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const createBody = await createResponse.json();
|
||||||
|
expect(createBody.data.duration).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that bare integer in create modal with decimal format is interpreted as hours', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'decimal',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
// Open the create modal
|
||||||
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('textbox', { name: 'Description' })
|
||||||
|
.fill('Bare integer decimal create test');
|
||||||
|
|
||||||
|
// Type "2" — with decimal format, should be interpreted as 2 hours
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('2');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
|
||||||
|
await expect(durationInput).toHaveValue('2.00 h');
|
||||||
|
|
||||||
|
const [createResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const createBody = await createResponse.json();
|
||||||
|
expect(createBody.data.duration).toBe(7200);
|
||||||
|
|
||||||
|
// Reset organization settings
|
||||||
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
|
interval_format: 'hours-minutes',
|
||||||
|
number_format: 'comma-point',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('test that project selection works in create modal', async ({ page, ctx }) => {
|
test('test that project selection works in create modal', async ({ page, ctx }) => {
|
||||||
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
await createProjectViaApi(ctx, { name: projectName });
|
||||||
@@ -1535,3 +2078,228 @@ test.describe('Employee Time Entry Isolation', () => {
|
|||||||
await expect(timeEntryRow).not.toBeVisible();
|
await expect(timeEntryRow).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function openTimeEntryContextMenu(page: Page, description: string) {
|
||||||
|
const row = page
|
||||||
|
.locator('[data-testid="time_entry_row"]')
|
||||||
|
.filter({ hasText: description })
|
||||||
|
.first();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that context menu appears with correct items on time entry row', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const description = 'Context menu items test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await openTimeEntryContextMenu(page, description);
|
||||||
|
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that context menu edit opens the edit modal', async ({ page, ctx }) => {
|
||||||
|
const description = 'Context edit test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await openTimeEntryContextMenu(page, description);
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('dialog').getByPlaceholder('What did you work on?')).toHaveValue(
|
||||||
|
description
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that context menu duplicate creates a copy', async ({ page, ctx }) => {
|
||||||
|
const description = 'Context dup test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const project = await createProjectViaApi(ctx, {
|
||||||
|
name: 'Dup Project ' + Math.floor(1 + Math.random() * 10000),
|
||||||
|
is_billable: true,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
billable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await openTimeEntryContextMenu(page, description);
|
||||||
|
|
||||||
|
const [createResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = await createResponse.json();
|
||||||
|
expect(body.data.description).toBe(description);
|
||||||
|
expect(body.data.project_id).toBe(project.id);
|
||||||
|
expect(body.data.billable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that context menu continue starts a new time entry', async ({ page, ctx }) => {
|
||||||
|
const description = 'Context continue test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const project = await createProjectViaApi(ctx, {
|
||||||
|
name: 'Continue Project ' + Math.floor(1 + Math.random() * 10000),
|
||||||
|
is_billable: false,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await openTimeEntryContextMenu(page, description);
|
||||||
|
|
||||||
|
const [createResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Continue' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = await createResponse.json();
|
||||||
|
expect(body.data.description).toBe(description);
|
||||||
|
expect(body.data.project_id).toBe(project.id);
|
||||||
|
expect(body.data.end).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that context menu delete removes the time entry', async ({ page, ctx }) => {
|
||||||
|
const description = 'Context delete test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await openTimeEntryContextMenu(page, description);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that aggregate row context menu shows only Continue and Delete', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const description = 'Context agg items ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '30min');
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const aggregateRow = page
|
||||||
|
.locator('[data-testid="time_entry_row"]')
|
||||||
|
.filter({ hasText: description })
|
||||||
|
.first();
|
||||||
|
await aggregateRow.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that aggregate row context menu continue starts a new time entry', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const description = 'Context agg continue ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const project = await createProjectViaApi(ctx, {
|
||||||
|
name: 'Agg Continue Project ' + Math.floor(1 + Math.random() * 10000),
|
||||||
|
is_billable: false,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description,
|
||||||
|
duration: '30min',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
const aggregateRow = page
|
||||||
|
.locator('[data-testid="time_entry_row"]')
|
||||||
|
.filter({ hasText: description })
|
||||||
|
.first();
|
||||||
|
await aggregateRow.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
|
||||||
|
const [createResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Continue' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = await createResponse.json();
|
||||||
|
expect(body.data.description).toBe(description);
|
||||||
|
expect(body.data.project_id).toBe(project.id);
|
||||||
|
expect(body.data.end).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that aggregate row context menu delete removes all grouped entries', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const description = 'Context agg delete ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '30min');
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
// The aggregate row groups entries with same description
|
||||||
|
const aggregateRow = page
|
||||||
|
.locator('[data-testid="time_entry_row"]')
|
||||||
|
.filter({ hasText: description })
|
||||||
|
.first();
|
||||||
|
await aggregateRow.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
import { newTagResponse } from './utils/tags';
|
||||||
import { updateOrganizationCurrencyViaWeb } from './utils/api';
|
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
@@ -368,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
|||||||
await assertThatTimerIsStopped(page);
|
await assertThatTimerIsStopped(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a project so the dropdown renders (not the "Add new project" button)
|
||||||
|
await createProjectViaApi(ctx, { name: existingProjectName });
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Open the project dropdown
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
|
||||||
|
// Type a search term that won't match any existing project
|
||||||
|
await page.getByTestId('client_dropdown_search').fill(searchText);
|
||||||
|
|
||||||
|
// Click "Create new Project"
|
||||||
|
await page.getByText('Create new Project').click();
|
||||||
|
|
||||||
|
// Verify the project name input is pre-filled with the search text
|
||||||
|
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
|
||||||
|
|
||||||
|
// Complete project creation to verify full flow works
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
(await response.json()).data.name === searchText
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The project dropdown should now show the newly created project
|
||||||
|
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
||||||
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
||||||
await goToDashboard(page);
|
await goToDashboard(page);
|
||||||
|
|||||||
189
e2e/utils/api.ts
189
e2e/utils/api.ts
@@ -473,6 +473,25 @@ export async function createTimeEntryWithTagViaApi(
|
|||||||
return { tag, entry };
|
return { tag, entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRunningTimeEntryViaApi(ctx: TestContext, description: string) {
|
||||||
|
const start = new Date();
|
||||||
|
start.setMinutes(start.getMinutes() - 10);
|
||||||
|
const response = await ctx.request.post(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
member_id: ctx.memberId,
|
||||||
|
start: formatTimestamp(start),
|
||||||
|
description,
|
||||||
|
billable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as { id: string; start: string; end: null; description: string };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBareTimeEntryViaApi(
|
export async function createBareTimeEntryViaApi(
|
||||||
ctx: TestContext,
|
ctx: TestContext,
|
||||||
description: string,
|
description: string,
|
||||||
@@ -584,7 +603,177 @@ export async function getInvitationsViaApi(ctx: TestContext) {
|
|||||||
const response = await ctx.request.get(
|
const response = await ctx.request.get(
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invitations`
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invitations`
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
expect(response.status()).toBe(200);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
return body.data as Array<{ id: string; email: string; role: string }>;
|
return body.data as Array<{ id: string; email: string; role: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Timestamp-based time entry helpers
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createTimeEntryWithTimestampsViaApi(
|
||||||
|
ctx: TestContext,
|
||||||
|
data: {
|
||||||
|
description?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
billable?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const response = await ctx.request.post(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
member_id: ctx.memberId,
|
||||||
|
start: data.start,
|
||||||
|
end: data.end,
|
||||||
|
description: data.description ?? '',
|
||||||
|
project_id: data.projectId ?? null,
|
||||||
|
task_id: data.taskId ?? null,
|
||||||
|
tags: data.tags ?? [],
|
||||||
|
billable: data.billable ?? false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as { id: string; start: string; end: string; description: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// User profile helpers
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getCurrentUserViaApi(ctx: TestContext) {
|
||||||
|
const response = await ctx.request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me`);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
timezone: string;
|
||||||
|
week_start: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserProfileViaWeb(
|
||||||
|
page: Page,
|
||||||
|
settings: { timezone?: string; week_start?: string }
|
||||||
|
) {
|
||||||
|
// Read user info from Inertia's data-page attribute on the root element
|
||||||
|
const userInfo = await page.evaluate(() => {
|
||||||
|
// Try Inertia's data-page attribute (stores initial page props as JSON)
|
||||||
|
const appEl = document.getElementById('app');
|
||||||
|
if (appEl) {
|
||||||
|
const dataPage = appEl.getAttribute('data-page');
|
||||||
|
if (dataPage) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataPage);
|
||||||
|
const user = parsed?.props?.auth?.user;
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
timezone: user.timezone,
|
||||||
|
week_start: user.week_start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parse failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (!userInfo) throw new Error('Could not read user info from Inertia data-page attribute');
|
||||||
|
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
||||||
|
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
|
||||||
|
|
||||||
|
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/user/profile-information`, {
|
||||||
|
headers: {
|
||||||
|
'X-XSRF-TOKEN': xsrfToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: userInfo.name,
|
||||||
|
email: userInfo.email,
|
||||||
|
timezone: settings.timezone ?? userInfo.timezone,
|
||||||
|
week_start: settings.week_start ?? userInfo.week_start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Running time entry with specific start
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createRunningTimeEntryWithStartViaApi(
|
||||||
|
ctx: TestContext,
|
||||||
|
description: string,
|
||||||
|
start: string
|
||||||
|
) {
|
||||||
|
const response = await ctx.request.post(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
member_id: ctx.memberId,
|
||||||
|
start,
|
||||||
|
description,
|
||||||
|
billable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as { id: string; start: string; end: null; description: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Reports
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createReportViaApi(
|
||||||
|
ctx: TestContext,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
is_public?: boolean;
|
||||||
|
public_until?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const response = await ctx.request.post(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/reports`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: '',
|
||||||
|
is_public: data.is_public ?? true,
|
||||||
|
public_until: data.public_until ?? null,
|
||||||
|
properties: {
|
||||||
|
start: '2024-01-01T00:00:00Z',
|
||||||
|
end: '2030-12-31T23:59:59Z',
|
||||||
|
group: 'project',
|
||||||
|
sub_group: 'project',
|
||||||
|
history_group: 'day',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_public: boolean;
|
||||||
|
public_until: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ export async function getInvitationAcceptUrl(
|
|||||||
expect(searchResult.messages.length).toBeGreaterThan(0);
|
expect(searchResult.messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const message = await getMessage(request, searchResult.messages[0].ID);
|
const message = await getMessage(request, searchResult.messages[0].ID);
|
||||||
const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/);
|
const acceptUrlMatch = message.HTML.match(
|
||||||
|
/href="([^"]*(?:organization-invitations|team-invitations)[^"]*)"/
|
||||||
|
);
|
||||||
expect(acceptUrlMatch).toBeTruthy();
|
expect(acceptUrlMatch).toBeTruthy();
|
||||||
|
|
||||||
return acceptUrlMatch![1].replace(/&/g, '&');
|
return acceptUrlMatch![1].replace(/&/g, '&');
|
||||||
@@ -79,3 +81,64 @@ export async function getPasswordResetUrl(
|
|||||||
|
|
||||||
return resetUrlMatch![1].replace(/&/g, '&');
|
return resetUrlMatch![1].replace(/&/g, '&');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count emails matching the given subject sent to the given address.
|
||||||
|
*/
|
||||||
|
export async function countEmailsWithSubject(
|
||||||
|
request: APIRequestContext,
|
||||||
|
recipientEmail: string,
|
||||||
|
subject: string
|
||||||
|
): Promise<number> {
|
||||||
|
const searchResult = await searchEmails(
|
||||||
|
request,
|
||||||
|
`to:${encodeURIComponent(recipientEmail)} subject:"${subject}"`
|
||||||
|
);
|
||||||
|
return searchResult.messages.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll Mailpit until the count of matching emails reaches `min`, or 5 attempts
|
||||||
|
* (~2.5s) elapse. Returns the final count.
|
||||||
|
*/
|
||||||
|
export async function waitForEmailCount(
|
||||||
|
request: APIRequestContext,
|
||||||
|
recipientEmail: string,
|
||||||
|
subject: string,
|
||||||
|
min: number
|
||||||
|
): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
count = await countEmailsWithSubject(request, recipientEmail, subject);
|
||||||
|
if (count >= min) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the email-change verification URL from a Mailpit email sent to the given address.
|
||||||
|
* Retries a few times to allow for email delivery delay.
|
||||||
|
*/
|
||||||
|
export async function getEmailChangeVerificationUrl(
|
||||||
|
request: APIRequestContext,
|
||||||
|
recipientEmail: string
|
||||||
|
): Promise<string> {
|
||||||
|
let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
searchResult = await searchEmails(
|
||||||
|
request,
|
||||||
|
`to:${encodeURIComponent(recipientEmail)} subject:"Verify Email Address"`
|
||||||
|
);
|
||||||
|
if (searchResult.messages.length > 0) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
expect(searchResult.messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const message = await getMessage(request, searchResult.messages[0].ID);
|
||||||
|
const verifyUrlMatch = message.HTML.match(/href="([^"]*verify-email-change[^"]*)"/);
|
||||||
|
expect(verifyUrlMatch).toBeTruthy();
|
||||||
|
|
||||||
|
return verifyUrlMatch![1].replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
|||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
|
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
use App\Service\Export\ExportException;
|
use App\Service\Export\ExportException;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -49,6 +50,7 @@ return [
|
|||||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||||
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
||||||
|
UserResendEmailVerificationNoPendingEmailApiException::KEY => 'Resend email not possible, no pending email.',
|
||||||
],
|
],
|
||||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
2029
package-lock.json
generated
2029
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user