Compare commits

..

15 Commits

Author SHA1 Message Date
Gregor Vostrak
dad686d107 add pending email to UserResource and update openapi client 2026-05-26 18:02:15 +02:00
Gregor Vostrak
414b5d3294 update ui package dependencies; update lucide imports 2026-05-26 17:30:30 +02:00
Gregor Vostrak
e9217df338 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-05-26 17:21:40 +02:00
Gregor Vostrak
96a0c21b5e update npm dependencies 2026-05-26 17:19:42 +02:00
Gregor Vostrak
8e7c8a1e1b add profile page e2e tests 2026-05-26 17:11:28 +02:00
Gregor Vostrak
6299e242a9 update email address change info to use session based banners 2026-05-26 14:03:30 +02:00
Gregor Vostrak
c573d31ef9 add 1MB photo upload limit 2026-05-26 13:59:44 +02:00
Gregor Vostrak
00ffabe108 add photo delete logic to user update endpoint 2026-05-26 13:23:31 +02:00
Constantin Graf
5b756be058 Updated composer dependencies 2026-05-22 16:18:02 +02:00
Constantin Graf
dc70eb7130 Add more tests 2026-05-22 16:06:51 +02:00
Constantin Graf
c2a8eac65f Add migration to lower case the user emails 2026-05-21 23:22:27 +02:00
Constantin Graf
28ecfc63a3 Migrate permission away from Jetstream; Moved update user to REST API 2026-05-21 23:22:09 +02:00
Gregor Vostrak
433a6f3770 rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-05-20 22:10:10 +02:00
Gregor Vostrak
0ba20fd24c add banners for invitation accept 2026-05-20 21:42:02 +02:00
Constantin Graf
3267acb161 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-05-20 16:25:17 +02:00
202 changed files with 2497 additions and 11659 deletions

View File

@@ -35,7 +35,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -46,9 +46,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -96,7 +96,7 @@ jobs:
node-version: '20.x' node-version: '20.x'
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
repository: solidtime-io/extension-invoicing repository: solidtime-io/extension-invoicing
path: extensions/Invoicing path: extensions/Invoicing
@@ -124,27 +124,27 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -163,7 +163,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -177,25 +177,25 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}

View File

@@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 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
@@ -33,9 +33,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: version id: version
@@ -73,7 +73,7 @@ jobs:
node-version: '20.x' node-version: '20.x'
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v6 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@v6 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@v6 uses: actions/checkout@v5
with: with:
repository: solidtime-io/extension-invoicing repository: solidtime-io/extension-invoicing
path: extensions/Invoicing path: extensions/Invoicing
@@ -160,7 +160,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin
@@ -168,7 +168,7 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: rg.fr-par.scw.cloud/solidtime/solidtime images: rg.fr-par.scw.cloud/solidtime/solidtime
tags: | tags: |
@@ -179,13 +179,13 @@ jobs:
type=sha,format=long type=sha,format=long
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push" - name: "Build and push"
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
build-args: | build-args: |

View File

@@ -36,7 +36,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 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
@@ -47,9 +47,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -109,34 +109,34 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -155,7 +155,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -169,31 +169,31 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}

View File

@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -52,7 +52,7 @@ jobs:
run: php artisan scramble:export --path=build/api-docs.json run: php artisan scramble:export --path=build/api-docs.json
- name: "Upload API docs to GitHub" - name: "Upload API docs to GitHub"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: api-docs.json name: api-docs.json
path: build/api-docs.json path: build/api-docs.json

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v6

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v6

View File

@@ -11,7 +11,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 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

View File

@@ -11,7 +11,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:

View File

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

View File

@@ -10,7 +10,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -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@v7.0.0 uses: codecov/codecov-action@v5.5.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6 uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,7 +35,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@@ -86,7 +86,7 @@ jobs:
MAILPIT_BASE_URL: 'http://localhost:8025' MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload blob report" - name: "Upload blob report"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: blob-report-${{ matrix.shardIndex }} name: blob-report-${{ matrix.shardIndex }}
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
@@ -110,7 +110,7 @@ jobs:
run: npm ci run: npm ci
- name: "Download blob reports" - name: "Download blob reports"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
path: all-blob-reports path: all-blob-reports
pattern: blob-report-* pattern: blob-report-*
@@ -120,7 +120,7 @@ jobs:
run: npx playwright merge-reports --reporter html ./all-blob-reports run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report" - name: "Upload merged HTML report"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/

1
.npmrc
View File

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

View File

@@ -16,6 +16,7 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
use Log; use Log;
class CreateNewUser implements CreatesNewUsers class CreateNewUser implements CreatesNewUsers
@@ -54,7 +55,7 @@ class CreateNewUser implements CreatesNewUsers
}), }),
], ],
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
'terms' => ['accepted', 'required'], 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
'newsletter_consent' => [ 'newsletter_consent' => [
'boolean', 'boolean',
], ],

View File

