Compare commits

..

51 Commits

Author SHA1 Message Date
Constantin Graf
be8906b822 Fixed organiztion page for employees 2026-06-18 17:13:26 +02:00
dependabot[bot]
dedb50ea73 Bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:32:01 +02:00
dependabot[bot]
6fe856fd31 Bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:31:39 +02:00
dependabot[bot]
20735c2fed Bump codecov/codecov-action from 5.5.1 to 7.0.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.1 to 7.0.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.1...v7.0.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:31:20 +02:00
dependabot[bot]
b14e40c64b Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:30:41 +02:00
dependabot[bot]
f2277ff708 Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:30:11 +02:00
Constantin Graf
6a197f7f34 Add password check to users.destroy and organizations.destroy 2026-06-18 13:27:43 +02:00
Constantin Graf
24c94af952 Add option to enable email verification locally for testing 2026-06-18 13:27:43 +02:00
Constantin Graf
111ce94150 Add comments to events about legacy events 2026-06-18 13:27:43 +02:00
Constantin Graf
49e69b85a6 Fixed middlwares after jetstream removal 2026-06-18 13:27:43 +02:00
Gregor Vostrak
90a5c2b87c add comprehensive 2 factor authentication e2e tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
6b01034bdd add back destroy other browser sessions endpoint (jetstream migration) 2026-06-18 13:27:43 +02:00
Gregor Vostrak
a4c400ef4e update e2e test setup to use user settings api endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
5d01b20dbf change redirects and references to new organization routes 2026-06-18 13:27:43 +02:00
Gregor Vostrak
24875f9424 add currency to organization update endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
4975cf1dab replace hardcoded inertia props with organization delete/update perms 2026-06-18 13:27:43 +02:00
Gregor Vostrak
b3fb04c4be remove unused inertia organization page props 2026-06-18 13:27:43 +02:00
Gregor Vostrak
0cc8d762a5 fix inertia backend role data structure after jetstream migration 2026-06-18 13:27:43 +02:00
Gregor Vostrak
e3e45161a1 call api for organization create/update/delete and switch 2026-06-18 13:27:43 +02:00
Constantin Graf
0c825a691e Fixed current organization after normal registration 2026-06-18 13:27:43 +02:00
Constantin Graf
24775d2c47 Added tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
c5c129b239 add switch current organization endpoint 2026-06-18 13:27:43 +02:00
Constantin Graf
89a9341d91 Removed Laravel Jetstream 2026-06-18 13:27:43 +02:00
Gregor Vostrak
bffd0773be remove jetstream inertia properties; remove unused ApiTokenManager; 2026-06-18 13:27:43 +02:00
Gregor Vostrak
56ea0ed90e move banners on login and register cards into the cards 2026-06-18 13:27:43 +02:00
Gregor Vostrak
24c19e7e7c add pending email cancel button 2026-06-18 13:27:43 +02:00
Constantin Graf
734aab6353 Replaces all Jetstream model trait functions and relations 2026-06-18 13:27:43 +02:00
Constantin Graf
5eb1a6a006 Add reset pending email endpoint to user controller 2026-06-18 13:27:43 +02:00
Gregor Vostrak
d0334bd730 move user delete to api endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
fe4e903203 use api routes for profile information updates 2026-06-18 13:27:43 +02:00
Gregor Vostrak
43e42ace54 show null billable rate as empty not as 0 to avoid confusion 2026-06-18 13:27:43 +02:00
Gregor Vostrak
84728f0914 fix e2e selectors to adapt to reka-ui change; 2026-06-18 13:27:43 +02:00
Gregor Vostrak
8f02f98afc add pending email to UserResource and update openapi client 2026-06-18 13:27:43 +02:00
Gregor Vostrak
c1ecd0eff5 update ui package dependencies; update lucide imports 2026-06-18 13:27:43 +02:00
Gregor Vostrak
486b5d2152 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-06-18 13:27:43 +02:00
Gregor Vostrak
5476b4e5a9 update npm dependencies 2026-06-18 13:27:43 +02:00
Gregor Vostrak
ceec4cbf62 add profile page e2e tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
372c7b02a0 update email address change info to use session based banners 2026-06-18 13:27:43 +02:00
Gregor Vostrak
cb8047028c add 1MB photo upload limit 2026-06-18 13:27:43 +02:00
Gregor Vostrak
e29be581fa add photo delete logic to user update endpoint 2026-06-18 13:27:43 +02:00
Constantin Graf
02da133fb3 Updated composer dependencies 2026-06-18 13:27:43 +02:00
Constantin Graf
d00fe2baff Add more tests 2026-06-18 13:27:43 +02:00
Constantin Graf
a73fe59892 Add migration to lower case the user emails 2026-06-18 13:27:43 +02:00
Constantin Graf
5d744d70c4 Migrate permission away from Jetstream; Moved update user to REST API 2026-06-18 13:27:43 +02:00
Gregor Vostrak
12d58ee42c rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-06-18 13:27:43 +02:00
Gregor Vostrak
f9be92894e add banners for invitation accept 2026-06-18 13:27:43 +02:00
Constantin Graf
a30d192ea2 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-06-18 13:27:43 +02:00
dependabot[bot]
d732064f31 Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 22:58:27 +02:00
Gregor Vostrak
cb5c2547f4 fix profile setting sidebar alignment 2026-06-03 12:24:53 +02:00
Gregor Vostrak
13a25524f3 add saved/saving/error indicators to timesheets 2026-06-02 17:14:32 +02:00
Gregor Vostrak
112f6aa6a6 add invoice clone to openapi client, expose DetailedInvoice type 2026-05-29 19:07:55 +02:00
121 changed files with 3338 additions and 1699 deletions

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -96,7 +96,7 @@ jobs:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
@@ -124,20 +124,20 @@ jobs:
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
@@ -177,14 +177,14 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v6
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_REPO }}

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -73,7 +73,7 @@ jobs:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
@@ -160,7 +160,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: rg.fr-par.scw.cloud/solidtime
username: nologin
@@ -168,7 +168,7 @@ jobs:
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: rg.fr-par.scw.cloud/solidtime/solidtime
tags: |
@@ -179,7 +179,7 @@ jobs:
type=sha,format=long
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -109,27 +109,27 @@ jobs:
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
@@ -169,20 +169,20 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v6
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -193,7 +193,7 @@ jobs:
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_REPO }}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.5.1
uses: codecov/codecov-action@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

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

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v6
@@ -99,7 +99,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v4
@@ -110,7 +110,7 @@ jobs:
run: npm ci
- name: "Download blob reports"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: all-blob-reports
pattern: blob-report-*

View File

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

View File

@@ -4,16 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Exceptions\MovedToApiException;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -27,61 +20,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
public function update(User $user, array $input): void
{
if (isset($input['email']) && is_string($input['email'])) {
$input['email'] = Str::lower($input['email']);
}
Validator::make($input, [
'name' => [
'required',
'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();
}
throw new MovedToApiException;
}
}

View File

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

@@ -1,60 +0,0 @@
<?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 App\Service\UserService;
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
);
app(UserService::class)->switchCurrentOrganization($user, $organization);
AfterCreateOrganization::dispatch($organization);
return $organization;
}
}

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

@@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
}),
])
->actions([

View File

@@ -63,7 +63,7 @@ class InvitationController extends Controller
$email = $request->getEmail();
$role = $request->getRole();
$invitationService->inviteUser($organization, $email, $role);
$invitationService->inviteUser($organization, $email, $role, $this->user());
return response()->json(null, 204);
}

View File

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

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
@@ -50,6 +51,9 @@ class OrganizationController extends Controller
if ($request->getName() !== null) {
$organization->name = $request->getName();
}
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
@@ -121,7 +125,7 @@ class OrganizationController extends Controller
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
{
$this->checkPermission($organization, 'organizations:delete');

View File

@@ -0,0 +1,33 @@
<?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,11 +6,15 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
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\Resources\V1\User\UserResource;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\Organization;
use App\Models\User;
use App\Service\DeletionService;
use App\Service\UserService;
use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -36,6 +40,35 @@ class UserController extends Controller
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
*
@@ -50,7 +83,7 @@ class UserController extends Controller
}
if ($request->hasPhotoKey()) {
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$photoDisk = (string) config('filesystems.public');
$previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto();
@@ -161,7 +194,7 @@ class UserController extends Controller
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/
public function destroy(User $user, DeletionService $deletionService): JsonResponse
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;

View File

@@ -4,4 +4,21 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
abstract class Controller extends \App\Http\Controllers\Controller {}
use App\Models\Organization;
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

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

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

@@ -0,0 +1,142 @@
<?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
{
if (! app()->isLocal()) {
if (! app()->isLocal() || config('app.local_email_verification')) {
if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()

View File

@@ -26,7 +26,7 @@ class ShareInertiaData
$permissions = app(PermissionStore::class);
Inertia::share([
'auth' => [
'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [],
'user' => function () use ($request): array {
/** @var User|null $user */
$user = $request->user();
@@ -35,6 +35,8 @@ class ShareInertiaData
return [];
}
$currentOrganization = $user->currentOrganization;
return array_merge([
'id' => $user->id,
'name' => $user->name,
@@ -47,12 +49,12 @@ class ShareInertiaData
'profile_photo_url' => $user->profile_photo_url,
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
&& ! is_null($user->two_factor_secret),
'current_team' => $user->currentTeam !== null ? [
'id' => $user->currentTeam->id,
'user_id' => $user->currentTeam->user_id,
'name' => $user->currentTeam->name,
'personal_team' => $user->currentTeam->personal_team,
'currency' => $user->currentTeam->currency,
'current_team' => $currentOrganization !== null ? [
'id' => $currentOrganization->id,
'user_id' => $currentOrganization->user_id,
'name' => $currentOrganization->name,
'personal_team' => $currentOrganization->personal_team,
'currency' => $currentOrganization->currency,
] : null,
], array_filter([
'all_teams' => $user->organizations->map(function (Organization $organization): array {

View File

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

View File

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

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

View File

@@ -14,6 +14,7 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -21,11 +22,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
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;
/**
@@ -53,7 +49,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
class Organization extends Model implements AuditableContract
{
use CustomAuditable;
@@ -91,17 +87,6 @@ class Organization extends JetstreamTeam implements AuditableContract
'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.
*
@@ -163,12 +148,13 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* 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.
* Find a model by its primary key or throw an exception.
*
* @param array<string> $columns
* @param array<int, string> $columns
*
* @throws ModelNotFoundException<Model>
*/
public function findOrFail(string $id, array $columns = ['*']): Team
public static function findOrFail(string $id, array $columns = ['*']): Model
{
if (! Str::isUuid($id)) {
throw (new ModelNotFoundException)->setModel(

View File

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

View File

@@ -26,8 +26,6 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
@@ -46,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Weekday $week_start
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property-read Collection<int, Token> $tokens
* @property Carbon|null $created_at
@@ -71,8 +68,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasProfilePhoto;
use HasTeams;
use HasUuids;
use Notifiable;
use TwoFactorAuthenticatable;

View File

@@ -1,102 +0,0 @@
<?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->isMemberOfOrganization($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 app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete');
}
}

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
@@ -22,7 +19,6 @@ class AuthServiceProvider extends ServiceProvider
* @var array<class-string, class-string>
*/
protected $policies = [
Organization::class => OrganizationPolicy::class,
];
/**
@@ -56,11 +52,5 @@ class AuthServiceProvider extends ServiceProvider
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));
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

@@ -15,12 +15,13 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Responses\LoginResponse;
class FortifyServiceProvider extends ServiceProvider
{
@@ -50,6 +51,40 @@ 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 {
/** @var User|null $user */
$user = User::query()
@@ -74,7 +109,7 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
$this->app->instance(LoginResponse::class, new CustomLoginResponse);
$this->app->instance(LoginResponseContract::class, new CustomLoginResponse);
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
}
}

View File

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

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

View File

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

View File

@@ -62,6 +62,7 @@ class UserService
$intervalFormat,
$timeFormat,
);
$this->switchCurrentOrganization($user, $organization);
}
return $user;

View File

@@ -18,8 +18,8 @@
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/fortify": "^1.37",
"laravel/framework": "^12.19.3",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3",
"laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8",
@@ -27,6 +27,7 @@
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1",
"mobiledetect/mobiledetectlib": "^4.11",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0",
@@ -131,7 +132,8 @@
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"wikimedia/composer-merge-plugin": true
}
},
"process-timeout": 900
},
"minimum-stability": "stable",
"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",
"This file is @generated automatically"
],
"content-hash": "4c728f01d2beb426b2d157143618fdae",
"content-hash": "897ca7bc13f827db641f7affa54a8523",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -4413,72 +4413,6 @@
},
"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",
"version": "v2.17.4",
@@ -6445,16 +6379,16 @@
},
{
"name": "mobiledetect/mobiledetectlib",
"version": "4.10.0",
"version": "4.11.0",
"source": {
"type": "git",
"url": "https://github.com/serbanghita/Mobile-Detect.git",
"reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c"
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/ab39168b7556f44c11c80be1222b44b239f5c2e4",
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4",
"shasum": ""
},
"require": {
@@ -6497,7 +6431,7 @@
],
"support": {
"issues": "https://github.com/serbanghita/Mobile-Detect/issues",
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0"
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.11.0"
},
"funding": [
{
@@ -6505,7 +6439,7 @@
"type": "github"
}
],
"time": "2026-04-23T13:05:57+00:00"
"time": "2026-05-24T12:32:40+00:00"
},
{
"name": "monolog/monolog",
@@ -13803,16 +13737,16 @@
},
{
"name": "web-auth/webauthn-lib",
"version": "5.3.3",
"version": "5.3.5",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f",
"shasum": ""
},
"require": {
@@ -13873,7 +13807,7 @@
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5"
},
"funding": [
{
@@ -13885,7 +13819,7 @@
"type": "patreon"
}
],
"time": "2026-05-17T19:04:30+00:00"
"time": "2026-05-31T15:00:08+00:00"
},
{
"name": "webmozart/assert",

View File

@@ -12,7 +12,6 @@ use App\Providers\AuthServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\Filament\AdminPanelProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\JetstreamServiceProvider;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
@@ -82,6 +81,8 @@ return [
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
/*
|--------------------------------------------------------------------------
| Application Timezone
@@ -203,7 +204,6 @@ return [
AdminPanelProvider::class,
RouteServiceProvider::class,
FortifyServiceProvider::class,
JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),

View File

@@ -1,82 +0,0 @@
<?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);
/** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
return $this->state(function (array $attributes) use ($path): array {
return [

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -36,13 +36,52 @@ async function createTimeEntry(page, duration: string) {
test('test that organization name can be updated', async ({ page }) => {
await goToOrganizationSettings(page);
await page.getByLabel('Organization Name').fill('NEW ORG NAME');
await page.getByLabel('Organization Name').press('Enter');
await page.getByLabel('Organization Name').press('Meta+r');
await Promise.all([
page.waitForResponse(
(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(
'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 ({
page,
}) => {
@@ -369,13 +408,153 @@ test('test that format settings persist after page reload', async ({ page }) =>
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
// =============================================
test.describe('Admin Organization Settings Access', () => {
test('admin can see and edit organization settings', async ({ ctx, admin }) => {
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/organizations/' + ctx.orgId);
// Organization Name section is visible
await expect(
@@ -396,6 +575,9 @@ test.describe('Admin Organization Settings Access', () => {
// Save buttons should be visible (admin can update)
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)
await expect(
admin.page.getByRole('heading', { name: 'Delete Organization' })
@@ -409,13 +591,17 @@ test.describe('Admin Organization Settings Access', () => {
test.describe('Employee Organization Settings Restrictions', () => {
test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/organizations/' + ctx.orgId);
// Organization Name section is visible (but inputs are disabled)
await expect(
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).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
await expect(
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
@@ -429,5 +615,10 @@ test.describe('Employee Organization Settings Restrictions', () => {
// Save button should not be visible (employee cannot update)
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

@@ -342,8 +342,8 @@ test('delete account shows an error when the password is wrong', async ({ page }
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/confirm-password') &&
response.request().method() === 'POST' &&
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),

192
e2e/two-factor.spec.ts Normal file
View File

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

@@ -641,10 +641,13 @@ export async function updateOrganizationCurrencyViaWeb(
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
headers: { 'X-XSRF-TOKEN': xsrfToken },
data: { name, currency },
});
const response = await page.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}`,
{
headers: { 'X-XSRF-TOKEN': xsrfToken },
data: { name, currency },
}
);
expect(response.status()).toBe(200);
}
@@ -801,53 +804,23 @@ export async function getCurrentUserViaApi(ctx: TestContext) {
};
}
export async function updateUserProfileViaWeb(
page: Page,
export async function updateUserProfileViaApi(
ctx: TestContext,
settings: { timezone?: string; week_start?: string }
) {
// Read user info from Inertia's data-page attribute on the root element
const userInfo = await page.evaluate(() => {
// Try Inertia's data-page attribute (stores initial page props as JSON)
const appEl = document.getElementById('app');
if (appEl) {
const dataPage = appEl.getAttribute('data-page');
if (dataPage) {
try {
const parsed = JSON.parse(dataPage);
const user = parsed?.props?.auth?.user;
if (user) {
return {
name: user.name,
email: user.email,
timezone: user.timezone,
week_start: user.week_start,
};
}
} catch {
// JSON parse failed
}
}
}
return null;
});
if (!userInfo) throw new Error('Could not read user info from Inertia data-page attribute');
const user = await getCurrentUserViaApi(ctx);
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
// Only send the fields under test; the endpoint leaves omitted fields untouched.
const data: Record<string, string> = {};
if (settings.timezone !== undefined) {
data.timezone = settings.timezone;
}
if (settings.week_start !== undefined) {
data.week_start = settings.week_start;
}
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,
},
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/api/v1/users/${user.id}`, {
data,
});
expect(response.status()).toBe(200);
}

58
e2e/utils/totp.ts Normal file
View File

@@ -0,0 +1,58 @@
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;
}
}
}

View File

@@ -61,7 +61,7 @@ const switchToTeam = (organization: Organization) => {
<DropdownMenuItem as-child>
<Link
:href="route('teams.show', page.props.auth.user.current_team.id)"
:href="route('organizations.show', page.props.auth.user.current_team.id)"
class="inline-flex items-center gap-2.5 w-full">
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
<span>Organization Settings</span>
@@ -74,7 +74,7 @@ const switchToTeam = (organization: Organization) => {
<DropdownMenuItem as-child>
<Link
:href="route('teams.create')"
:href="route('organizations.create')"
class="inline-flex items-center gap-2.5 w-full">
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
<span>Create new organization</span>

View File

@@ -62,4 +62,35 @@ describe('TimesheetCell', () => {
expect(wrapper.emitted('update')).toBeUndefined();
expect((input.element as HTMLInputElement).value).toBe(previousValue);
});
it('shows a pending 0 (delete in flight) over the cell total', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
pendingSeconds: 0,
},
});
// `??` (not `||`): a pending 0 must win over the 2h cell total.
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('');
});
it('disables editing while the cell is saving', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
saveStatus: 'saving',
},
});
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
});
});

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CheckIcon } from '@heroicons/vue/16/solid';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import {
Tooltip,
TooltipContent,
@@ -7,18 +10,40 @@ import {
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
defineProps<{
const props = defineProps<{
cell?: TimesheetCell;
dayIndex: number;
date: string;
isToday: boolean;
hasRunningEntry: boolean;
saveStatus?: CellSaveStatus;
pendingSeconds?: number;
}>();
const emit = defineEmits<{
update: [newSeconds: number];
}>();
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
const isSaving = computed(() => props.saveStatus === 'saving');
// Swap the border color (don't layer) to avoid same-specificity fights.
const inputClass = computed(() => {
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
return [
'w-[80px] mx-auto text-center font-medium',
'bg-transparent text-text-primary placeholder:text-text-quaternary',
'rounded-lg border shadow-none',
border,
'hover:bg-card-background',
'focus-visible:bg-tertiary focus-visible:border-transparent',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'disabled:cursor-wait disabled:opacity-70',
].join(' ');
});
</script>
<template>
@@ -46,18 +71,26 @@ const emit = defineEmits<{
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip>
</TooltipProvider>
<DurationSecondsInput
v-else
:model-value="cell?.totalSeconds ?? 0"
default-unit="hours"
placeholder="-"
size="sm"
input-class="w-[80px] mx-auto text-center font-medium
bg-transparent text-text-primary placeholder:text-text-quaternary
rounded-lg border border-input-border shadow-none
hover:bg-card-background
focus-visible:bg-tertiary focus-visible:border-transparent
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@commit="(seconds) => emit('update', seconds ?? 0)" />
<template v-else>
<span class="relative inline-flex items-center">
<DurationSecondsInput
:model-value="displaySeconds"
default-unit="hours"
placeholder="-"
size="sm"
:disabled="isSaving"
:input-class="inputClass"
@commit="(seconds) => emit('update', seconds ?? 0)" />
<span
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center"
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
<LoadingSpinner
v-if="saveStatus === 'saving'"
class="h-3 w-3 m-0 text-text-tertiary" />
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
</span>
</span>
</template>
</div>
</template>

View File

@@ -15,6 +15,7 @@ import type {
Task,
} from '@/packages/api/src';
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const organization = inject<ComputedRef<Organization>>('organization');
const dayjs = getDayJsInstance();
@@ -36,6 +37,8 @@ defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
@@ -60,7 +63,7 @@ const emit = defineEmits<{
class="grid min-w-full w-max border-y border-default-background-separator"
style="
grid-template-columns:
minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto)
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto)
40px;
">
<!-- Header row -->
@@ -100,6 +103,8 @@ const emit = defineEmits<{
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatuses"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="$emit('remove-row', $event)"
@cell-update="
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)

View File

@@ -15,6 +15,10 @@ import type {
Organization,
} from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import {
makeCellStatusKey,
type CellSaveStatus,
} from '@/utils/timesheet/useTimesheetCellMutations';
import { Button } from '@/packages/ui/src/Buttons';
const organization = inject<ComputedRef<Organization>>('organization');
@@ -34,6 +38,8 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
@@ -109,6 +115,8 @@ function hasRunningEntry(dayIndex: number): boolean {
:date="day"
:is-today="day === todayDate"
:has-running-entry="hasRunningEntry(dayIndex)"
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
<!-- Row total -->

View File

@@ -280,10 +280,15 @@ const page = usePage<{
v-if="canUpdateOrganization()"
title="Settings"
:icon="Cog6ToothIcon"
:href="route('teams.show', page.props.auth.user.current_team.id)"
:href="
route(
'organizations.show',
page.props.auth.user.current_team.id
)
"
:current="
route().current(
'teams.show',
'organizations.show',
page.props.auth.user.current_team.id
)
"></NavigationSidebarItem>
@@ -293,7 +298,7 @@ const page = usePage<{
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<ul
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
<NavigationSidebarItem

View File

@@ -1,15 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import ActionSection from '@/Components/ActionSection.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { Field, FieldError } from '@/packages/ui/src/field';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useDeleteUserMutation, useUserQuery } from '@/utils/useUserQuery';
import { useDeleteUserMutation } from '@/utils/useUserQuery';
import { getCurrentUserId } from '@/utils/useUser';
const { user } = useUserQuery();
const deleteUserMutation = useDeleteUserMutation();
const confirmingUserDeletion = ref(false);
@@ -24,26 +23,26 @@ function confirmUserDeletion() {
}
async function deleteUser() {
if (!user.value || processing.value) return;
if (processing.value) return;
processing.value = true;
passwordError.value = '';
try {
await axios.post(route('password.confirm'), { password: password.value });
} catch (error) {
processing.value = false;
if (axios.isAxiosError(error) && error.response?.status === 422) {
passwordError.value = error.response.data?.errors?.password?.[0] ?? 'Invalid password.';
} else {
passwordError.value = 'Could not confirm password. Please try again.';
}
passwordInput.value?.focus();
return;
}
try {
await deleteUserMutation.mutateAsync(user.value.id);
await deleteUserMutation.mutateAsync({
userId: getCurrentUserId(),
body: { password: password.value },
});
window.location.href = '/';
} catch {
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}
}
processing.value = false;
passwordInput.value?.focus();
}
}

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { usePage } from '@inertiajs/vue3';
import axios from 'axios';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
@@ -16,6 +15,7 @@ import {
useUserQuery,
} from '@/utils/useUserQuery';
import type { UpdateUserBody, User } from '@/packages/api/src';
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
const { user } = useUserQuery();
const updateUser = useUpdateUserMutation();
@@ -58,17 +58,9 @@ const hasUploadedPhoto = computed(() => {
return !!url && !url.includes('ui-avatars.com');
});
const fieldErrors = computed<Record<string, string>>(() => {
const err = updateUser.error.value;
if (!axios.isAxiosError(err) || err.response?.status !== 422) return {};
const raw = err.response.data?.errors as Record<string, string[]> | undefined;
if (!raw) return {};
const flat: Record<string, string> = {};
for (const [key, messages] of Object.entries(raw)) {
if (Array.isArray(messages) && messages[0]) flat[key] = messages[0];
}
return flat;
});
const fieldErrors = computed<Record<string, string>>(() =>
getApiValidationFieldErrors(updateUser.error.value)
);
function buildPayload(): UpdateUserBody {
if (!user.value) return {};

View File

@@ -1,25 +1,68 @@
<script setup lang="ts">
import { useForm, usePage } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import { router, usePage } from '@inertiajs/vue3';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { User } from '@/types/models';
import { initializeStores } from '@/utils/init';
import { useOrganizationStore } from '@/utils/useOrganization';
import { useNotificationsStore } from '@/utils/notification';
import {
getApiValidationFieldErrors,
getApiValidationMessage,
isApiValidationError,
} from '@/utils/apiValidation';
const form = useForm({
name: '',
const name = ref('');
const processing = ref(false);
const createError = ref<unknown>(null);
const organizationStore = useOrganizationStore();
const notifications = useNotificationsStore();
const fieldErrors = computed<Record<string, string>>(() =>
getApiValidationFieldErrors(createError.value)
);
watch(name, () => {
createError.value = null;
});
const createTeam = () => {
form.post(route('teams.store'), {
errorBag: 'createTeam',
preserveScroll: true,
onSuccess: () => {
initializeStores();
},
});
const createTeam = async () => {
processing.value = true;
createError.value = null;
try {
const organization = await organizationStore.createOrganization(name.value);
if (organization) {
notifications.addNotification('success', 'Organization created successfully');
// The backend already switched the current organization to the new one.
// Flush Inertia's prefetch cache and do a full reload so the new
// organization context is picked up everywhere.
router.flushAll();
router.visit(route('dashboard'));
}
} catch (error) {
createError.value = error;
if (isApiValidationError(error)) {
notifications.addNotification(
'error',
getApiValidationMessage(error, 'Failed to create organization')
);
} else if (axios.isAxiosError(error)) {
notifications.addNotification(
'error',
'Failed to create organization',
error.response?.data?.message ?? 'Please try again later.'
);
} else {
notifications.addNotification('error', 'Failed to create organization');
}
} finally {
processing.value = false;
}
};
const page = usePage<{
auth: {
user: User;
@@ -60,16 +103,17 @@ const page = usePage<{
<FieldLabel for="name">Organization Name</FieldLabel>
<TextInput
id="name"
v-model="form.name"
v-model="name"
type="text"
class="block w-full"
autofocus />
<FieldError v-if="form.errors.name">{{ form.errors.name }}</FieldError>
autofocus
:aria-invalid="Boolean(fieldErrors.name)" />
<FieldError v-if="fieldErrors.name">{{ fieldErrors.name }}</FieldError>
</Field>
</template>
<template #actions>
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">
Create
</PrimaryButton>
</template>

View File

@@ -1,26 +1,58 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { router } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { Field, FieldError } from '@/packages/ui/src/field';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useOrganizationStore } from '@/utils/useOrganization';
const props = defineProps({
team: Object,
});
const props = defineProps<{
team: { id: string };
}>();
const confirmingTeamDeletion = ref(false);
const form = useForm({});
const passwordInput = ref<HTMLInputElement | null>(null);
const password = ref('');
const passwordError = ref('');
const processing = ref(false);
const organizationStore = useOrganizationStore();
const confirmTeamDeletion = () => {
confirmingTeamDeletion.value = true;
setTimeout(() => passwordInput.value?.focus(), 250);
};
const deleteTeam = () => {
form.delete(route('teams.destroy', props.team), {
errorBag: 'deleteTeam',
});
const deleteTeam = async () => {
if (processing.value) return;
processing.value = true;
passwordError.value = '';
try {
await organizationStore.deleteOrganization(props.team.id, { password: password.value });
// The backend reassigns the user's current organization after deletion,
// so flush the prefetch cache and reload into the dashboard.
router.flushAll();
router.visit(route('dashboard'));
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}
}
processing.value = false;
passwordInput.value?.focus();
}
};
const closeModal = () => {
confirmingTeamDeletion.value = false;
password.value = '';
passwordError.value = '';
};
</script>
@@ -42,30 +74,40 @@ const deleteTeam = () => {
</div>
<!-- Delete Organization Confirmation Modal -->
<ConfirmationModal
:show="confirmingTeamDeletion"
@close="confirmingTeamDeletion = false">
<DialogModal :show="confirmingTeamDeletion" @close="closeModal">
<template #title> Delete Organization </template>
<template #content>
Are you sure you want to delete this organization? Once a organization is
deleted, all of its resources and data will be permanently deleted.
deleted, all of its resources and data will be permanently deleted. Please enter
your password to confirm you would like to permanently delete this organization.
<Field class="mt-4">
<TextInput
ref="passwordInput"
v-model="password"
type="password"
class="block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="deleteTeam" />
<FieldError v-if="passwordError">{{ passwordError }}</FieldError>
</Field>
</template>
<template #footer>
<SecondaryButton @click="confirmingTeamDeletion = false">
Cancel
</SecondaryButton>
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
:class="{ 'opacity-25': processing }"
:disabled="processing"
@click="deleteTeam">
Delete Organization
</DangerButton>
</template>
</ConfirmationModal>
</DialogModal>
</template>
</ActionSection>
</template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { Link, router } from '@inertiajs/vue3';
import { reactive, ref } from 'vue';
import axios from 'axios';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
@@ -10,22 +12,66 @@ import type { Permissions } from '@/types/jetstream';
import { CreditCardIcon } from '@heroicons/vue/20/solid';
import { isBillingActivated } from '@/utils/billing';
import { canManageBilling } from '@/utils/permissions';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
import { getApiValidationFieldErrors, isApiValidationError } from '@/utils/apiValidation';
const props = defineProps<{
team: Organization;
permissions: Permissions;
}>();
const form = useForm({
const form = reactive({
name: props.team.name,
currency: props.team.currency,
});
const updateTeamName = () => {
form.put(route('teams.update', props.team.id), {
errorBag: 'updateTeamName',
preserveScroll: true,
});
const errors = ref<Record<string, string>>({});
const processing = ref(false);
const recentlySuccessful = ref(false);
const notifications = useNotificationsStore();
let recentlySuccessfulTimeout: ReturnType<typeof setTimeout> | undefined;
const updateTeamName = async () => {
processing.value = true;
recentlySuccessful.value = false;
errors.value = {};
try {
await api.updateOrganization(
{
name: form.name,
currency: form.currency,
},
{
params: {
organization: props.team.id,
},
}
);
notifications.addNotification('success', 'Organization updated successfully');
recentlySuccessful.value = true;
if (recentlySuccessfulTimeout) {
clearTimeout(recentlySuccessfulTimeout);
}
recentlySuccessfulTimeout = setTimeout(() => {
recentlySuccessful.value = false;
}, 2000);
router.reload({ only: ['auth', 'team'] });
} catch (error) {
if (isApiValidationError(error)) {
errors.value = getApiValidationFieldErrors(error);
} else if (axios.isAxiosError(error)) {
notifications.addNotification(
'error',
'Failed to update organization',
error.response?.data?.message ?? 'Please try again later.'
);
} else {
notifications.addNotification('error', 'Failed to update organization');
}
} finally {
processing.value = false;
}
};
</script>
@@ -74,7 +120,7 @@ const updateTeamName = () => {
class="block w-full"
:disabled="!permissions.canUpdateTeam" />
<FieldError v-if="form.errors.name">{{ form.errors.name }}</FieldError>
<FieldError v-if="errors.name">{{ errors.name }}</FieldError>
</Field>
<!-- Currency -->
@@ -94,14 +140,14 @@ const updateTeamName = () => {
{{ currencyKey }} - {{ currencyTranslated }}
</option>
</select>
<FieldError v-if="form.errors.currency">{{ form.errors.currency }}</FieldError>
<FieldError v-if="errors.currency">{{ errors.currency }}</FieldError>
</Field>
</template>
<template v-if="permissions.canUpdateTeam" #actions>
<ActionMessage :on="form.recentlySuccessful" class="me-3"> Saved. </ActionMessage>
<ActionMessage :on="recentlySuccessful" class="me-3"> Saved. </ActionMessage>
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">
Save
</PrimaryButton>
</template>

View File

@@ -4,7 +4,7 @@ import DeleteTeamForm from '@/Pages/Teams/Partials/DeleteTeamForm.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';
import type { Organization } from '@/types/models';
import type { Permissions, Role } from '@/types/jetstream';
import type { Permissions } from '@/types/jetstream';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
@@ -14,7 +14,6 @@ import { storeToRefs } from 'pinia';
defineProps<{
team: Organization;
availableRoles: Role[];
permissions: Permissions;
}>();
@@ -44,17 +43,17 @@ onMounted(async () => {
<template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<template v-if="permissions.canDeleteTeam">
<SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template>
</template>

View File

@@ -90,7 +90,12 @@ const weekRangeDisplay = computed(() => {
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
weekDays,
timeEntries,
rows,
removeSlot
);
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations,
@@ -167,6 +172,8 @@ async function createTag(name: string): Promise<Tag | undefined> {
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatus"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="handleRemoveRow"
@cell-update="handleCellUpdate"
@project-task-change="

View File

@@ -114,6 +114,8 @@ export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;
@@ -124,6 +126,8 @@ export type UpdateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoice'>
export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data'];
export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>;
export type DeleteUserBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteUser'>;
export type DeleteOrganizationBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteOrganization'>;
const api = createApiClient('/api', { validate: 'none' });

View File

@@ -37,6 +37,7 @@ const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passth
const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
.passthrough();
const DestroyWithPasswordRequest = z.object({ password: z.string() }).passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() })
@@ -330,6 +331,7 @@ const OrganizationResource = z
const OrganizationUpdateRequest = z
.object({
name: z.string().max(255),
currency: z.string(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
@@ -803,6 +805,39 @@ const endpoints = makeApi([
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
),
},
{
method: 'post',
path: '/v1/organizations',
alias: 'createOrganization',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string().max(255) }).passthrough(),
},
],
response: z.object({ data: OrganizationResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization',
@@ -877,6 +912,42 @@ const endpoints = makeApi([
},
],
},
{
method: 'delete',
path: '/v1/organizations/:organization',
alias: 'deleteOrganization',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: DestroyWithPasswordRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/charts/daily-tracked-hours',
@@ -1898,6 +1969,54 @@ const endpoints = makeApi([
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/invoices/:invoice/copy',
alias: 'copyInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ reference: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoice',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/invoices/:invoice',
@@ -4447,6 +4566,42 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
},
],
},
{
method: 'put',
path: '/v1/users/me/current-organization',
alias: 'updateMyCurrentOrganization',
description: `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.`,
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ organization_id: z.string().uuid() }).passthrough(),
},
],
response: z.object({ data: UserResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'put',
path: '/v1/users/:user',
@@ -4493,6 +4648,11 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
description: `This endpoint is independent of the organization.`,
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: DestroyWithPasswordRequest,
},
{
name: 'user',
type: 'Path',

View File

@@ -1,11 +1,8 @@
import type { User } from '@/types/models';
export interface Permissions {
canAddTeamMembers: boolean;
canDeleteTeam: boolean;
canRemoveTeamMembers: boolean;
canUpdateTeam: boolean;
canUpdateTeamMembers: boolean;
}
export interface Session {

View File

@@ -0,0 +1,31 @@
import axios, { type AxiosError } from 'axios';
type ApiValidationResponse = {
message?: string;
errors?: Record<string, string[]>;
};
export function isApiValidationError(error: unknown): error is AxiosError<ApiValidationResponse> {
return axios.isAxiosError<ApiValidationResponse>(error) && error.response?.status === 422;
}
export function getApiValidationFieldErrors(error: unknown): Record<string, string> {
if (!isApiValidationError(error)) {
return {};
}
const fieldErrors: Record<string, string> = {};
for (const [field, messages] of Object.entries(error.response?.data?.errors ?? {})) {
if (Array.isArray(messages) && messages[0]) {
fieldErrors[field] = messages[0];
}
}
return fieldErrors;
}
export function getApiValidationMessage(error: unknown, fallback: string): string {
if (!isApiValidationError(error)) {
return fallback;
}
return error.response?.data?.message ?? fallback;
}

View File

@@ -210,7 +210,7 @@ export function createNavigationCommands(
icon: Cog6ToothIcon,
keywords: ['settings', 'organization', 'configuration'],
group: 'navigation',
action: () => navigate('teams.show', { team: currentTeamId() }),
action: () => navigate('organizations.show', { organizationId: currentTeamId() }),
permission: permissions.canUpdateOrganization,
priority: GROUP_PRIORITIES.navigation - 3,
},

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { useTimesheetCellMutations } from './useTimesheetCellMutations';
import { useTimesheetCellMutations, makeCellStatusKey } from './useTimesheetCellMutations';
import { api } from '@/packages/api/src';
import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid';
import type { TimeEntry } from '@/packages/api/src';
@@ -549,3 +549,119 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
});
});
});
describe('useTimesheetCellMutations save status', () => {
// Timer handles keep old fade-outs from clearing newer status, and
// the same-cell saving guard prevents concurrent writes from stale rows.
it('does not let a stale fade-out timer clear a newer edit on the same cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
// Re-edit the same cell partway through the first "saved" window.
vi.advanceTimersByTime(1000);
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
// Advance past the FIRST timer's deadline: it must not wipe the newer state.
vi.advanceTimersByTime(2000);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
});
it('ignores another commit while the same cell is saving', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
let release!: () => void;
const gateA = new Promise<void>((res) => {
release = () => res();
});
apiMocks.createTimeEntry.mockImplementationOnce(async () => {
await gateA;
return { data: { id: 'a' } } as never;
});
const save = cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saving');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
// The second commit would be planned from the same stale row, so it is ignored.
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
release();
await save;
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
});
it('marks error and drops the optimistic value when the save fails', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom'));
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
'Failed to update timesheet',
expect.any(String)
);
});
it('marks error and drops the optimistic value when the day is full', async () => {
// Block all but the last 2h, then ask for 3h → NoFreeWindowError.
const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'blocker' });
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, 3 * HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
"This day can't fit any more work",
expect.any(String)
);
});
it('creates no status when the committed value is unchanged', async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z');
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBeUndefined();
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
});
it('tracks save status independently for each cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const mondayKey = makeCellStatusKey(row.key, 0);
const tuesdayKey = makeCellStatusKey(row.key, 1);
await cellMutations.handleCellUpdate(row, 0, HOUR);
await cellMutations.handleCellUpdate(row, 1, 2 * HOUR);
expect(cellMutations.cellStatus.value[mondayKey]).toBe('saved');
expect(cellMutations.cellStatus.value[tuesdayKey]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[mondayKey]).toBe(HOUR);
expect(cellMutations.cellPendingSeconds.value[tuesdayKey]).toBe(2 * HOUR);
});
});

View File

@@ -1,4 +1,4 @@
import type { Ref } from 'vue';
import { ref, type Ref } from 'vue';
import { useQueryClient } from '@tanstack/vue-query';
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
@@ -19,6 +19,17 @@ import {
type FreeWindow,
} from './cellMath';
export type CellSaveStatus = 'saving' | 'saved' | 'error';
/** Map key for a cell's save state (row + day). */
export function makeCellStatusKey(rowKey: TimesheetRowKey, dayIndex: number): string {
return `${rowKey}:${dayIndex}`;
}
/** How long the saved/error state stays visible before fading. */
const SAVED_VISIBLE_MS = 2800;
const ERROR_VISIBLE_MS = 2500;
/**
* Cell-level edit dispatcher. Picks one of four strategies based on
* the diff between current and requested totals:
@@ -48,15 +59,58 @@ export function useTimesheetCellMutations(
const queryClient = useQueryClient();
const notifications = useNotificationsStore();
// Save status + the optimistic value shown while saving, so a saved cell
// doesn't flicker back to its old total before the refetch lands.
const cellStatus = ref<Record<string, CellSaveStatus>>({});
const cellPendingSeconds = ref<Record<string, number>>({});
const statusClearTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function clearStatusTimer(key: string): void {
clearTimeout(statusClearTimers[key]);
delete statusClearTimers[key];
}
function beginSaving(key: string, seconds: number): void {
clearStatusTimer(key);
cellPendingSeconds.value[key] = seconds;
cellStatus.value[key] = 'saving';
}
function markSaved(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'saved';
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete cellPendingSeconds.value[key];
delete statusClearTimers[key];
}, SAVED_VISIBLE_MS);
}
function markError(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'error';
// Drop the optimistic value so the cell shows server truth after refetch.
delete cellPendingSeconds.value[key];
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete statusClearTimers[key];
}, ERROR_VISIBLE_MS);
}
async function handleCellUpdate(
row: TimesheetRow,
dayIndex: number,
newTotalSeconds: number
): Promise<void> {
const statusKey = makeCellStatusKey(row.key, dayIndex);
if (cellStatus.value[statusKey] === 'saving') return;
const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0;
if (newTotalSeconds === existingSeconds) return;
beginSaving(statusKey, newTotalSeconds);
// Capture row state before the mutation: a row that was empty
// and shares identity with another slot collapses after the
// first entry lands, so the entry naturally identity-routes to
@@ -74,7 +128,9 @@ export function useTimesheetCellMutations(
'Another row with the same project, task, billable status and tags already exists.'
);
}
markSaved(statusKey);
} catch (err) {
markError(statusKey);
if (err instanceof NoFreeWindowError) {
const friendlyDuration = formatHumanReadableDuration(
err.requiredSeconds,
@@ -93,7 +149,6 @@ export function useTimesheetCellMutations(
'Failed to update timesheet',
'Please try again later.'
);
throw err;
} finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
@@ -316,5 +371,5 @@ export function useTimesheetCellMutations(
return best;
}
return { handleCellUpdate };
return { handleCellUpdate, cellStatus, cellPendingSeconds };
}

View File

@@ -2,37 +2,45 @@ import { router } from '@inertiajs/vue3';
import { initializeStores } from '@/utils/init';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import axios from 'axios';
import type {
Organization,
OrganizationResponse,
DeleteOrganizationBody,
UpdateOrganizationBody,
} from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
export function switchOrganization(organizationId: string) {
// Clear Inertia's prefetch cache to prevent stale pages from the old
// organization being served when navigating after the switch.
router.flushAll();
export async function switchOrganization(organizationId: string) {
const { handleApiRequestNotifications } = useNotificationsStore();
try {
await handleApiRequestNotifications(
() => api.updateMyCurrentOrganization({ organization_id: organizationId }),
undefined,
'Failed to switch organization'
);
} catch {
// The error notification is surfaced by the request handler.
return;
}
router.put(
route('current-team.update'),
{
team_id: organizationId,
// The current organization changed server-side. Clear Inertia's prefetch
// cache and reload into the dashboard so the new organization context
// (auth.user.current_team) is picked up everywhere.
router.flushAll();
router.visit(route('dashboard'), {
preserveState: false,
onSuccess: () => {
initializeStores();
},
{
preserveState: false,
onSuccess: () => {
initializeStores();
},
}
);
});
}
export const useOrganizationStore = defineStore('organization', () => {
const organizationResponse = ref<OrganizationResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
const { addNotification, handleApiRequestNotifications } = useNotificationsStore();
async function fetchOrganization() {
const organization = getCurrentOrganizationId();
@@ -67,9 +75,42 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function createOrganization(name: string): Promise<Organization | null> {
const response = await api.createOrganization({ name });
return response?.data ?? null;
}
async function deleteOrganization(organizationId: string, body: DeleteOrganizationBody) {
try {
await api.deleteOrganization(body, {
params: {
organization: organizationId,
},
});
addNotification('success', 'Organization deleted successfully');
} catch (error) {
if (!axios.isAxiosError(error) || error.response?.status !== 422) {
addNotification(
'error',
'Failed to delete organization',
axios.isAxiosError(error)
? (error.response?.data?.message ?? 'Please try again later.')
: 'Please try again later.'
);
}
throw error;
}
}
const organization = computed<Organization | null>(() => {
return organizationResponse.value?.data || null;
});
return { organization, fetchOrganization, updateOrganization };
return {
organization,
fetchOrganization,
updateOrganization,
createOrganization,
deleteOrganization,
};
});

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { computed } from 'vue';
import axios from 'axios';
import { api, type UpdateUserBody, type User } from '@/packages/api/src';
import { api, type DeleteUserBody, type UpdateUserBody, type User } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
const ME_QUERY_KEY = ['me'] as const;
@@ -61,9 +61,9 @@ export function useDeleteUserMutation() {
const { addNotification } = useNotificationsStore();
return useMutation({
mutationFn: async (userId: string) => {
mutationFn: async ({ userId, body }: { userId: string; body: DeleteUserBody }) => {
try {
await api.deleteUser(undefined, { params: { user: userId } });
await api.deleteUser(body, { params: { user: userId } });
} catch (error) {
if (!axios.isAxiosError(error) || error.response?.status !== 422) {
addNotification(

View File

@@ -114,6 +114,13 @@ declare module 'ziggy-js' {
'other-browser-sessions.destroy': [];
'current-user-photo.destroy': [];
'current-user.destroy': [];
'organizations.create': [];
'organizations.show': [
{
'name': 'organizationId';
'required': true;
},
];
'teams.create': [];
'teams.store': [];
'teams.show': [

View File

@@ -197,6 +197,12 @@ const Ziggy = {
'methods': ['DELETE'],
},
'current-user.destroy': { 'uri': 'user', 'methods': ['DELETE'] },
'organizations.create': { 'uri': 'organizations/create', 'methods': ['GET', 'HEAD'] },
'organizations.show': {
'uri': 'organizations/{organizationId}',
'methods': ['GET', 'HEAD'],
'parameters': ['organizationId'],
},
'teams.create': { 'uri': 'teams/create', 'methods': ['GET', 'HEAD'] },
'teams.store': { 'uri': 'teams', 'methods': ['POST'] },
'teams.show': {

View File

@@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\ReportController;
use App\Http\Controllers\Api\V1\TagController;
use App\Http\Controllers\Api\V1\TaskController;
use App\Http\Controllers\Api\V1\TimeEntryController;
use App\Http\Controllers\Api\V1\TimeZoneController;
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserMembershipController;
use App\Http\Controllers\Api\V1\UserTimeEntryController;
@@ -61,6 +62,7 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
// User routes
Route::name('users.')->group(static function (): void {
Route::get('/users/me', [UserController::class, 'me'])->name('me');
Route::put('/users/me/current-organization', [UserController::class, 'updateMyCurrentOrganization'])->name('update-current-organization');
Route::put('/users/{user}', [UserController::class, 'update'])->name('update');
Route::post('/users/{user}/resend-email-verification', [UserController::class, 'resendEmailVerification'])->name('resend-email-verification');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy');
@@ -179,10 +181,15 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::post('/export', [ExportController::class, 'export'])->name('export');
});
});
// Currency routes
Route::get('/currencies', [CurrencyController::class, 'index'])->name('currencies.index');
// Timezone routes
Route::get('/time-zones', [TimeZoneController::class, 'index'])->name('time-zones.index');
// Public routes
Route::name('public.')->prefix('/public')->group(static function (): void {
Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show');

View File

@@ -4,11 +4,15 @@ declare(strict_types=1);
use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\OrganizationController;
use App\Http\Controllers\Web\OrganizationInvitationController;
use App\Http\Controllers\Web\OtherBrowserSessionsController;
use App\Http\Controllers\Web\UserController;
use App\Http\Controllers\Web\UserProfileController;
use App\Service\PermissionStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Jetstream\Jetstream;
/*
|--------------------------------------------------------------------------
@@ -29,7 +33,7 @@ Route::get('/shared-report', function () {
Route::middleware([
'auth:web',
config('jetstream.auth_session'),
'auth.session',
'verified',
])->group(function (): void {
Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard');
@@ -72,7 +76,14 @@ Route::middleware([
Route::get('/members', function () {
return Inertia::render('Members', [
'availableRoles' => array_values(Jetstream::$roles),
'availableRoles' => collect(PermissionStore::roleDefinitions())
->map(fn (array $definition, string $key): array => [
'key' => $key,
'name' => $definition['name'],
'description' => $definition['description'],
])
->values()
->all(),
]);
})->name('members');
@@ -84,6 +95,17 @@ Route::middleware([
return Inertia::render('Import');
})->name('import');
Route::get('/organizations/create', [OrganizationController::class, 'create'])->name('organizations.create');
Route::get('/organizations/{organizationId}', [OrganizationController::class, 'show'])->name('organizations.show');
Route::get('/teams/create', function (): RedirectResponse {
return to_route('organizations.create');
})->name('teams.create');
Route::get('/teams/{organizationId}', function (string $organizationId): RedirectResponse {
return to_route('organizations.show', [$organizationId]);
})->name('teams.show');
Route::get('/user/profile', [UserProfileController::class, 'show'])->name('profile.show');
Route::delete('/user/other-browser-sessions', [OtherBrowserSessionsController::class, 'destroy'])
->name('other-browser-sessions.destroy');
});
Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
@@ -94,5 +116,5 @@ Route::get('/organization-invitations/{invitation}', [OrganizationInvitationCont
->name('organization-invitations.accept');
Route::get('/users/{user}/verify-email-change', [UserController::class, 'verifyEmailChange'])
->middleware(['auth:web', config('jetstream.auth_session'), 'signed:relative'])
->middleware(['auth:web', 'signed:relative'])
->name('users.verify-email-change');

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BrowserSessionsTest extends TestCase
{
use RefreshDatabase;
public function test_other_browser_sessions_can_be_logged_out(): void
{
$this->actingAs($user = User::factory()->create());
$response = $this->delete('/user/other-browser-sessions', [
'password' => 'password',
]);
$response->assertSessionHasNoErrors();
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class CreateOrganizationTest extends TestCase
{
use RefreshDatabase;
public function test_organizations_can_be_created(): void
{
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$this->actingAs($user);
Event::fake([
AfterCreateOrganization::class,
]);
// Act
$response = $this->post('/teams', [
'name' => 'Test Organization',
]);
// Assert
$response->assertStatus(302);
/** @var Organization|null $newOrganization */
$ownedOrganizations = $user->fresh()->ownedOrganizations;
$this->assertCount(2, $ownedOrganizations);
$this->assertTrue($ownedOrganizations->contains('name', 'Test Organization'));
$newOrganization = $ownedOrganizations->firstWhere('name', 'Test Organization');
/** @var Member $member */
$member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail();
$this->assertSame(Role::Owner->value, $member->role);
Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool {
return $event->organization->is($newOrganization);
});
}
}

View File

@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DeleteAccountTest extends TestCase
{
use RefreshDatabase;
public function test_user_accounts_can_be_deleted(): void
{
// Arrange
$user = User::factory()->create();
$this->actingAs($user);
// Act
$response = $this->delete('/user', [
'password' => 'password',
]);
// Assert
$response->assertStatus(302);
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_before_account_can_be_deleted(): void
{
// Arrange
$user = User::factory()->create();
$this->actingAs($user);
// Act
$response = $this->delete('/user', [
'password' => 'wrong-password',
]);
// Assert
$this->assertNotNull($user->fresh());
}
public function test_user_account_can_not_be_deleted_if_attached_to_a_organization_with_multiple_users(): void
{
// Arrange
$user = User::factory()->create();
$organization = Organization::factory()->withOwner($user)->create();
$userMember = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->role(Role::Admin)->create();
$this->actingAs($user);
// Act
$response = $this->delete('/user', [
'password' => 'password',
]);
// Assert
$response->assertInvalid(['password']);
$this->assertNotNull($user->fresh());
}
}

View File

@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DeleteOrganizationTest extends TestCase
{
use RefreshDatabase;
public function test_organizations_can_be_deleted_and_users_of_the_organization_that_have_no_organization_get_a_new_one(): void
{
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$this->actingAs($user);
$organization = Organization::factory()->withOwner($user)->create([
'personal_team' => false,
]);
Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();
$otherUser = User::factory()->create();
$organization->users()->attach(
$otherUser, ['role' => 'test-role']
);
// Act
$response = $this->delete('/teams/'.$organization->getKey());
// Assert
$this->assertNull($organization->fresh());
$this->assertCount(1, $otherUser->fresh()->organizations);
$this->assertFalse($otherUser->fresh()->organizations->first()->is($organization));
}
public function test_personal_organizations_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void
{
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$organization = $user->currentOrganization;
$this->actingAs($user);
// Act
$response = $this->delete('/teams/'.$organization->getKey());
// Assert
$user->refresh();
$this->assertDatabaseMissing(Organization::class, [
'id' => $organization->getKey(),
]);
$this->assertTrue($user->currentOrganization->isNot($organization));
}
public function test_organization_can_not_be_deleted_if_user_is_not_owner(): void
{
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$organization = Organization::factory()->withOwner($user)->create([
'personal_team' => false,
]);
$this->actingAs($user);
$otherUser = User::factory()->create();
$organization->users()->attach(
$otherUser, ['role' => Role::Admin->value]
);
// Act
$response = $this->delete('/teams/'.$organization->getKey());
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Organization::class, [
'id' => $organization->getKey(),
]);
}
}

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