@@ -5,12 +5,8 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Providers\FortifyServiceProvider;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords; use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords class ResetUserPassword implements ResetsUserPasswords
@@ -24,16 +20,6 @@ class ResetUserPassword implements ResetsUserPasswords
*/ */
public function reset(User $user, array $input): void public function reset(User $user, array $input): void
{ {
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
/** @var PasswordBroker $broker */
$broker = Password::broker(config('fortify.passwords'));
$broker->deleteToken($user);
throw ValidationException::withMessages([
'email' => [__('This password reset link is invalid.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();

View File

@@ -4,9 +4,16 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Exceptions\MovedToApiException; use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation; use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -20,6 +27,61 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/ */
public function update(User $user, array $input): void public function update(User $user, array $input): void
{ {
throw new MovedToApiException; if (isset($input['email']) && is_string($input['email'])) {
$input['email'] = Str::lower($input['email']);
}
Validator::make($input, [
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
'mimes:jpg,jpeg,png',
'max:1024',
],
'timezone' => [
'required',
'timezone:all',
],
'week_start' => [
'required',
Rule::enum(Weekday::class),
],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']);
}
$email = Str::lower((string) $input['email']);
if ($email !== Str::lower($user->email)) {
$user->forceFill([
'name' => $input['name'],
'pending_email' => $email,
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
} else {
$user->forceFill([
'name' => $input['name'],
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();
}
} }
} }

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
class AddOrganizationMember implements AddsTeamMembers
{
/**
* Add a new team member to the given team.
*/
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{
throw new MovedToApiException;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Events\AfterCreateOrganization;
use App\Models\Organization;
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\CreatesTeams;
use Laravel\Jetstream\Jetstream;
class CreateOrganization implements CreatesTeams
{
/**
* Validate and create a new team for the given user.
*
* @param array<string, string> $input
*
* @throws AuthorizationException
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function create(User $user, array $input): Organization
{
Gate::forUser($user)->authorize('create', Jetstream::newTeamModel());
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam');
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$currency = null;
if ($ipLookupResponse !== null) {
$currency = $ipLookupResponse->currency;
}
$organization = app(OrganizationService::class)->createOrganization(
$input['name'],
$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 $organization;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Service\DeletionService;
use Laravel\Jetstream\Contracts\DeletesTeams;
class DeleteOrganization implements DeletesTeams
{
/**
* Delete the given team.
*
* @deprecated Use REST endpoint instead
*/
public function delete(Organization $organization): void
{
/** @see ValidateOrganizationDeletion */
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function delete(User $user): void
{
try {
app(DeletionService::class)->deleteUser($user);
} catch (ApiException $exception) {
throw ValidationException::withMessages([
'password' => $exception->getTranslatedMessage(),
]);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use Exception;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class InviteOrganizationMember implements InvitesTeamMembers
{
/**
* Invite a new team member to the given team.
*
* @throws Exception
*/
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
{
throw new MovedToApiException;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use Exception;
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
class RemoveOrganizationMember implements RemovesTeamMembers
{
/**
* Remove the team member from the given team.
*
* @throws Exception
*/
public function remove(User $user, Organization $organization, User $teamMember): void
{
throw new MovedToApiException;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Exception;
class UpdateMemberRole
{
/**
* Update the role for the given team member.
*
* @throws Exception
*/
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
{
throw new MovedToApiException;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Rules\CurrencyRule;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\UpdatesTeamNames;
class UpdateOrganization implements UpdatesTeamNames
{
/**
* Validate and update the given team's name.
*
* @param array<string, string> $input
*
* @throws AuthorizationException
* @throws ValidationException
*/
public function update(User $user, Organization $organization, array $input): void
{
Gate::forUser($user)->authorize('update', $organization);
Validator::make($input, [
'name' => [
'required',
'string',
'max:255',
],
'currency' => [
'required',
'string',
new CurrencyRule,
],
])->validateWithBag('updateTeamName');
$organization->forceFill([
'name' => $input['name'],
'currency' => $input['currency'],
])->save();
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
class ValidateOrganizationDeletion
{
/**
* Validate that the team can be deleted by the given user.
*
* @param User $user Authenticated user
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*
* @deprecated Use REST endpoint instead
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException;
}
}
}

View File

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

View File

@@ -4,12 +4,8 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum Role: string enum Role: string
{ {
use LaravelEnumHelper;
case Owner = 'owner'; case Owner = 'owner';
case Admin = 'admin'; case Admin = 'admin';
case Manager = 'manager'; case Manager = 'manager';

View File

@@ -9,9 +9,6 @@ use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy TeamMemberAdded event.
*/
class MemberAdded class MemberAdded
{ {
use Dispatchable; use Dispatchable;

View File

@@ -9,9 +9,6 @@ use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy AddingTeamMember event.
*/
class MemberAdding class MemberAdding
{ {
use Dispatchable; use Dispatchable;

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy InvitingTeamMember event.
*/
class OrganizationInvitationAdding
{
use Dispatchable;
public Organization $organization;
public string $email;
public Role $role;
public User $inviter;
public function __construct(
Organization $organization,
string $email,
Role $role,
User $inviter
) {
$this->role = $role;
$this->email = $email;
$this->organization = $organization;
$this->inviter = $inviter;
}
}

View File

@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager class InvitationsRelationManager extends RelationManager
{ {
protected static string $relationship = 'organizationInvitations'; protected static string $relationship = 'teamInvitations';
protected static ?string $title = 'Invitations'; protected static ?string $title = 'Invitations';
@@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager
$ownerRecord = $this->getOwnerRecord(); $ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class) return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user()); ->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
}), }),
]) ])
->actions([ ->actions([

View File

@@ -12,7 +12,6 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat
use App\Models\User; use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Exception; use Exception;
use Filament\Forms; use Filament\Forms;
@@ -180,7 +179,7 @@ class UserResource extends Resource
]) ])
->actions([ ->actions([
Impersonate::make()->before(function (User $record): void { Impersonate::make()->before(function (User $record): void {
if ($record->currentOrganization === null) { if ($record->currentTeam === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first(); $organization = $record->organizations()->where('personal_team', '=', true)->first();
if ($organization === null) { if ($organization === null) {
$organization = $record->organizations()->first(); $organization = $record->organizations()->first();
@@ -188,7 +187,8 @@ class UserResource extends Resource
if ($organization === null) { if ($organization === null) {
throw new Exception('User has no organization'); throw new Exception('User has no organization');
} }
app(UserService::class)->switchCurrentOrganization($record, $organization); $record->currentTeam()->associate($organization);
$record->save();
} }
}), }),
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

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

View File

@@ -40,7 +40,7 @@ class InvitationController extends Controller
{ {
$this->checkPermission($organization, 'invitations:view'); $this->checkPermission($organization, 'invitations:view');
$invitations = $organization->organizationInvitations() $invitations = $organization->teamInvitations()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
@@ -63,7 +63,7 @@ class InvitationController extends Controller
$email = $request->getEmail(); $email = $request->getEmail();
$role = $request->getRole(); $role = $request->getRole();
$invitationService->inviteUser($organization, $email, $role, $this->user()); $invitationService->inviteUser($organization, $email, $role);
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -192,7 +192,7 @@ class MemberController extends Controller
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
} }
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user()); $invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\AfterCreateOrganization; use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest; 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;
@@ -15,7 +14,6 @@ use App\Service\BillableRateService;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService; use App\Service\OrganizationService;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -51,9 +49,6 @@ class OrganizationController extends Controller
if ($request->getName() !== null) { if ($request->getName() !== null) {
$organization->name = $request->getName(); $organization->name = $request->getName();
} }
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
if ($request->getEmployeesCanSeeBillableRates() !== null) { if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
} }
@@ -111,8 +106,10 @@ class OrganizationController extends Controller
$currency $currency
); );
app(UserService::class)->switchCurrentOrganization($user, $organization); $user->switchTeam($organization);
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization); AfterCreateOrganization::dispatch($organization);
return new OrganizationResource($organization, true); return new OrganizationResource($organization, true);
@@ -125,7 +122,7 @@ class OrganizationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
{ {
$this->checkPermission($organization, 'organizations:delete'); $this->checkPermission($organization, 'organizations:delete');

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Service\TimezoneService;
use Illuminate\Http\JsonResponse;
class TimeZoneController extends Controller
{
/**
* Get all timezones
*
* @response object{key: string}[]
*
* @operationId getTimezones
*/
public function index(): JsonResponse
{
$timezones = app(TimezoneService::class)->getTimezones();
$response = [];
foreach ($timezones as $timezone) {
$response[] = (object) [
'key' => $timezone,
];
}
return response()->json($response);
}
}

View File

@@ -6,15 +6,11 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException; use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Http\Requests\V1\User\UserDestroyRequest;
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
use App\Http\Requests\V1\User\UserUpdateRequest; 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\Mail\VerifyUpdatedEmailMail;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\UserService;
use App\Support\Base64File; use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -40,35 +36,6 @@ class UserController extends Controller
return new UserResource($user); return new UserResource($user);
} }
/**
* Update the current organization of the current user
*
* Switches the organization that the user is currently working in. The user
* must be a member of the given organization. This endpoint is independent of
* the organization.
*
* @operationId updateMyCurrentOrganization
*
* @throws AuthorizationException
*/
public function updateMyCurrentOrganization(UserUpdateCurrentOrganizationRequest $request, UserService $userService): UserResource
{
$user = $this->user();
/** @var Organization|null $organization */
$organization = $user->organizations()
->whereKey($request->getOrganizationId())
->first();
if ($organization === null) {
throw new AuthorizationException;
}
$userService->switchCurrentOrganization($user, $organization);
return new UserResource($user->refresh());
}
/** /**
* Update the current user * Update the current user
* *
@@ -83,7 +50,7 @@ class UserController extends Controller
} }
if ($request->hasPhotoKey()) { if ($request->hasPhotoKey()) {
$photoDisk = (string) config('filesystems.public'); $photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$previousPhotoPath = $user->profile_photo_path; $previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto(); $newPhoto = $request->getPhoto();
@@ -133,27 +100,6 @@ class UserController extends Controller
return new UserResource($user); return new UserResource($user);
} }
/**
* Reset the pending email for a user.
*
* This endpoint is independent of the organization.
*
* @operationId resetUserPendingEmail
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
*/
public function resetPendingEmail(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$user->pending_email = null;
$user->save();
return response()->json(null, 204);
}
/** /**
* Resend the pending email update verification email. * Resend the pending email update verification email.
* *
@@ -194,7 +140,7 @@ class UserController extends Controller
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted. * @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. * @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/ */
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse public function destroy(User $user, DeletionService $deletionService): JsonResponse
{ {
if ($user->getKey() !== $this->user()->getKey()) { if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException; throw new AuthorizationException;

View File

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

View File

@@ -4,21 +4,4 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Models\Organization; abstract class Controller extends \App\Http\Controllers\Controller {}
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
abstract class Controller extends \App\Http\Controllers\Controller
{
public function __construct(
protected PermissionStore $permissionStore,
) {}
/**
* @throws AuthorizationException
*/
protected function hasPermission(Organization $organization, string $permission): bool
{
return $this->permissionStore->has($organization, $permission);
}
}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\Organization;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class OrganizationController extends Controller
{
/**
* Show the team creation screen.
*/
public function create(Request $request): Response
{
return Inertia::render('Teams/Create');
}
/**
* Show the organizatio details screen.
*
* @param string $organizationId The organization ID
*/
public function show(string $organizationId): Response|RedirectResponse
{
$organization = Str::isUuid($organizationId) ? Organization::find($organizationId) : null;
if ($organization === null) {
return redirect()->route('dashboard');
}
if (! $this->hasPermission($organization, 'organizations:view')) {
return redirect()->route('dashboard');
}
$owner = $organization->owner;
return Inertia::render('Teams/Show', [
'team' => [
'id' => $organization->getKey(),
'name' => $organization->name,
'currency' => $organization->currency,
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'profile_photo_url' => $owner->profile_photo_url,
],
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
'permissions' => [
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),
],
]);
}
}

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmPassword;
class OtherBrowserSessionsController extends Controller
{
/**
* Log the user out of their other browser sessions across all devices.
*/
public function destroy(Request $request, StatefulGuard $guard): RedirectResponse
{
$password = (string) $request->string('password');
$confirmed = app(ConfirmPassword::class)($guard, $request->user(), $password);
if (! $confirmed) {
throw ValidationException::withMessages([
'password' => __('The password is incorrect.'),
]);
}
$guard->logoutOtherDevices($password);
$this->deleteOtherSessionRecords($request);
return back(303);
}
/**
* Delete the other browser session records from storage.
*/
protected function deleteOtherSessionRecords(Request $request): void
{
if (config('session.driver') !== 'database') {
return;
}
DB::connection(config('session.connection'))
->table(config('session.table', 'sessions'))
->where('user_id', $request->user()->getAuthIdentifier())
->where('id', '!=', $request->session()->getId())
->delete();
}
}

View File

@@ -1,142 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Weekday;
use App\Service\Dto\UserAgentDto;
use App\Service\TimezoneService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
class UserProfileController extends Controller
{
/**
* Validate the two-factor authentication state for the request.
*/
protected function validateTwoFactorAuthenticationState(Request $request): void
{
if (! Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
return;
}
$currentTime = time();
// Notate totally disabled state in session...
if ($this->twoFactorAuthenticationDisabled($request)) {
$request->session()->put('two_factor_empty_at', $currentTime);
}
// If was previously totally disabled this session but is now confirming, notate time...
if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) {
$request->session()->put('two_factor_confirming_at', $currentTime);
}
// If the profile is reloaded and is not confirmed but was previously in confirming state, disable...
if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) {
app(DisableTwoFactorAuthentication::class)(Auth::user());
$request->session()->put('two_factor_empty_at', $currentTime);
$request->session()->remove('two_factor_confirming_at');
}
}
/**
* Determine if two-factor authentication is totally disabled.
*
* @return bool
*/
protected function twoFactorAuthenticationDisabled(Request $request)
{
return is_null($request->user()->two_factor_secret) &&
is_null($request->user()->two_factor_confirmed_at);
}
/**
* Determine if two-factor authentication is just now being confirmed within the last request cycle.
*
* @return bool
*/
protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request)
{
return ! is_null($request->user()->two_factor_secret) &&
is_null($request->user()->two_factor_confirmed_at) &&
$request->session()->has('two_factor_empty_at') &&
is_null($request->session()->get('two_factor_confirming_at'));
}
/**
* Determine if two-factor authentication was never totally confirmed once confirmation started.
*
* @return bool
*/
protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime)
{
return ! array_key_exists('code', $request->session()->getOldInput()) &&
is_null($request->user()->two_factor_confirmed_at) &&
$request->session()->get('two_factor_confirming_at', 0) !== $currentTime;
}
/**
* Show the general profile settings screen.
*/
public function show(Request $request): Response
{
$this->validateTwoFactorAuthenticationState($request);
return Inertia::render('Profile/Show', [
'timezones' => app(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request),
]);
}
/**
* Get the current sessions.
*
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&\stdClass>
*/
public function sessions(Request $request): array
{
if (config('session.driver') !== 'database') {
return [];
}
return collect(
DB::connection(config('session.connection'))->table(config('session.table', 'sessions'))
->where('user_id', $request->user()->getAuthIdentifier())
->orderBy('last_activity', 'desc')
->get()
)->map(function (object $session) use ($request): object {
$agent = $this->createAgent(is_string($session->user_agent) ? $session->user_agent : '');
return (object) [
'agent' => [
'is_desktop' => $agent->isDesktop(),
'platform' => $agent->platform(),
'browser' => $agent->browser(),
],
'ip_address' => is_string($session->ip_address) ? $session->ip_address : '',
'is_current_device' => $session->id === $request->session()->getId(),
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
];
})->all();
}
/**
* Create a new agent instance from the given session.
*/
protected function createAgent(string $userAgent): UserAgentDto
{
return tap(new UserAgentDto, fn ($agent) => $agent->setUserAgent($userAgent));
}
}

View File

@@ -17,7 +17,7 @@ class EnsureEmailIsVerified
*/ */
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
{ {
if (! app()->isLocal() || config('app.local_email_verification')) { if (! app()->isLocal()) {
if ($request->user() === null || if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) { (! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson() return $request->expectsJson()

View File

@@ -46,7 +46,7 @@ class HandleInertiaRequests extends Middleware
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
$currentOrganization = $request->user()?->currentOrganization; $currentOrganization = $request->user()?->currentTeam;
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,

View File

@@ -9,10 +9,12 @@ use App\Models\User;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Illuminate\Support\MessageBag; use Illuminate\Support\MessageBag;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class ShareInertiaData class ShareInertiaData
@@ -25,8 +27,27 @@ class ShareInertiaData
/** @var PermissionStore $permissions */ /** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class); $permissions = app(PermissionStore::class);
Inertia::share([ Inertia::share([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
return [
'canCreateTeams' => $user !== null &&
Jetstream::userHasTeamFeatures($user) &&
Gate::forUser($user)->check('create', Jetstream::newTeamModel()),
'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
'hasApiFeatures' => Jetstream::hasApiFeatures(),
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
'managesProfilePhotos' => Jetstream::managesProfilePhotos(),
];
},
'auth' => [ 'auth' => [
'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [], 'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
'user' => function () use ($request): array { 'user' => function () use ($request): array {
/** @var User|null $user */ /** @var User|null $user */
$user = $request->user(); $user = $request->user();
@@ -35,8 +56,6 @@ class ShareInertiaData
return []; return [];
} }
$currentOrganization = $user->currentOrganization;
return array_merge([ return array_merge([
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
@@ -49,12 +68,12 @@ class ShareInertiaData
'profile_photo_url' => $user->profile_photo_url, 'profile_photo_url' => $user->profile_photo_url,
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication()) 'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
&& ! is_null($user->two_factor_secret), && ! is_null($user->two_factor_secret),
'current_team' => $currentOrganization !== null ? [ 'current_team' => $user->currentTeam !== null ? [
'id' => $currentOrganization->id, 'id' => $user->currentTeam->id,
'user_id' => $currentOrganization->user_id, 'user_id' => $user->currentTeam->user_id,
'name' => $currentOrganization->name, 'name' => $user->currentTeam->name,
'personal_team' => $currentOrganization->personal_team, 'personal_team' => $user->currentTeam->personal_team,
'currency' => $currentOrganization->currency, 'currency' => $user->currentTeam->currency,
] : null, ] : null,
], array_filter([ ], array_filter([
'all_teams' => $user->organizations->map(function (Organization $organization): array { 'all_teams' => $user->organizations->map(function (Organization $organization): array {

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Validator;
class OrganizationDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'password' => [
'required',
'string',
],
];
}
/**
* @return array<int, callable(Validator): void>
*/
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->has('password')) {
return;
}
$user = $this->user();
$password = $this->input('password');
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
$validator->errors()->add('password', __('The password is incorrect.'));
}
},
];
}
}

View File

@@ -11,8 +11,6 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Rules\CurrencyRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
@@ -23,7 +21,7 @@ class OrganizationUpdateRequest 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|\Illuminate\Contracts\Validation\Rule|ValidationRule>> * @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -32,10 +30,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
'max:255', 'max:255',
], ],
'currency' => [
'string',
new CurrencyRule,
],
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',
@@ -74,11 +68,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
return $this->has('name') ? (string) $this->input('name') : null; return $this->has('name') ? (string) $this->input('name') : null;
} }
public function getCurrency(): ?string
{
return $this->has('currency') ? (string) $this->input('currency') : null;
}
public function getNumberFormat(): ?NumberFormat public function getNumberFormat(): ?NumberFormat
{ {
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null; return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Validator;
class UserDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'password' => [
'required',
'string',
],
];
}
/**
* @return array<int, callable(Validator): void>
*/
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->has('password')) {
return;
}
$user = $this->user();
$password = $this->input('password');
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
$validator->errors()->add('password', __('The password is incorrect.'));
}
},
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class UserUpdateCurrentOrganizationRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'organization_id' => [
'required',
'string',
'uuid',
],
];
}
public function getOrganizationId(): string
{
return (string) $this->input('organization_id');
}
}

View File

@@ -9,11 +9,10 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory; use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Laravel\Jetstream\Membership as JetstreamMembership;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -31,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
class Member extends Pivot implements AuditableContract class Member extends JetstreamMembership implements AuditableContract
{ {
use CustomAuditable; use CustomAuditable;

View File

@@ -14,7 +14,6 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory; use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -22,6 +21,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Team;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -39,7 +43,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Collection<int, User> $users * @property Collection<int, User> $users
* @property Collection<int, User> $realUsers * @property Collection<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $organizationInvitations * @property-read Collection<int, OrganizationInvitation> $teamInvitations
* @property Member $membership * @property Member $membership
* @property NumberFormat $number_format * @property NumberFormat $number_format
* @property CurrencyFormat $currency_format * @property CurrencyFormat $currency_format
@@ -47,9 +51,10 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format * @property IntervalFormat $interval_format
* @property TimeFormat $time_format * @property TimeFormat $time_format
* *
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
*/ */
class Organization extends Model implements AuditableContract class Organization extends JetstreamTeam implements AuditableContract
{ {
use CustomAuditable; use CustomAuditable;
@@ -87,6 +92,17 @@ class Organization extends Model implements AuditableContract
'personal_team', 'personal_team',
]; ];
/**
* The event map for the model.
*
* @var array<string, class-string>
*/
protected $dispatchesEvents = [
'created' => TeamCreated::class,
'updated' => TeamUpdated::class,
'deleted' => TeamDeleted::class,
];
/** /**
* The model's default values for attributes. * The model's default values for attributes.
* *
@@ -95,6 +111,23 @@ class Organization extends Model implements AuditableContract
protected $attributes = [ protected $attributes = [
]; ];
/**
* Get all the non-placeholder users of the organization including its owner.
*
* @return Collection<int, User>
*/
public function allRealUsers(): Collection
{
return $this->realUsers->merge([$this->owner]);
}
public function hasRealUserWithEmail(string $email): bool
{
return $this->allRealUsers()->contains(function (User $user) use ($email): bool {
return $user->email === $email;
});
}
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *
@@ -140,21 +173,12 @@ class Organization extends Model implements AuditableContract
} }
/** /**
* @return HasMany<OrganizationInvitation, $this> * This method prevents an unhandled exception when the ID is not a UUID.
*/ * Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
public function organizationInvitations(): HasMany
{
return $this->hasMany(OrganizationInvitation::class, 'organization_id');
}
/**
* Find a model by its primary key or throw an exception.
* *
* @param array<int, string> $columns * @param array<string> $columns
*
* @throws ModelNotFoundException<Model>
*/ */
public static function findOrFail(string $id, array $columns = ['*']): Model public function findOrFail(string $id, array $columns = ['*']): Team
{ {
if (! Str::isUuid($id)) { if (! Str::isUuid($id)) {
throw (new ModelNotFoundException)->setModel( throw (new ModelNotFoundException)->setModel(

View File

@@ -8,9 +8,9 @@ use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationInvitationFactory; use Database\Factories\OrganizationInvitationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -25,7 +25,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* *
* @method static OrganizationInvitationFactory factory() * @method static OrganizationInvitationFactory factory()
*/ */
class OrganizationInvitation extends Model implements AuditableContract class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
{ {
use CustomAuditable; use CustomAuditable;

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
@@ -26,6 +25,8 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable; use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
@@ -44,13 +45,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Weekday $week_start * @property Weekday $week_start
* @property string|null $profile_photo_path * @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization * @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url * @property-read string $profile_photo_url
* @property-read Collection<int, Token> $tokens * @property-read Collection<int, Token> $tokens
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property string|null $current_team_id * @property string|null $current_team_id
* @property Collection<int, Organization> $organizations * @property Collection<int, Organization> $organizations
* @property Collection<int, Organization> $ownedOrganizations
* @property Collection<int, TimeEntry> $timeEntries * @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership * @property Member $membership
* *
@@ -68,6 +69,8 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory; use HasFactory;
use HasProfilePhoto;
use HasTeams;
use HasUuids; use HasUuids;
use Notifiable; use Notifiable;
use TwoFactorAuthenticatable; use TwoFactorAuthenticatable;
@@ -128,47 +131,14 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
{ {
return Attribute::get(function (): string { return Attribute::get(function (): string {
return $this->profile_photo_path return $this->profile_photo_path
? Storage::disk(config('filesystems.public'))->url($this->profile_photo_path) ? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
: $this->defaultProfilePhotoUrl(); : $this->defaultProfilePhotoUrl();
}); });
} }
/**
* Get the default profile photo URL if no profile photo has been uploaded.
*/
protected function defaultProfilePhotoUrl(): string
{
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
return mb_substr($segment, 0, 1);
})->join(' '));
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
}
public function isSuperAdmin(): bool
{
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function hasLocalPassword(): bool
{
return is_string($this->password) && $this->password !== '';
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->isSuperAdmin(); return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function isMemberOfOrganization(Organization $organization): bool
{
if ($this->relationLoaded('organizations')) {
return $this->organizations->contains(function (Organization $o) use ($organization): bool {
return $o->getKey() === $organization->getKey();
});
}
return $this->organizations()->whereKey($organization->getKey())->exists();
} }
public function canBeImpersonated(): bool public function canBeImpersonated(): bool
@@ -191,14 +161,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
->as('membership'); ->as('membership');
} }
/**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
*/
public function ownedOrganizations(): BelongsToMany
{
return $this->organizations()->wherePivot('role', Role::Owner->value);
}
/** /**
* @return HasMany<TimeEntry, $this> * @return HasMany<TimeEntry, $this>
*/ */
@@ -253,8 +215,12 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/ */
public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder
{ {
return $builder->where(function (Builder $builder) use ($organization): Builder {
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void { return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey()); $query->whereKey($organization->getKey());
})->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey());
});
}); });
} }
} }

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization;
class OrganizationPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
if (Filament::isServing()) {
return true;
}
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->belongsToTeam($organization);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
if (Filament::isServing()) {
return true;
}
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
}
/**
* Determine whether the user can update team member permissions.
*/
public function updateTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
// Note: since this policy is only used for jetstream endpoints, we can return false here
return false;
}
/**
* Determine whether the user can remove team members.
*/
public function removeTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
// Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->ownsTeam($organization);
}
}

View File

@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode; use App\Models\Passport\AuthCode;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken; use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider class AuthServiceProvider extends ServiceProvider
@@ -19,6 +22,7 @@ class AuthServiceProvider extends ServiceProvider
* @var array<class-string, class-string> * @var array<class-string, class-string>
*/ */
protected $policies = [ protected $policies = [
Organization::class => OrganizationPolicy::class,
]; ];
/** /**
@@ -52,5 +56,11 @@ class AuthServiceProvider extends ServiceProvider
// Passport::tokensExpireIn(now()->addDays(15)); // Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30)); // Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::personalAccessTokensExpireIn(now()->addMonths(12)); Passport::personalAccessTokensExpireIn(now()->addMonths(12));
// same as passport default above
Jetstream::defaultApiTokenPermissions(['read']);
// use passport scopes for jetstream token permissions
Jetstream::permissions(Passport::scopeIds());
} }
} }

View File

@@ -26,7 +26,6 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module; use Nwidart\Modules\Facades\Module;
use Nwidart\Modules\Laravel\Module as LaravelModule;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
@@ -92,77 +91,22 @@ class AdminPanelProvider extends PanelProvider
$modules = Module::allEnabled(); $modules = Module::allEnabled();
foreach ($modules as $module) { foreach ($modules as $module) {
$moduleNamespace = $this->getModuleAppNamespace($module);
$panel->discoverResources( $panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'), in: module_path($module->getName(), 'app/Filament/Resources'),
for: $moduleNamespace.'\\Filament\\Resources' for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
); );
$panel->discoverPages( $panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'), in: module_path($module->getName(), 'app/Filament/Pages'),
for: $moduleNamespace.'\\Filament\\Pages' for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
); );
$panel->discoverWidgets( $panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'), in: module_path($module->getName(), 'app/Filament/Widgets'),
for: $moduleNamespace.'\\Filament\\Widgets' for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
); );
} }
return $panel; return $panel;
} }
/** @var array<string, string> Cache of module name => resolved app namespace. */
private static array $moduleAppNamespaces = [];
private function getModuleAppNamespace(LaravelModule $module): string
{
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
}
/**
* Resolve the PHP namespace mapped to a module's app/ directory so the
* Filament panel can discover its Resources/Pages/Widgets under the right
* namespace.
*
* Two module layouts currently coexist in this repo:
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
* app/ e.g. "Extensions\SSO\" => app/, so classes are
* Extensions\SSO\Filament\... (this is the current convention).
* - the older layout: an "...\App" namespace maps to app/ e.g.
* "Extensions\Billing\App\" => app/, so classes are
* Extensions\Billing\App\Filament\...
*
* The package's own namespace derivation assumes the v12 (bare) layout and
* would mis-resolve the legacy modules, so we read each module's composer
* PSR-4 map and use whichever namespace actually points at app/. The legacy
* "...\App" shape is only a fallback for when composer is missing/unreadable.
* Once every module adopts the bare layout this collapses to
* config('modules.namespace').'\\'.$module->getName().
*/
private function resolveModuleAppNamespace(LaravelModule $module): string
{
$fallback = 'Extensions\\'.$module->getName().'\\App';
$composerPath = module_path($module->getName(), 'composer.json');
$psr4 = [];
if (is_file($composerPath)) {
$composer = json_decode((string) file_get_contents($composerPath), true);
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
}
foreach ((array) $psr4 as $namespace => $path) {
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
return rtrim($namespace, '\\');
}
}
return $fallback;
}
private function normalizeComposerPath(mixed $path): string
{
return trim(str_replace('\\', '/', (string) $path), '/');
}
} }

View File

@@ -15,83 +15,15 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse; use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Responses\LoginResponse;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
/**
* Dummy bcrypt hash compared against when no user matches the submitted
* email. Hash::check is run against it so login takes the same time whether
* or not the email exists otherwise an unknown email would skip the
* (deliberately slow) hash and return faster, letting an attacker enumerate
* registered accounts by timing the response. The plaintext is irrelevant:
* it is only ever checked against attacker-supplied input and never matches.
*/
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
/**
* Authorization rules applied AFTER the password is verified. Each rule
* receives the authenticated user + request and returns whether the login
* may proceed; any rule returning false denies it. This is an extension
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
* instead of replacing this credential check which would silently drift
* from the host logic the next time it changes.
*
* @var array<int, \Closure(User, Request): bool>
*/
protected static array $loginRules = [];
/**
* Authorization rules applied before a password reset is completed. Rules
* receive the user being reset + submitted input and return whether the
* local reset flow may set a new password for that account.
*
* @var array<int, \Closure(User, array<string, mixed>): bool>
*/
protected static array $passwordResetRules = [];
/**
* Register an additional rule that gates password login (see $loginRules).
*
* @param \Closure(User, Request): bool $rule
*/
public static function authenticateUsingRule(\Closure $rule): void
{
static::$loginRules[] = $rule;
}
/**
* Register an additional rule that gates password reset completion.
*
* @param \Closure(User, array<string, mixed>): bool $rule
*/
public static function resetPasswordUsingRule(\Closure $rule): void
{
static::$passwordResetRules[] = $rule;
}
/**
* Check whether the given user may complete the local password reset flow.
*
* @param array<string, mixed> $input
*/
public static function canResetPassword(User $user, array $input = []): bool
{
foreach (static::$passwordResetRules as $rule) {
if (! $rule($user, $input)) {
return false;
}
}
return true;
}
/** /**
* Register any application services. * Register any application services.
*/ */
@@ -118,40 +50,6 @@ class FortifyServiceProvider extends ServiceProvider
]); ]);
}); });
Fortify::loginView(function () {
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
});
Fortify::requestPasswordResetLinkView(function () {
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
});
Fortify::resetPasswordView(function (Request $request) {
return Inertia::render('Auth/ResetPassword', [
'email' => $request->input('email'),
'token' => $request->route('token'),
]);
});
Fortify::verifyEmailView(function () {
return Inertia::render('Auth/VerifyEmail', [
'status' => session('status'),
]);
});
Fortify::twoFactorChallengeView(function () {
return Inertia::render('Auth/TwoFactorChallenge');
});
Fortify::confirmPasswordView(function () {
return Inertia::render('Auth/ConfirmPassword');
});
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()
@@ -159,23 +57,7 @@ class FortifyServiceProvider extends ServiceProvider
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->first(); ->first();
// Always run the hash check — against the real hash, or a dummy when if ($user !== null && Hash::check($request->password, $user->password)) {
// there is no user — so login timing is identical either way (see
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
// have password = null) fail here, so they cannot password-login.
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
if ($user !== null && $passwordIsValid) {
// Credentials are valid; now apply any registered authorization
// rules (e.g. SSO enforcement may still block password login).
foreach (static::$loginRules as $rule) {
if (! $rule($user, $request)) {
return null;
}
}
return $user; return $user;
} }
@@ -192,7 +74,7 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id')); return Limit::perMinute(5)->by($request->session()->get('login.id'));
}); });
$this->app->instance(LoginResponseContract::class, new CustomLoginResponse); $this->app->instance(LoginResponse::class, new CustomLoginResponse);
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse); $this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
} }
} }

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Jetstream\AddOrganizationMember;
use App\Actions\Jetstream\CreateOrganization;
use App\Actions\Jetstream\DeleteOrganization;
use App\Actions\Jetstream\DeleteUser;
use App\Actions\Jetstream\InviteOrganizationMember;
use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Weekday;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\PermissionStore;
use App\Service\TimezoneService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configurePermissions();
Jetstream::createTeamsUsing(CreateOrganization::class);
Jetstream::updateTeamNamesUsing(UpdateOrganization::class);
Jetstream::addTeamMembersUsing(AddOrganizationMember::class);
Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class);
Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class);
Jetstream::deleteTeamsUsing(DeleteOrganization::class);
Jetstream::deleteUsersUsing(DeleteUser::class);
Jetstream::useTeamModel(Organization::class);
Jetstream::useMembershipModel(Member::class);
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Gate::define('removeTeamMember', function (User $user, Organization $team) {
return false;
});
}
/**
* Configure the roles and permissions that are available within the application.
*/
protected function configurePermissions(): void
{
Jetstream::defaultApiTokenPermissions([]);
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
Jetstream::role($role, $definition['name'], $definition['permissions'])
->description($definition['description']);
}
Jetstream::inertia()
->whenRendering(
'Profile/Show',
function (Request $request, array $data): array {
return array_merge($data, [
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
]);
}
)
->whenRendering(
'Teams/Show',
function (Request $request, array $data): array {
/** @var Organization $teamModel */
$teamModel = $data['team'];
$owner = $teamModel->owner;
return array_merge($data, [
'team' => [
'id' => $teamModel->getKey(),
'name' => $teamModel->name,
'currency' => $teamModel->currency,
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'profile_photo_url' => $owner->profile_photo_url,
],
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
]);
}
);
}
}

View File

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

View File

@@ -1,179 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use Closure;
use Detection\MobileDetect;
/**
* @copyright Originally created by Jens Segers: https://github.com/jenssegers/agent
*/
class UserAgentDto extends MobileDetect
{
/**
* List of additional operating systems.
*
* @var array<string, string>
*/
protected static array $additionalOperatingSystems = [
'Windows' => 'Windows',
'Windows NT' => 'Windows NT',
'OS X' => 'Mac OS X',
'Debian' => 'Debian',
'Ubuntu' => 'Ubuntu',
'Macintosh' => 'PPC',
'OpenBSD' => 'OpenBSD',
'Linux' => 'Linux',
'ChromeOS' => 'CrOS',
];
/**
* List of additional browsers.
*
* @var array<string, string>
*/
protected static array $additionalBrowsers = [
'Opera Mini' => 'Opera Mini',
'Opera' => 'Opera|OPR',
'Edge' => 'Edge|Edg',
'Coc Coc' => 'coc_coc_browser',
'UCBrowser' => 'UCBrowser',
'Vivaldi' => 'Vivaldi',
'Chrome' => 'Chrome',
'Firefox' => 'Firefox',
'Safari' => 'Safari',
'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+',
'Netscape' => 'Netscape',
'Mozilla' => 'Mozilla',
'WeChat' => 'MicroMessenger',
];
/**
* Key value store for resolved strings.
*
* @var array<string, mixed>
*/
protected array $store = [];
/**
* Get the platform name from the User Agent.
*/
public function platform(): ?string
{
return $this->retrieveUsingCacheOrResolve('platform', function () {
return $this->findDetectionRulesAgainstUserAgent(
$this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems)
);
});
}
/**
* Get the browser name from the User Agent.
*/
public function browser(): ?string
{
return $this->retrieveUsingCacheOrResolve('browser', function (): ?string {
return $this->findDetectionRulesAgainstUserAgent(
$this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers())
);
});
}
/**
* Determine if the device is a desktop computer.
*/
public function isDesktop(): bool
{
return $this->retrieveUsingCacheOrResolve('desktop', function (): bool {
// Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront'
if (
$this->getUserAgent() === static::$cloudFrontUA
&& $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true'
) {
return true;
}
return ! $this->isMobile() && ! $this->isTablet();
});
}
/**
* Match a detection rule and return the matched key.
*
* @param array<string, string|list<string>> $rules
*/
protected function findDetectionRulesAgainstUserAgent(array $rules): ?string
{
$userAgent = $this->getUserAgent();
foreach ($rules as $key => $regex) {
if (is_array($regex)) {
$regex = implode('|', $regex);
}
if (empty($regex)) {
continue;
}
if ($this->match($regex, $userAgent)) {
if ($key !== '') {
return $key;
}
$match = reset($this->matchesArray);
return is_string($match) ? $match : null;
}
}
return null;
}
/**
* Retrieve from the given key from the cache or resolve the value.
*
* @template TReturn of string|bool|null
*
* @param Closure():TReturn $callback
* @return TReturn
*/
protected function retrieveUsingCacheOrResolve(string $key, Closure $callback): string|bool|null
{
$cacheKey = $this->createCacheKey($key);
if (! is_null($cacheItem = $this->store[$cacheKey] ?? null)) {
return $cacheItem;
}
return tap(call_user_func($callback), function ($result) use ($cacheKey): void {
$this->store[$cacheKey] = $result;
});
}
/**
* Merge multiple rules into one array.
*
* @param array<string, string|list<string>> ...$all
* @return array<string, string>
*/
protected function mergeRules(array ...$all): array
{
$merged = [];
foreach ($all as $rules) {
foreach ($rules as $key => $value) {
$value = is_array($value) ? implode('|', $value) : $value;
if (empty($merged[$key])) {
$merged[$key] = $value;
} else {
$merged[$key] .= '|'.$value;
}
}
}
return $merged;
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Events\OrganizationInvitationAdding;
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;
@@ -15,13 +14,14 @@ use App\Models\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService class InvitationService
{ {
/** /**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/ */
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{ {
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) { if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
@@ -34,7 +34,7 @@ class InvitationService
throw new InvitationForTheEmailAlreadyExistsApiException; throw new InvitationForTheEmailAlreadyExistsApiException;
} }
OrganizationInvitationAdding::dispatch($organization, $email, $role, $inviter); InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation; $invitation = new OrganizationInvitation;
$invitation->email = $email; $invitation->email = $email;

View File

@@ -23,6 +23,8 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class MemberService class MemberService
{ {
@@ -37,6 +39,7 @@ class MemberService
{ {
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
MemberAdding::dispatch($user, $organization, $role); MemberAdding::dispatch($user, $organization, $role);
AddingTeamMember::dispatch($organization, $user); // Legacy event
} }
$member = new Member; $member = new Member;
@@ -53,6 +56,7 @@ class MemberService
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
MemberAdded::dispatch($member, $organization, $user); MemberAdded::dispatch($member, $organization, $user);
TeamMemberAdded::dispatch($organization, $user); // Legacy event
} }
return $member; return $member;
@@ -93,7 +97,7 @@ class MemberService
$isPlaceholder = $user->is_placeholder; $isPlaceholder = $user->is_placeholder;
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) { if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
$user->currentOrganization()->disassociate(); $user->currentTeam()->disassociate();
$user->save(); $user->save();
} }
@@ -212,7 +216,7 @@ class MemberService
{ {
$user = $member->user; $user = $member->user;
if ($user->current_team_id === $member->organization_id) { if ($user->current_team_id === $member->organization_id) {
$user->currentOrganization()->disassociate(); $user->currentTeam()->disassociate();
$user->save(); $user->save();
} }

View File

@@ -291,7 +291,7 @@ class PermissionStore
public function userHas(Organization $organization, User $user, string $permission): bool public function userHas(Organization $organization, User $user, string $permission): bool
{ {
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) { if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
if (! $user->isMemberOfOrganization($organization)) { if (! $user->belongsToTeam($organization)) {
return false; return false;
} }
@@ -309,7 +309,7 @@ class PermissionStore
*/ */
private function getPermissionsByUser(Organization $organization, User $user): array private function getPermissionsByUser(Organization $organization, User $user): array
{ {
if (! $user->isMemberOfOrganization($organization)) { if (! $user->belongsToTeam($organization)) {
return []; return [];
} }

View File

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

View File

@@ -19,7 +19,6 @@ use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
class UserService class UserService
{ {
@@ -48,56 +47,6 @@ class UserService
} }
$user->save(); $user->save();
$this->createDefaultOrganizationForUser(
$user,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
return $user;
}
/**
* Create a user without a password (e.g. provisioned via SSO). Such users
* can only authenticate through a linked identity provider.
*/
public function createPasswordlessUser(
string $name,
string $email,
string $timezone,
Weekday $weekStart,
?string $currency,
bool $verifyEmail = false
): User {
$user = new User;
$user->name = $name;
$user->email = strtolower($email);
$user->password = null;
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$this->createDefaultOrganizationForUser($user, $currency);
return $user;
}
private function createDefaultOrganizationForUser(
User $user,
?string $currency,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): void {
$organizations = app(InvitationService::class)->processAcceptedInvitations($user); $organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) { if ($organizations->isEmpty()) {
@@ -112,8 +61,10 @@ class UserService
$intervalFormat, $intervalFormat,
$timeFormat, $timeFormat,
); );
$this->switchCurrentOrganization($user, $organization); $user->ownedTeams()->save($organization);
} }
return $user;
} }
/** /**
@@ -152,15 +103,11 @@ class UserService
true true
); );
$this->switchCurrentOrganization($user, $organization); // Set the organization as the user's current organization
AfterCreateOrganization::dispatch($organization);
}
public function switchCurrentOrganization(User $user, Organization $organization): void
{
$user->currentOrganization()->associate($organization); $user->currentOrganization()->associate($organization);
$user->save(); $user->save();
AfterCreateOrganization::dispatch($organization);
} }
public function getOrganizationNameForUserName(string $username): string public function getOrganizationNameForUserName(string $username): string
@@ -210,16 +157,4 @@ class UserService
$oldOwner->save(); $oldOwner->save();
} }
} }
public function deleteProfilePhoto(User $user): void
{
if ($user->profile_photo_path === null) {
return;
}
Storage::disk(config('filesystems.public'))->delete($user->profile_photo_path);
$user->profile_photo_path = null;
$user->save();
}
} }

View File

@@ -18,8 +18,8 @@
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0", "korridor/laravel-model-validation-rules": "^3.0",
"laravel/fortify": "^1.37",
"laravel/framework": "^12.19.3", "laravel/framework": "^12.19.3",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^13.0.5", "laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
@@ -27,7 +27,6 @@
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3", "league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"mobiledetect/mobiledetectlib": "^4.11",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4", "nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0", "owen-it/laravel-auditing": "^14.0.0",
@@ -132,8 +131,7 @@
"pestphp/pest-plugin": true, "pestphp/pest-plugin": true,
"php-http/discovery": true, "php-http/discovery": true,
"wikimedia/composer-merge-plugin": true "wikimedia/composer-merge-plugin": true
}, }
"process-timeout": 900
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true

92
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "897ca7bc13f827db641f7affa54a8523", "content-hash": "4c728f01d2beb426b2d157143618fdae",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -4413,6 +4413,72 @@
}, },
"time": "2026-05-20T11:48:19+00:00" "time": "2026-05-20T11:48:19+00:00"
}, },
{
"name": "laravel/jetstream",
"version": "v5.5.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/jetstream.git",
"reference": "61cac5cde455311890f6981fb2da47acd298e4e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/jetstream/zipball/61cac5cde455311890f6981fb2da47acd298e4e2",
"reference": "61cac5cde455311890f6981fb2da47acd298e4e2",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"laravel/fortify": "^1.20",
"mobiledetect/mobiledetectlib": "^4.8.08",
"php": "^8.2.0",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"inertiajs/inertia-laravel": "^2.0",
"laravel/sanctum": "^4.0",
"livewire/livewire": "^3.3",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Jetstream\\JetstreamServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Jetstream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Tailwind scaffolding for the Laravel framework.",
"keywords": [
"auth",
"laravel",
"tailwind"
],
"support": {
"issues": "https://github.com/laravel/jetstream/issues",
"source": "https://github.com/laravel/jetstream"
},
"time": "2026-05-19T01:30:03+00:00"
},
{ {
"name": "laravel/octane", "name": "laravel/octane",
"version": "v2.17.4", "version": "v2.17.4",
@@ -6379,16 +6445,16 @@
}, },
{ {
"name": "mobiledetect/mobiledetectlib", "name": "mobiledetect/mobiledetectlib",
"version": "4.11.0", "version": "4.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/serbanghita/Mobile-Detect.git", "url": "https://github.com/serbanghita/Mobile-Detect.git",
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4" "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/ab39168b7556f44c11c80be1222b44b239f5c2e4", "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4", "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6431,7 +6497,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/serbanghita/Mobile-Detect/issues", "issues": "https://github.com/serbanghita/Mobile-Detect/issues",
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.11.0" "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0"
}, },
"funding": [ "funding": [
{ {
@@ -6439,7 +6505,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-05-24T12:32:40+00:00" "time": "2026-04-23T13:05:57+00:00"
}, },
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
@@ -13737,16 +13803,16 @@
}, },
{ {
"name": "web-auth/webauthn-lib", "name": "web-auth/webauthn-lib",
"version": "5.3.5", "version": "5.3.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git", "url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -13807,7 +13873,7 @@
"webauthn" "webauthn"
], ],
"support": { "support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
}, },
"funding": [ "funding": [
{ {
@@ -13819,7 +13885,7 @@
"type": "patreon" "type": "patreon"
} }
], ],
"time": "2026-05-31T15:00:08+00:00" "time": "2026-05-17T19:04:30+00:00"
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",

View File

@@ -12,6 +12,7 @@ use App\Providers\AuthServiceProvider;
use App\Providers\EventServiceProvider; use App\Providers\EventServiceProvider;
use App\Providers\Filament\AdminPanelProvider; use App\Providers\Filament\AdminPanelProvider;
use App\Providers\FortifyServiceProvider; use App\Providers\FortifyServiceProvider;
use App\Providers\JetstreamServiceProvider;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -81,8 +82,6 @@ return [
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false), 'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Timezone | Application Timezone
@@ -204,6 +203,7 @@ return [
AdminPanelProvider::class, AdminPanelProvider::class,
RouteServiceProvider::class, RouteServiceProvider::class,
FortifyServiceProvider::class, FortifyServiceProvider::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(),

82
config/jetstream.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
return [
/*
|--------------------------------------------------------------------------
| Jetstream Stack
|--------------------------------------------------------------------------
|
| This configuration value informs Jetstream which "stack" you will be
| using for your application. In general, this value is set for you
| during installation and will not need to be changed after that.
|
*/
'stack' => 'inertia',
/*
|--------------------------------------------------------------------------
| Jetstream Route Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Jetstream will assign to the routes
| that it registers with the application. When necessary, you may modify
| these middleware; however, this default value is usually sufficient.
|
*/
'middleware' => ['web'],
'auth_session' => AuthenticateSession::class,
/*
|--------------------------------------------------------------------------
| Jetstream Guard
|--------------------------------------------------------------------------
|
| Here you may specify the authentication guard Jetstream will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of Jetstream's features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::termsAndPrivacyPolicy(),
Features::profilePhotos(),
Features::teams(['invitations' => true]),
Features::accountDeletion(),
],
/*
|--------------------------------------------------------------------------
| Profile Photo Disk
|--------------------------------------------------------------------------
|
| This configuration value determines the default disk that will be used
| when storing profile photos for your application's users. Typically
| this will be the "public" disk but you may adjust this if needed.
|
*/
'profile_photo_disk' => env('PROFILE_PHOTO_DISK', env('PUBLIC_FILESYSTEM_DISK', 'public')),
];

View File

@@ -94,7 +94,7 @@ class UserFactory extends Factory
$profilePhoto = $this->faker->image(null, 500, 500); $profilePhoto = $this->faker->image(null, 500, 500);
/** @see FileHelpers::hashName */ /** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png'; $path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto); Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
return $this->state(function (array $attributes) use ($path): array { return $this->state(function (array $attributes) use ($path): array {
return [ return [
@@ -120,7 +120,7 @@ class UserFactory extends Factory
$organization->owner()->associate($user); $organization->owner()->associate($user);
$organization->users()->attach($user, ['role' => Role::Owner->value]); $organization->users()->attach($user, ['role' => Role::Owner->value]);
$user->currentOrganization()->associate($organization); $user->currentTeam()->associate($organization);
$user->save(); $user->save();
}); });
} }

View File

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

View File

@@ -12,7 +12,7 @@ import {
createRunningTimeEntryWithStartViaApi, createRunningTimeEntryWithStartViaApi,
createTaskViaApi, createTaskViaApi,
createProjectWithClientViaApi, createProjectWithClientViaApi,
updateUserProfileViaApi, updateUserProfileViaWeb,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
@@ -1803,22 +1803,28 @@ test.describe('Click-Drag Selection to Create', () => {
// ============================================= // =============================================
test.describe('Timezone & Localization', () => { test.describe('Timezone & Localization', () => {
test('week start day: monday shows Mon as first column', async ({ page, ctx }) => { test('week start day: monday shows Mon as first column', async ({ page }) => {
await updateUserProfileViaApi(ctx, { week_start: 'monday' }); // Navigate to calendar first to load Inertia page props
await goToCalendar(page); await goToCalendar(page);
await updateUserProfileViaWeb(page, { week_start: 'monday' });
await page.reload();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
const firstHeader = page.locator('.fc-col-header-cell').first(); const firstHeader = page.locator('.fc-col-header-cell').first();
await expect(firstHeader).toContainText('Mon'); await expect(firstHeader).toContainText('Mon');
}); });
test('week start day: sunday shows Sun as first column', async ({ page, ctx }) => { test('week start day: sunday shows Sun as first column', async ({ page }) => {
await updateUserProfileViaApi(ctx, { week_start: 'sunday' });
await goToCalendar(page); await goToCalendar(page);
await updateUserProfileViaWeb(page, { week_start: 'sunday' });
await page.reload();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
const firstHeader = page.locator('.fc-col-header-cell').first(); const firstHeader = page.locator('.fc-col-header-cell').first();
await expect(firstHeader).toContainText('Sun'); await expect(firstHeader).toContainText('Sun');
// Reset to monday for other tests
await updateUserProfileViaWeb(page, { week_start: 'monday' });
}); });
test('12-hour time format shows AM/PM on slot labels', async ({ page, ctx }) => { test('12-hour time format shows AM/PM on slot labels', async ({ page, ctx }) => {

View File

@@ -348,7 +348,7 @@ test.describe('Command Palette', () => {
const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000); const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000);
// Create a new organization // Create a new organization
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create'); await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
await page.getByLabel('Organization Name').fill(newOrgName); await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
@@ -393,7 +393,7 @@ test.describe('Command Palette', () => {
const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000); const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000);
// Create a new organization to ensure we have multiple // Create a new organization to ensure we have multiple
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create'); await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
await page.getByLabel('Organization Name').fill(newOrgName); await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });

View File

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

View File

@@ -1,5 +1,5 @@
import { expect, test } from '../playwright/fixtures'; import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToOrganizationSettings(page) { async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -36,52 +36,13 @@ async function createTimeEntry(page, duration: string) {
test('test that organization name can be updated', async ({ page }) => { test('test that organization name can be updated', async ({ page }) => {
await goToOrganizationSettings(page); await goToOrganizationSettings(page);
await page.getByLabel('Organization Name').fill('NEW ORG NAME'); await page.getByLabel('Organization Name').fill('NEW ORG NAME');
await Promise.all([ await page.getByLabel('Organization Name').press('Enter');
page.waitForResponse( await page.getByLabel('Organization Name').press('Meta+r');
(response) =>
response.url().includes('/api/v1/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page
.locator('form')
.filter({ hasText: 'Organization Name' })
.getByRole('button', { name: 'Save' })
.click(),
]);
await page.reload();
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText( await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
'NEW ORG NAME' 'NEW ORG NAME'
); );
}); });
test('test that organization currency can be updated', async ({ page }) => {
await goToOrganizationSettings(page);
await page.getByLabel('Currency', { exact: true }).selectOption('USD');
await Promise.all([
page.waitForRequest(
(request) =>
request.url().includes('/api/v1/organizations/') &&
request.method() === 'PUT' &&
request.postDataJSON().currency === 'USD'
),
page.waitForResponse(
async (response) =>
response.url().includes('/api/v1/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.currency === 'USD'
),
page
.locator('form')
.filter({ hasText: 'Organization Name' })
.getByRole('button', { name: 'Save' })
.click(),
]);
await page.reload();
await expect(page.getByLabel('Currency', { exact: true })).toHaveValue('USD');
});
test('test that organization billable rate can be updated with all existing time entries', async ({ test('test that organization billable rate can be updated with all existing time entries', async ({
page, page,
}) => { }) => {
@@ -408,153 +369,13 @@ test('test that format settings persist after page reload', async ({ page }) =>
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
}); });
// =============================================
// Create, Delete & Switch
// =============================================
test.describe('Organization Create, Delete & Switch', () => {
async function createOrganization(page, name: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create');
await page.getByLabel('Organization Name').fill(name);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('button', { name: 'Create' }).click(),
]);
// The backend switches the current organization to the new one and the
// frontend reloads into its dashboard.
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
}
test('can create a new organization and switches to it automatically', async ({ page }) => {
const newOrgName = 'CreateOrg' + Math.floor(Math.random() * 100000);
await createOrganization(page, newOrgName);
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
newOrgName
);
});
test('does not create an organization when the name is empty', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create');
// The form posts to the API, which rejects the empty name with a 422.
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'POST' &&
response.status() === 422
),
page.getByRole('button', { name: 'Create' }).click(),
]);
// Validation failed, so we stay on the create form and never reach a
// dashboard. Assert on the form rather than the URL.
await expect(page.getByText('Organization Details')).toBeVisible();
await expect(page.getByRole('alert')).toContainText('The name field is required.');
await expect(page.getByLabel('Organization Name')).toHaveAttribute('aria-invalid', 'true');
await expect(page.getByTestId('dashboard_view')).toHaveCount(0);
});
test('can delete an organization', async ({ page }) => {
// Create a throwaway organization so the primary one is never deleted.
const orgName = 'DeleteOrg' + Math.floor(Math.random() * 100000);
await createOrganization(page, orgName);
// Open the (now current) throwaway organization's settings.
await goToOrganizationSettings(page);
// Open the confirmation modal, then confirm inside the dialog.
await page.getByRole('button', { name: 'Delete Organization' }).click();
await page.getByRole('dialog').getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('dialog').getByRole('button', { name: 'Delete Organization' }).click(),
]);
// We are redirected to the dashboard of a different organization.
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
await expect(
page.locator('[data-testid="organization_switcher"]:visible')
).not.toContainText(orgName);
});
test('delete organization shows an error when the password is wrong', async ({ page }) => {
const orgName = 'DeleteOrgWrongPassword' + Math.floor(Math.random() * 100000);
await createOrganization(page, orgName);
await goToOrganizationSettings(page);
await page.getByRole('button', { name: 'Delete Organization' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'DELETE' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Organization' }).click(),
]);
await expect(dialog.getByRole('alert')).toBeVisible();
await expect(dialog).toBeVisible();
});
test('can switch the current organization via the organization switcher', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
await expect(orgSwitcher).toBeVisible();
const previousOrgNameLines = (await orgSwitcher.innerText())
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const previousOrgName = previousOrgNameLines[previousOrgNameLines.length - 1];
// Ensure there are at least two organizations to switch between.
const orgName = 'SwitchOrg' + Math.floor(Math.random() * 100000);
await createOrganization(page, orgName);
await expect(orgSwitcher).toContainText(orgName);
// Open the switcher and pick a different organization.
await orgSwitcher.click();
await expect(page.getByText('Switch Organizations')).toBeVisible();
const otherOrgButton = page.getByRole('menuitem', { name: previousOrgName });
await expect(otherOrgButton).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/users/me/current-organization') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
otherOrgButton.click(),
]);
await expect(orgSwitcher).not.toContainText(orgName, { timeout: 10000 });
await expect(orgSwitcher).toContainText(previousOrgName, { timeout: 10000 });
});
});
// ============================================= // =============================================
// Admin Permission Tests // Admin Permission Tests
// ============================================= // =============================================
test.describe('Admin Organization Settings Access', () => { test.describe('Admin Organization Settings Access', () => {
test('admin can see and edit organization settings', async ({ ctx, admin }) => { test('admin can see and edit organization settings', async ({ ctx, admin }) => {
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/organizations/' + ctx.orgId); await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
// Organization Name section is visible // Organization Name section is visible
await expect( await expect(
@@ -575,9 +396,6 @@ test.describe('Admin Organization Settings Access', () => {
// Save buttons should be visible (admin can update) // Save buttons should be visible (admin can update)
await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible(); await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();
// The Organization Name input is editable (admin can update)
await expect(admin.page.getByLabel('Organization Name')).toBeEnabled();
// Delete organization should NOT be visible (owner only) // Delete organization should NOT be visible (owner only)
await expect( await expect(
admin.page.getByRole('heading', { name: 'Delete Organization' }) admin.page.getByRole('heading', { name: 'Delete Organization' })
@@ -591,17 +409,13 @@ test.describe('Admin Organization Settings Access', () => {
test.describe('Employee Organization Settings Restrictions', () => { test.describe('Employee Organization Settings Restrictions', () => {
test('employee can see org name but not editable settings', async ({ ctx, employee }) => { test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/organizations/' + ctx.orgId); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
// Organization Name section is visible (but inputs are disabled) // Organization Name section is visible (but inputs are disabled)
await expect( await expect(
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 }) employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
// The name and currency inputs are rendered but disabled (employee cannot update)
await expect(employee.page.getByLabel('Organization Name')).toBeDisabled();
await expect(employee.page.getByLabel('Currency')).toBeDisabled();
// Editable settings sections should NOT be visible // Editable settings sections should NOT be visible
await expect( await expect(
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 }) employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
@@ -615,10 +429,5 @@ test.describe('Employee Organization Settings Restrictions', () => {
// Save button should not be visible (employee cannot update) // Save button should not be visible (employee cannot update)
await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible(); await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();
// Delete organization should NOT be visible (owner only)
await expect(
employee.page.getByRole('heading', { name: 'Delete Organization' })
).not.toBeVisible();
}); });
}); });

View File

@@ -8,22 +8,20 @@ import {
import { getCurrentUserViaApi } from './utils/api'; import { getCurrentUserViaApi } from './utils/api';
import { registerUser } from './utils/members'; import { registerUser } from './utils/members';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import path from 'path';
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');
} }
function profileInformationForm(page: Page) {
return page
.getByRole('heading', { name: 'Profile Information', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> { async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page); await Promise.all([
await form.getByRole('button', { name: 'Save' }).click(); page.waitForResponse(
await expect(form.getByText('Saved.', { exact: true })).toBeVisible(); (resp) =>
resp.url().includes('/user/profile-information') &&
resp.request().method() === 'POST'
),
page.getByRole('button', { name: 'Save' }).first().click(),
]);
} }
test('user name can be updated', async ({ page }) => { test('user name can be updated', async ({ page }) => {
@@ -50,66 +48,6 @@ test('week-start change persists across reload', async ({ page }) => {
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday'); await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
}); });
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
page,
}) => {
await goToProfilePage(page);
const form = profileInformationForm(page);
const profilePhoto = form.getByRole('img', { name: 'John Doe' });
await expect(profilePhoto).toBeVisible();
await expect(profilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
await expect(form.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await form.locator('#photo').setInputFiles(path.resolve('resources/testfiles/test.png'));
await saveProfileForm(page);
await expect(profilePhoto).toHaveAttribute('src', /profile-photos/);
await expect(form.getByRole('button', { name: 'Remove Photo' })).toBeVisible();
await page.reload();
const reloadedForm = profileInformationForm(page);
const reloadedProfilePhoto = reloadedForm.getByRole('img', { name: 'John Doe' });
await expect(reloadedProfilePhoto).toHaveAttribute('src', /profile-photos/);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
reloadedForm.getByRole('button', { name: 'Remove Photo' }).click(),
]);
await expect(reloadedProfilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
await expect(reloadedForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await page.reload();
const finalForm = profileInformationForm(page);
await expect(finalForm.getByRole('img', { name: 'John Doe' })).toHaveAttribute(
'src',
/ui-avatars\.com/
);
await expect(finalForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
});
test('field-level validation errors render inline when the server returns 422', async ({
page,
}) => {
await goToProfilePage(page);
const form = profileInformationForm(page);
await form.getByLabel('Name').fill('a'.repeat(256));
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 422
),
form.getByRole('button', { name: 'Save' }).click(),
]);
await expect(form.getByRole('alert').filter({ hasText: /255 characters/i })).toBeVisible();
});
test('submitting a new email keeps the current email displayed after reload', async ({ test('submitting a new email keeps the current email displayed after reload', async ({
page, page,
ctx, ctx,
@@ -173,94 +111,6 @@ test('re-submitting the current email does not send a verification email', async
expect(afterCount).toBe(beforeCount); expect(afterCount).toBe(beforeCount);
}); });
test('after submitting a new email the pending-email banner is shown with a resend button', async ({
page,
}) => {
await goToProfilePage(page);
const newEmail = `pending+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await expect(page.getByText(`A verification link was sent to`)).toBeVisible();
await expect(page.getByText(newEmail)).toBeVisible();
await expect(page.getByRole('button', { name: 'Resend verification email' })).toBeVisible();
});
test('clicking resend sends a second verification email and shows confirmation', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `resend+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const beforeCount = await waitForEmailCount(request, newEmail, 'Verify Email Address', 1);
await page.getByRole('button', { name: 'Resend verification email' }).click();
await expect(page.getByText('Verification email sent.')).toBeVisible();
const afterCount = await waitForEmailCount(
request,
newEmail,
'Verify Email Address',
beforeCount + 1
);
expect(afterCount).toBeGreaterThan(beforeCount);
});
test('cancelling a pending email change clears it and hides the banner', async ({ page, ctx }) => {
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
const newEmail = `cancel+${Date.now()}@test.com`;
await goToProfilePage(page);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
// The pending-email banner is shown with the cancel control.
await expect(page.getByText('A verification link was sent to')).toBeVisible();
await expect(page.getByText(newEmail)).toBeVisible();
const cancelButton = page.getByRole('button', { name: 'Cancel email change' });
await expect(cancelButton).toBeVisible();
// Cancelling clears the pending email server-side (204).
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reset-pending-email') &&
response.request().method() === 'POST' &&
response.status() === 204
),
cancelButton.click(),
]);
// The banner disappears and the email field still shows the current address.
await expect(page.getByText('A verification link was sent to')).toBeHidden();
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
// The cancellation is persistent — still gone after a reload.
await page.reload();
await expect(page.getByText('A verification link was sent to')).toBeHidden();
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
});
test('re-submitting the same pending email does not send another verification email', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `dup+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const beforeCount = await waitForEmailCount(request, newEmail, 'Verify Email Address', 1);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await new Promise((r) => setTimeout(r, 1000));
const afterCount = await countEmailsWithSubject(request, newEmail, 'Verify Email Address');
expect(afterCount).toBe(beforeCount);
});
test('clicking the verification link swaps the email and shows a success banner', async ({ test('clicking the verification link swaps the email and shows a success banner', async ({
page, page,
}) => { }) => {
@@ -334,43 +184,6 @@ test('visiting the verification link while logged out redirects to login', async
} }
}); });
test('delete account shows an error when the password is wrong', async ({ page }) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await expect(dialog.getByRole('alert')).toBeVisible();
await expect(dialog).toBeVisible();
});
test('delete account succeeds with the correct password and logs the user out', async ({
page,
}) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await page.waitForURL(/\/login/);
});
async function createNewApiToken(page) { async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY'); await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([ await Promise.all([

View File

@@ -6,7 +6,6 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
import { import {
createProjectViaApi, createProjectViaApi,
createPublicProjectViaApi, createPublicProjectViaApi,
createProjectMemberViaApi,
createTaskViaApi, createTaskViaApi,
createClientViaApi, createClientViaApi,
createTimeEntryViaApi, createTimeEntryViaApi,
@@ -218,59 +217,6 @@ test('test that creating a non-billable project works', async ({ page }) => {
await expect(page.getByTestId('project_table')).toContainText(newProjectName); await expect(page.getByTestId('project_table')).toContainText(newProjectName);
}); });
test('test that creating a public project via the modal works', async ({ page }) => {
const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
// Visibility defaults to Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.is_public === true
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
await projectRow.getByRole('button').click();
await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();
// Loaded as Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.is_public === true
),
]);
});
test('test that switching from custom rate to default rate clears billable rate', async ({ test('test that switching from custom rate to default rate clears billable rate', async ({
page, page,
ctx, ctx,
@@ -694,7 +640,7 @@ test('test that creating a project with estimated time in human-readable format
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using human-readable format // Fill in estimated time using human-readable format
const estimatedTimeInput = page.getByLabel('Time Estimated'); const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('2h 30m'); await estimatedTimeInput.fill('2h 30m');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -722,7 +668,7 @@ test('test that creating a project with estimated time using decimal notation wo
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m) // Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
const estimatedTimeInput = page.getByLabel('Time Estimated'); const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('1.5'); await estimatedTimeInput.fill('1.5');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -750,7 +696,7 @@ test('test that creating a project with estimated time using comma decimal notat
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m) // Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
const estimatedTimeInput = page.getByLabel('Time Estimated'); const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('2,5'); await estimatedTimeInput.fill('2,5');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -781,7 +727,7 @@ test('test that updating estimated time on existing project works', async ({ pag
await page.getByRole('menuitem').getByText('Edit').first().click(); await page.getByRole('menuitem').getByText('Edit').first().click();
// Fill in estimated time // Fill in estimated time
const estimatedTimeInput = page.getByLabel('Time Estimated'); const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
await estimatedTimeInput.fill('4h 15m'); await estimatedTimeInput.fill('4h 15m');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -802,7 +748,7 @@ test('test that estimated time input displays formatted value after blur', async
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
const estimatedTimeInput = page.getByLabel('Time Estimated'); const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
// Enter time in various formats and check the displayed value // Enter time in various formats and check the displayed value
await estimatedTimeInput.fill('90'); await estimatedTimeInput.fill('90');
@@ -979,39 +925,6 @@ test.describe('Employee Projects Restrictions', () => {
employee.page.locator(`[aria-label='Delete Project ${projectName}']`) employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible(); ).not.toBeVisible();
}); });
test('employee does not see private projects they are not a member of', async ({
ctx,
employee,
}) => {
const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: publicName });
// createProjectViaApi defaults to is_public: false (private); the employee is not a member
await createProjectViaApi(ctx, { name: privateName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The public project is visible — confirms the list has loaded
await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });
// The private project the employee is not a member of must not appear
await expect(employee.page.getByText(privateName)).not.toBeVisible();
});
test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
// Add the employee as a project member so the private project becomes visible to them
await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The private project is visible because the employee is a member
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
});
}); });
test.describe('Employee Billable Rate Visibility', () => { test.describe('Employee Billable Rate Visibility', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,192 +0,0 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import { generateTotpCode, generateInvalidTotpCode } from './utils/totp';
import type { Page } from '@playwright/test';
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
/**
* ConfirmsPassword only opens the dialog when the password has not been
* confirmed recently, so fill it only when it actually shows up.
*/
async function confirmPasswordIfPrompted(page: Page) {
const dialog = page.getByRole('dialog');
const appeared = await dialog
.waitFor({ state: 'visible', timeout: 2500 })
.then(() => true)
.catch(() => false);
if (appeared) {
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await dialog.getByRole('button', { name: 'Confirm' }).click();
await expect(dialog).not.toBeVisible();
}
}
/**
* Enables 2FA from the profile page and returns the TOTP secret (setup key)
* and the recovery codes fetched right after enabling.
*/
async function enableTwoFactor(page: Page): Promise<{ secret: string; recoveryCodes: string[] }> {
await goToProfilePage(page);
await page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' })
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const recoveryCodesResponse = page.waitForResponse(
(response) =>
response.url().includes('/user/two-factor-recovery-codes') &&
response.request().method() === 'GET'
);
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await dialog.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
const recoveryCodes: string[] = await (await recoveryCodesResponse).json();
const setupKeyText = await page.getByText('Setup Key:').textContent();
const secret = setupKeyText!.replace('Setup Key:', '').trim();
expect(secret.length).toBeGreaterThan(0);
return { secret, recoveryCodes };
}
/**
* Confirms a freshly enabled 2FA setup with a valid TOTP code.
*/
async function confirmTwoFactor(page: Page, secret: string) {
await page.getByLabel('Code').fill(generateTotpCode(secret));
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await confirmPasswordIfPrompted(page);
await expect(page.getByText('You have enabled two factor authentication.')).toBeVisible();
}
async function logout(page: Page) {
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out', { exact: true }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
}
/**
* Reads the email of the current user from the profile form, waiting until
* the user query has populated it.
*/
async function getProfileEmail(page: Page): Promise<string> {
await goToProfilePage(page);
const emailInput = page.getByLabel('Email', { exact: true });
await expect(emailInput).toHaveValue(/@/);
return await emailInput.inputValue();
}
async function loginUntilTwoFactorChallenge(page: Page, email: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/two-factor-challenge');
}
test('test that 2FA can be confirmed with a TOTP code and shows recovery codes', async ({
page,
}) => {
const { secret, recoveryCodes } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await expect(page.getByText('Store these recovery codes')).toBeVisible();
expect(recoveryCodes.length).toBeGreaterThan(0);
for (const code of recoveryCodes) {
await expect(page.getByText(code)).toBeVisible();
}
// The confirmed state survives a reload
await page.reload();
await expect(page.getByText('You have enabled two factor authentication.')).toBeVisible();
});
test('test that 2FA confirmation fails with an invalid TOTP code', async ({ page }) => {
const { secret } = await enableTwoFactor(page);
await page.getByLabel('Code').fill(generateInvalidTotpCode(secret));
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await confirmPasswordIfPrompted(page);
await expect(page.getByRole('alert')).toContainText(
'The provided two factor authentication code was invalid.'
);
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
});
test('test that recovery codes can be regenerated', async ({ page }) => {
const { secret, recoveryCodes } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
const newCodesResponse = page.waitForResponse(
(response) =>
response.url().includes('/user/two-factor-recovery-codes') &&
response.request().method() === 'GET'
);
await page.getByRole('button', { name: 'Regenerate Recovery Codes' }).click();
await confirmPasswordIfPrompted(page);
const newCodes: string[] = await (await newCodesResponse).json();
expect(newCodes).not.toEqual(recoveryCodes);
await expect(page.getByText(newCodes[0])).toBeVisible();
await expect(page.getByText(recoveryCodes[0])).not.toBeVisible();
});
test('test that 2FA can be disabled', async ({ page }) => {
const { secret } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await page.getByRole('button', { name: 'Disable' }).click();
await confirmPasswordIfPrompted(page);
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
// The disabled state survives a reload
await page.reload();
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
});
test('test that login challenges for a TOTP code and rejects an invalid code', async ({ page }) => {
const email = await getProfileEmail(page);
const { secret } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await logout(page);
await loginUntilTwoFactorChallenge(page, email);
await page.getByLabel('Code').fill(generateInvalidTotpCode(secret));
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('alert')).toContainText(
'The provided two factor authentication code was invalid.'
);
// Fortify rejects replayed codes, and the current window's code was
// already consumed when confirming the setup — use the next window's
// code, which the +/- 1 step verification window also accepts.
await page.getByLabel('Code').fill(generateTotpCode(secret, Date.now() + 30_000));
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
});
test('test that login works with a recovery code', async ({ page }) => {
const email = await getProfileEmail(page);
const { secret, recoveryCodes } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await logout(page);
await loginUntilTwoFactorChallenge(page, email);
await page.getByRole('button', { name: 'Use a recovery code' }).click();
await page.getByLabel('Recovery Code').fill(recoveryCodes[0]);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
});

View File

@@ -170,24 +170,10 @@ function parseDurationToSeconds(duration: string): number {
return totalSeconds; return totalSeconds;
} }
/**
* Builds a start/end pair anchored to 09:00 UTC on today's UTC date.
*
* Intentionally pinned to UTC (rather than the runner's local time) so
* the produced timestamps are identical regardless of where the suite
* runs. Playwright test users default to UTC, so this matches what the
* app will see and keeps day-of-week / "this week" assertions stable
* for developers running the suite locally in non-UTC timezones.
*/
function createTimestamps(duration: string): { start: string; end: string } { function createTimestamps(duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration); const durationSeconds = parseDurationToSeconds(duration);
const now = new Date(); const now = new Date();
const start = createUtcTimestampFromDateParts( const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000); const end = new Date(start.getTime() + durationSeconds * 1000);
return { return {
@@ -200,32 +186,6 @@ function formatTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
} }
function createUtcTimestampFromDateParts(
year: number,
month: number,
date: number,
hours: number,
minutes: number = 0,
seconds: number = 0
): Date {
return new Date(Date.UTC(year, month, date, hours, minutes, seconds));
}
function createTimestampsOnDate(date: Date, duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const start = createUtcTimestampFromDateParts(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
start: formatTimestamp(start),
end: formatTimestamp(end),
};
}
function randomColor(): string { function randomColor(): string {
const colors = [ const colors = [
'#ef5350', '#ef5350',
@@ -415,39 +375,6 @@ export async function createTimeEntryViaApi(
return body.data as { id: string; start: string; end: string; description: string }; return body.data as { id: string; start: string; end: string; description: string };
} }
export async function createTimeEntryOnDateViaApi(
ctx: TestContext,
data: {
date: Date;
duration: string;
description?: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const { start, end } = createTimestampsOnDate(data.date, data.duration);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createProjectMemberViaApi( export async function createProjectMemberViaApi(
ctx: TestContext, ctx: TestContext,
projectId: string, projectId: string,
@@ -641,13 +568,10 @@ export async function updateOrganizationCurrencyViaWeb(
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN'); const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : ''; const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put( const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}`,
{
headers: { 'X-XSRF-TOKEN': xsrfToken }, headers: { 'X-XSRF-TOKEN': xsrfToken },
data: { name, currency }, data: { name, currency },
} });
);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
} }
@@ -689,72 +613,6 @@ export async function getInvitationsViaApi(ctx: TestContext) {
// Timestamp-based time entry helpers // Timestamp-based time entry helpers
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
/**
* Creates a time entry on `date` at a specific UTC hour with a duration
* in seconds. Playwright test users default to the UTC timezone, so this
* keeps time-placement scenarios stable across runner locales.
*/
export async function createTimeEntryAtHourViaApi(
ctx: TestContext,
data: {
date: Date;
startHour: number;
startMinute?: number;
durationSeconds: number;
projectId?: string | null;
taskId?: string | null;
description?: string;
}
) {
const start = createUtcTimestampFromDateParts(
data.date.getUTCFullYear(),
data.date.getUTCMonth(),
data.date.getUTCDate(),
data.startHour,
data.startMinute ?? 0
);
const end = new Date(start.getTime() + data.durationSeconds * 1000);
return createTimeEntryWithTimestampsViaApi(ctx, {
start: formatTimestamp(start),
end: formatTimestamp(end),
projectId: data.projectId ?? null,
taskId: data.taskId ?? null,
description: data.description ?? '',
});
}
/**
* Reads time entries for the current member, optionally filtered to a
* date range. Returns the raw API objects (id, start, end, project_id,
* etc.) so tests can assert on the database state after a UI action.
*/
export async function getTimeEntriesViaApi(
ctx: TestContext,
filters: { start?: string; end?: string } = {}
): Promise<
Array<{
id: string;
start: string;
end: string | null;
duration: number | null;
project_id: string | null;
task_id: string | null;
description: string;
}>
> {
const params = new URLSearchParams();
params.set('member_id', ctx.memberId);
if (filters.start) params.set('start', filters.start);
if (filters.end) params.set('end', filters.end);
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries?${params.toString()}`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function createTimeEntryWithTimestampsViaApi( export async function createTimeEntryWithTimestampsViaApi(
ctx: TestContext, ctx: TestContext,
data: { data: {
@@ -804,23 +662,53 @@ export async function getCurrentUserViaApi(ctx: TestContext) {
}; };
} }
export async function updateUserProfileViaApi( export async function updateUserProfileViaWeb(
ctx: TestContext, page: Page,
settings: { timezone?: string; week_start?: string } settings: { timezone?: string; week_start?: string }
) { ) {
const user = await getCurrentUserViaApi(ctx); // Read user info from Inertia's data-page attribute on the root element
const userInfo = await page.evaluate(() => {
// Only send the fields under test; the endpoint leaves omitted fields untouched. // Try Inertia's data-page attribute (stores initial page props as JSON)
const data: Record<string, string> = {}; const appEl = document.getElementById('app');
if (settings.timezone !== undefined) { if (appEl) {
data.timezone = settings.timezone; 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,
};
} }
if (settings.week_start !== undefined) { } catch {
data.week_start = settings.week_start; // JSON parse failed
} }
}
}
return null;
});
if (!userInfo) throw new Error('Could not read user info from Inertia data-page attribute');
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/api/v1/users/${user.id}`, { const cookies = await page.context().cookies();
data, 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); expect(response.status()).toBe(200);
} }

View File

@@ -1,58 +0,0 @@
import { createHmac } from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Decode(input: string): Buffer {
const normalized = input
.toUpperCase()
.replace(/=+$/, '')
.replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of normalized) {
value = (value << 5) | BASE32_ALPHABET.indexOf(char);
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/**
* Generates a 6-digit TOTP code (RFC 6238, SHA-1, 30 second period) for the
* given base32 secret — the "Setup Key" shown while enabling 2FA.
*/
export function generateTotpCode(base32Secret: string, atMs: number = Date.now()): string {
const counter = Math.floor(atMs / 1000 / 30);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac('sha1', base32Decode(base32Secret)).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, '0');
}
/**
* Generates a syntactically valid TOTP code that is guaranteed to be rejected,
* by using a timestamp far outside the accepted verification window.
*/
export function generateInvalidTotpCode(base32Secret: string): string {
const validNow = [
generateTotpCode(base32Secret, Date.now() - 30_000),
generateTotpCode(base32Secret),
generateTotpCode(base32Secret, Date.now() + 30_000),
];
for (let minutes = 10; ; minutes++) {
const candidate = generateTotpCode(base32Secret, Date.now() + minutes * 60_000);
if (!validNow.includes(candidate)) {
return candidate;
}
}
}

1041
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,17 +12,10 @@
"lint": "eslint resources/js", "lint": "eslint resources/js",
"lint:fix": "eslint --fix resources/js", "lint:fix": "eslint --fix resources/js",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "rm -rf test-results/.auth && npx playwright test", "test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api", "zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api",
"format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'", "format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'", "format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
"build:ui": "npm run build --workspace=@solidtime/ui",
"build:api": "npm run build --workspace=@solidtime/api",
"build:packages": "npm run build:api && npm run build:ui",
"watch:ui": "npm run watch --workspace=@solidtime/ui",
"watch:api": "npm run watch --workspace=@solidtime/api"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
@@ -34,12 +27,10 @@
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"axios": "^1.16.0", "axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0", "laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.18.3", "openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14", "postcss": "^8.5.14",
@@ -49,7 +40,6 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-plugin-checker": "^0.12.0", "vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-tsc": "^3.2.8" "vue-tsc": "^3.2.8"
}, },

View File

@@ -5,15 +5,6 @@ import { usePage } from '@inertiajs/vue3';
const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const; const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const;
type BannerStyle = (typeof ALLOWED_STYLES)[number]; type BannerStyle = (typeof ALLOWED_STYLES)[number];
withDefaults(
defineProps<{
// Render as a self-contained rounded alert that sits inside a card
// (e.g. the auth card on login/register) instead of a full-width page banner.
card?: boolean;
}>(),
{ card: false }
);
const page = usePage<{ const page = usePage<{
flash: { flash: {
bannerText?: string; bannerText?: string;
@@ -35,16 +26,10 @@ const show = ref(true);
<div <div
v-if="show && message" v-if="show && message"
data-testid="banner" data-testid="banner"
:class=" class="bg-secondary border-b border-border-secondary">
card <div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
? 'bg-secondary border border-border-secondary rounded-lg mb-4'
: 'bg-secondary border-b border-border-secondary'
">
<div :class="card ? 'py-2 px-3' : 'mx-auto py-1 px-3 sm:px-6 lg:px-8'">
<div class="flex items-center justify-between flex-wrap"> <div class="flex items-center justify-between flex-wrap">
<div <div class="w-0 flex-1 flex items-center min-w-0">
class="w-0 flex-1 flex min-w-0"
:class="card ? 'items-start' : 'items-center'">
<span class="flex"> <span class="flex">
<svg <svg
v-if="style === 'success'" v-if="style === 'success'"
@@ -89,9 +74,7 @@ const show = ref(true);
</svg> </svg>
</span> </span>
<p <p class="ms-3 font-medium text-sm text-text-primary truncate">
class="ms-3 font-medium text-sm text-text-primary"
:class="{ truncate: !card }">
{{ message }} {{ message }}
</p> </p>
</div> </div>

View File

@@ -19,7 +19,6 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue'; import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -45,7 +44,6 @@ const project = ref<CreateProjectBody>({
billable_rate: props.originalProject.billable_rate, billable_rate: props.originalProject.billable_rate,
is_billable: props.originalProject.is_billable, is_billable: props.originalProject.is_billable,
estimated_time: props.originalProject.estimated_time, estimated_time: props.originalProject.estimated_time,
is_public: props.originalProject.is_public,
}); });
async function submit() { async function submit() {
@@ -128,7 +126,6 @@ async function submitBillableRate() {
v-if="isAllowedToPerformPremiumAction()" v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time" v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection> @submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup> </FieldGroup>
</template> </template>
<template #footer> <template #footer>

View File

@@ -13,8 +13,7 @@ export type SortColumn =
| 'spent_time' | 'spent_time'
| 'progress' | 'progress'
| 'billable_rate' | 'billable_rate'
| 'status' | 'status';
| 'visibility';
export type SortDirection = 'asc' | 'desc'; export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -103,10 +102,6 @@ const columns = computed(() => [
id: 'status', id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0), accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
}, },
{
id: 'visibility',
accessorFn: (row: Project) => (row.is_public ? 1 : 0),
},
]); ]);
// Columns with sortDescFirst get desc as default direction on first click. // Columns with sortDescFirst get desc as default direction on first click.
@@ -154,7 +149,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
} }
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
}); });
</script> </script>
@@ -176,7 +171,7 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection" :sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns" :desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading> @sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center"> <div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold"> <h3 class="text-text-primary font-semibold">
{{ {{

View File

@@ -86,14 +86,6 @@ function isChevronUp(column: SortColumn): boolean {
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" /> <ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('visibility')">
Visibility
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -7,8 +7,6 @@ import {
PencilSquareIcon, PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid, ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon, TrashIcon,
GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery'; import { useTasksQuery } from '@/utils/useTasksQuery';
@@ -143,17 +141,6 @@ const showEditProjectModal = ref(false);
<span>Active</span> <span>Active</span>
</template> </template>
</div> </div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_public">
<GlobeAltIcon class="w-4 text-icon-default"></GlobeAltIcon>
<span>Public</span>
</template>
<template v-else>
<LockClosedIcon class="w-4 text-icon-default"></LockClosedIcon>
<span>Private</span>
</template>
</div>
<div <div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown <ProjectMoreOptionsDropdown

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/packages/ui/src';
import BaseFilterBadge from './BaseFilterBadge.vue';
type VisibilityValue = 'public' | 'private' | 'all';
const props = defineProps<{
value: VisibilityValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: VisibilityValue];
}>();
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const label = computed(() => {
return visibilityOptions.find((opt) => opt.id === props.value)?.name ?? 'Visibility';
});
function updateVisibility(visibility: VisibilityValue) {
emit('update:value', visibility);
}
</script>
<template>
<BaseFilterBadge
:icon="GlobeAltIcon"
:label="label"
filter-name="Visibility"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid'; import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue'; import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import { import {
DropdownMenu, DropdownMenu,
@@ -19,7 +19,6 @@ import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters { export interface ProjectFilters {
status: 'active' | 'archived' | 'all'; status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
clientIds: string[]; clientIds: string[];
} }
@@ -37,11 +36,6 @@ const statusOptions = [
{ id: 'archived' as const, name: 'Archived' }, { id: 'archived' as const, name: 'Archived' },
]; ];
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const open = ref(false); const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') { function updateStatus(status: 'active' | 'archived' | 'all') {
@@ -52,14 +46,6 @@ function updateStatus(status: 'active' | 'archived' | 'all') {
open.value = false; open.value = false;
} }
function updateVisibility(visibility: 'public' | 'private' | 'all') {
emit('update:filters', {
...props.filters,
visibility,
});
open.value = false;
}
function toggleClient(clientId: string) { function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId) const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId) ? props.filters.clientIds.filter((id) => id !== clientId)
@@ -83,11 +69,7 @@ function toggleNoClient() {
} }
const hasActiveFilters = computed(() => { const hasActiveFilters = computed(() => {
return ( return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
}); });
</script> </script>
@@ -120,25 +102,6 @@ const hasActiveFilters = computed(() => {
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<!-- Visibility Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<GlobeAltIcon class="h-4 w-4 text-icon-default" />
<span>Visibility</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[
filters.visibility === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter --> <!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0"> <DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2"> <DropdownMenuSubTrigger class="gap-2">

View File

@@ -20,6 +20,12 @@ import {
} from '@/packages/ui/src'; } from '@/packages/ui/src';
const page = usePage<{ const page = usePage<{
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
managesProfilePhotos: boolean;
hasApiFeatures: boolean;
};
auth: { auth: {
user: User & { user: User & {
all_teams: Organization[]; all_teams: Organization[];
@@ -33,7 +39,7 @@ const switchToTeam = (organization: Organization) => {
</script> </script>
<template> <template>
<DropdownMenu> <DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
<DropdownMenuTrigger <DropdownMenuTrigger
class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between" class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
as-child> as-child>
@@ -61,7 +67,7 @@ const switchToTeam = (organization: Organization) => {
<DropdownMenuItem as-child> <DropdownMenuItem as-child>
<Link <Link
:href="route('organizations.show', page.props.auth.user.current_team.id)" :href="route('teams.show', page.props.auth.user.current_team.id)"
class="inline-flex items-center gap-2.5 w-full"> class="inline-flex items-center gap-2.5 w-full">
<Cog6ToothIcon class="w-5 h-5 text-icon-default" /> <Cog6ToothIcon class="w-5 h-5 text-icon-default" />
<span>Organization Settings</span> <span>Organization Settings</span>
@@ -72,9 +78,9 @@ const switchToTeam = (organization: Organization) => {
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link> <Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem as-child> <DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
<Link <Link
:href="route('organizations.create')" :href="route('teams.create')"
class="inline-flex items-center gap-2.5 w-full"> class="inline-flex items-center gap-2.5 w-full">
<PlusCircleIcon class="w-5 h-5 text-icon-default" /> <PlusCircleIcon class="w-5 h-5 text-icon-default" />
<span>Create new organization</span> <span>Create new organization</span>

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/Components/ui/alert-dialog';
defineProps<{
open: boolean;
entryCount: number;
projectName: string;
}>();
defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'confirm'): void;
}>();
</script>
<template>
<AlertDialog :open="open" @update:open="$emit('update:open', $event)">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove timesheet row?</AlertDialogTitle>
<AlertDialogDescription>
This will delete {{ entryCount }} time
{{ entryCount === 1 ? 'entry' : 'entries' }}
for "{{ projectName }}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="$emit('confirm')">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

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