Compare commits

..

4 Commits

Author SHA1 Message Date
Gregor Vostrak
595132ec2c fix diagnostics smoke test 2026-05-12 15:15:40 +02:00
Gregor Vostrak
0659cb3993 fix smoke test permissions 2026-05-11 20:15:21 +02:00
Gregor Vostrak
e8fc6fd77e removed SOLIDTIME_DROP_PRIVILEGES always option 2026-05-11 20:10:37 +02:00
Gregor Vostrak
a04185921d improve self-hosting permission handling 2026-05-11 19:08:55 +02:00
250 changed files with 4562 additions and 14529 deletions

View File

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

View File

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

View File

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

View File

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

258
.github/workflows/image-smoke-test.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
name: Image Smoke Tests
on:
pull_request:
paths:
- 'docker/prod/**'
- '.github/workflows/image-smoke-test.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
smoke:
name: Smoke (${{ matrix.mode }})
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
mode:
- default
- puid-pgid
- openshift
- drop-never
- diagnostic
- puid-mismatch-warning
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Copy .env template
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: Composer install
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: NPM ci
run: npm ci
- name: NPM build
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build smoke image
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
load: true
tags: solidtime-smoke:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Smoke: default (image config + fresh deploy with empty bind mounts)"
if: matrix.mode == 'default'
run: |
echo "[smoke] image's default USER is root (entrypoint needs root to drop privs)"
user=$(docker inspect --format '{{.Config.User}}' solidtime-smoke:test)
if [ "$user" != "root" ]; then
echo "Expected 'root', got '$user'. The Dockerfile must end with USER root so the entrypoint can chown/usermod and drop privileges."
exit 1
fi
echo "[smoke] storage tree is group-0 owned (OpenShift / arbitrary-UID compat)"
group=$(docker run --rm --entrypoint stat solidtime-smoke:test -c '%g' /var/www/html/storage)
if [ "$group" != "0" ]; then
echo "Expected group 0, got '$group'. The Dockerfile must chgrp -R 0 storage bootstrap/cache so arbitrary-UID containers can write."
exit 1
fi
mkdir -p test-storage test-cache
docker run --rm \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] framework subdirs exist"
test -d /var/www/html/storage/framework/cache/data
test -d /var/www/html/storage/framework/sessions
test -d /var/www/html/storage/framework/views
test -d /var/www/html/storage/framework/testing
test -d /var/www/html/storage/logs
test -d /var/www/html/storage/app/public
test -d /var/www/html/storage/app/private
test -d /var/www/html/bootstrap/cache
echo "[smoke] storage is writable"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] running as octane (UID 1000)"
[ "$(id -u)" = "1000" ]
echo "[smoke] PASS"
'
- name: "Smoke: PUID/PGID remap"
if: matrix.mode == 'puid-pgid'
run: |
mkdir -p test-storage test-cache
sudo chown -R 1501:1501 test-storage test-cache
docker run --rm \
-e PUID=1501 -e PGID=1501 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as remapped UID/GID 1501"
[ "$(id -u)" = "1501" ]
[ "$(id -g)" = "1501" ]
echo "[smoke] storage is writable as 1501"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: OpenShift / arbitrary UID + group 0"
if: matrix.mode == 'openshift'
run: |
mkdir -p test-storage test-cache
sudo chown -R 2000:0 test-storage test-cache
sudo chmod -R g+rwX test-storage test-cache
docker run --rm --user 2000:0 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as arbitrary UID 2000, group 0"
[ "$(id -u)" = "2000" ]
[ "$(id -g)" = "0" ]
echo "[smoke] storage is writable via group 0"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: SOLIDTIME_DROP_PRIVILEGES=never (run as root)"
if: matrix.mode == 'drop-never'
run: |
mkdir -p test-storage test-cache
docker run --rm \
-e SOLIDTIME_DROP_PRIVILEGES=never \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as root (privilege drop disabled)"
[ "$(id -u)" = "0" ]
echo "[smoke] bootstrap still ran"
test -d /var/www/html/storage/framework/cache/data
echo "[smoke] storage writable as root"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: PUID set + started non-root prints a warning but continues"
if: matrix.mode == 'puid-mismatch-warning'
run: |
mkdir -p test-storage test-cache
sudo chown -R 1500:1500 test-storage test-cache
set +e
docker run --rm \
--user 1500:1500 \
-e PUID=1500 -e PGID=1500 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as 1500 (user: directive wins)"
[ "$(id -u)" = "1500" ]
echo "[smoke] storage is writable as 1500"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] container completed successfully"
' \
>stdout.log 2>stderr.log
exit_code=$?
set -e
echo "[smoke] exit code: $exit_code"
echo "--- stderr ---"
cat stderr.log
echo "--- end stderr ---"
if [ "$exit_code" -ne 0 ]; then
echo "Expected the entrypoint to continue (warning is non-fatal)."
exit 1
fi
for needle in "PUID/PGID is set but the container started as UID" "remove any 'user:' directive" "Continuing as UID"; do
if ! grep -q "$needle" stderr.log; then
echo "Missing warning fragment: $needle"
exit 1
fi
done
echo "[smoke] PASS"
- name: "Smoke: diagnostic error path (read-only storage mount)"
if: matrix.mode == 'diagnostic'
run: |
# Pre-create the full storage tree on the host so the entrypoint's
# bootstrap_storage_tree() is a no-op (mkdir -p on existing dirs
# returns 0 even on a read-only mount). The write test then fires
# against the RO mount and triggers our diagnostic.
mkdir -p test-storage/framework/cache/data \
test-storage/framework/sessions \
test-storage/framework/views \
test-storage/framework/testing \
test-storage/logs \
test-storage/app/public \
test-storage/app/private \
test-cache
set +e
docker run --rm \
-v "$(pwd)/test-storage:/var/www/html/storage:ro" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache:ro" \
solidtime-smoke:test \
true \
>stdout.log 2>stderr.log
exit_code=$?
set -e
echo "[smoke] exit code: $exit_code"
echo "--- stderr ---"
cat stderr.log
echo "--- end stderr ---"
if [ "$exit_code" -eq 0 ]; then
echo "Expected the entrypoint to exit non-zero on an unwritable storage mount."
exit 1
fi
for needle in "not writable" "PUID=" "permissions"; do
if ! grep -q "$needle" stderr.log; then
echo "Missing diagnostic fragment: $needle"
exit 1
fi
done
echo "[smoke] PASS"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

1
.npmrc
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class MemberAdded
{
use Dispatchable;
public Member $member;
public Organization $organization;
public User $user;
public function __construct(Member $member, Organization $organization, User $user)
{
$this->member = $member;
$this->organization = $organization;
$this->user = $user;
}
}

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class MemberAdding
{
use Dispatchable;
public User $user;
public Organization $organization;
public Role $role;
public function __construct(User $user, Organization $organization, Role $role)
{
$this->user = $user;
$this->organization = $organization;
$this->role = $role;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{ {
if (! $organization->prevent_overlapping_time_entries) { if (! $organization->prevent_overlapping_time_entries) {
return; return;

View File

@@ -4,26 +4,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Http\Requests\V1\User\UserUpdateRequest;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User;
use App\Service\DeletionService;
use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller class UserController extends Controller
{ {
/** /**
* Get the current user * Get the current user
* *
* This endpoint is independent of the organization. * This endpoint is independent of organization.
* *
* @operationId getMe * @operationId getMe
* *
@@ -35,140 +24,4 @@ class UserController extends Controller
return new UserResource($user); return new UserResource($user);
} }
/**
* Update the current user
*
* This endpoint is independent of the organization.
*
* @operationId updateUser
*/
public function update(User $user, UserUpdateRequest $request): UserResource
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($request->hasPhotoKey()) {
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto();
if ($newPhoto === null) {
$user->profile_photo_path = null;
} else {
$decoded = Base64File::decode($newPhoto);
assert($decoded !== null);
$extension = Base64File::extension($decoded['mime_type']);
assert($extension !== null);
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
Storage::disk($photoDisk)->put($photoPath, $decoded['data'], 'public');
$user->profile_photo_path = $photoPath;
}
if ($previousPhotoPath !== null) {
Storage::disk($photoDisk)->delete($previousPhotoPath);
}
}
$emailToVerify = null;
$email = $request->getEmail();
if ($email !== null && $email !== Str::lower($user->email)) {
$emailToVerify = $email;
$user->pending_email = $email;
}
if ($request->getName() !== null) {
$user->name = $request->getName();
}
if ($request->getTimezone() !== null) {
$user->timezone = $request->getTimezone();
}
if ($request->getWeekStart() !== null) {
$user->week_start = $request->getWeekStart();
}
$user->save();
if ($emailToVerify !== null) {
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
}
return new UserResource($user);
}
/**
* Reset the pending email for a user.
*
* This endpoint is independent of the organization.
*
* @operationId resetUserPendingEmail
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
*/
public function resetPendingEmail(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$user->pending_email = null;
$user->save();
return response()->json(null, 204);
}
/**
* Resend the pending email update verification email.
*
* This endpoint is independent of the organization.
*
* @operationId resendUserEmailVerification
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
*/
public function resendEmailVerification(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($user->pending_email === null) {
throw new UserResendEmailVerificationNoPendingEmailApiException;
}
Mail::to($user->pending_email)
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
return response()->json(null, 204);
}
/**
* Handles the deletion of a user.
*
* This endpoint is independent of the organization.
*
* @operationId deleteUser
*
* @param User $user The user instance to be deleted.
* @param DeletionService $deletionService The service responsible for performing the user deletion.
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/
public function destroy(User $user, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$deletionService->deleteUser($user);
return response()->json(null, 204);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class UserController extends Controller
{
public function verifyEmailChange(Request $request, User $user): RedirectResponse
{
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
abort(403);
}
$email = $request->query('email');
if (! is_string($email)) {
abort(403);
}
$email = Str::lower($email);
if ($user->pending_email !== $email) {
abort(403);
}
$emailAlreadyInUse = User::query()
->where('email', '=', $email)
->where('is_placeholder', '=', false)
->whereKeyNot($user->getKey())
->exists();
if ($emailAlreadyInUse) {
return redirect(route('dashboard'))
->with('bannerStyle', 'danger')
->with('bannerText', __('The email address is already in use.'));
}
$user->email = $email;
$user->pending_email = null;
$user->email_verified_at = Carbon::now();
$user->save();
return redirect(route('dashboard'))
->with('bannerStyle', 'success')
->with('bannerText', __('Your email address has been updated successfully.'));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,12 @@ use App\Models\User;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Illuminate\Support\MessageBag; use Illuminate\Support\MessageBag;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class ShareInertiaData class ShareInertiaData
@@ -25,6 +27,26 @@ class ShareInertiaData
/** @var PermissionStore $permissions */ /** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class); $permissions = app(PermissionStore::class);
Inertia::share([ Inertia::share([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
return [
'canCreateTeams' => $user !== null &&
Jetstream::userHasTeamFeatures($user) &&
Gate::forUser($user)->check('create', Jetstream::newTeamModel()),
'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
'flash' => $request->session()->get('flash', []),
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
'hasApiFeatures' => Jetstream::hasApiFeatures(),
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
'managesProfilePhotos' => Jetstream::managesProfilePhotos(),
];
},
'auth' => [ 'auth' => [
'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [], 'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
'user' => function () use ($request): array { 'user' => function () use ($request): array {

View File

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

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|Rule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
}

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\User;
use App\Rules\Base64ImageRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property User $user User from model binding
*/
class UserUpdateRequest extends BaseFormRequest
{
protected function prepareForValidation(): void
{
if ($this->has('email') && is_string($this->input('email'))) {
$this->merge([
'email' => Str::lower((string) $this->input('email')),
]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'string',
'max:255',
],
'email' => [
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
new Base64ImageRule,
],
'timezone' => [
'timezone:all',
],
'week_start' => [
Rule::enum(Weekday::class),
],
];
}
public function getName(): ?string
{
return $this->has('name') ? (string) $this->input('name') : null;
}
public function getEmail(): ?string
{
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
}
public function getTimezone(): ?string
{
return $this->has('timezone') ? (string) $this->input('timezone') : null;
}
public function getWeekStart(): ?Weekday
{
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
}
public function hasPhotoKey(): bool
{
return $this->has('photo');
}
public function getPhoto(): ?string
{
$value = $this->input('photo');
return is_string($value) ? $value : null;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
class VerifyUpdatedEmailMail extends Mailable
{
use Queueable, SerializesModels;
public User $user;
public string $email;
public function __construct(User $user, string $email)
{
$this->user = $user;
$this->email = Str::lower($email);
}
/**
* Build the message.
*/
public function build(): self
{
$verificationUrl = URL::temporarySignedRoute(
'users.verify-email-change',
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
[
'user' => $this->user->getKey(),
'email' => $this->email,
],
false
);
return $this->markdown('emails.verify-updated-email', [
'verificationUrl' => URL::to($verificationUrl),
])->subject(__('Verify Email Address'));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,18 +13,20 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization; use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion; use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole; use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion; use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -58,6 +60,13 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useTeamInvitationModel(OrganizationInvitation::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class); app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'),
]);
});
Gate::define('removeTeamMember', function (User $user, Organization $team) { Gate::define('removeTeamMember', function (User $user, Organization $team) {
return false; return false;
}); });
@@ -70,10 +79,205 @@ class JetstreamServiceProvider extends ServiceProvider
{ {
Jetstream::defaultApiTokenPermissions([]); Jetstream::defaultApiTokenPermissions([]);
foreach (PermissionStore::roleDefinitions() as $role => $definition) { Jetstream::role(Role::Owner->value, 'Owner', [
Jetstream::role($role, $definition['name'], $definition['permissions']) 'charts:view:own',
->description($definition['description']); 'charts:view:all',
} 'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'organizations:update',
'organizations:delete',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'organizations:update',
'import',
'export',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:delete',
'members:update',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
'organizations:view',
'invitations:view',
'members:view',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [
'charts:view:own',
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
Jetstream::inertia() Jetstream::inertia()
->whenRendering( ->whenRendering(
@@ -100,8 +304,28 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [ 'owner' => [
'id' => $owner->getKey(), 'id' => $owner->getKey(),
'name' => $owner->name, 'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url, 'profile_photo_url' => $owner->profile_photo_url,
], ],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
], ],
'currencies' => array_map(function (Currency $currency): string { 'currencies' => array_map(function (Currency $currency): string {
return $currency->getName(); return $currency->getName();

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Support\Base64File;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class Base64ImageRule implements ValidationRule
{
private const array ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
];
private const int MAX_BYTES = 1024 * 1024;
/**
* Run the validation rule.
*
* @param Closure(string): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail(__('validation.string'));
return;
}
$file = Base64File::decode($value);
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
$fail(__('validation.mimes', ['values' => 'jpg, png']));
return;
}
if (strlen($file['data']) > self::MAX_BYTES) {
$fail(__('validation.max.file', ['max' => (string) (self::MAX_BYTES / 1024)]));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Symfony\Component\Mime\MimeTypes;
class Base64File
{
/**
* @return array{data: string, mime_type: string}|null
*/
public static function decode(string $value): ?array
{
if (str_contains($value, ',')) {
[, $value] = explode(',', $value, 2);
}
$value = preg_replace('/\s+/', '', $value);
if ($value === null || $value === '') {
return null;
}
$decoded = base64_decode($value, true);
if ($decoded === false) {
return null;
}
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
if ($mimeType === false) {
return null;
}
return [
'data' => $decoded,
'mime_type' => $mimeType,
];
}
public static function extension(string $mimeType): ?string
{
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
}
}

View File

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

3359
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organization_invitations', function (Blueprint $table): void {
$table->timestamp('accepted_at')->nullable()->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organization_invitations', function (Blueprint $table): void {
$table->dropColumn('accepted_at');
});
}
};

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('pending_email')->nullable()->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('pending_email');
});
}
};

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* @throws RuntimeException
*/
public function up(): void
{
$duplicateEmails = DB::table('users')
->selectRaw('LOWER(email) as normalized_email')
->selectRaw('COUNT(*) as user_count')
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
->where('is_placeholder', false)
->groupByRaw('LOWER(email)')
->havingRaw('COUNT(*) > 1')
->orderBy('normalized_email')
->get();
if ($duplicateEmails->isNotEmpty()) {
$duplicateEmailMessage = $duplicateEmails
->take(20)
->map(fn (stdClass $duplicateEmail): string => sprintf(
'%s (%d users: %s)',
$duplicateEmail->normalized_email,
$duplicateEmail->user_count,
$duplicateEmail->users,
))
->implode('; ');
$remainingDuplicateCount = $duplicateEmails->count() - 20;
$remainingDuplicateMessage = $remainingDuplicateCount > 0
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
: '';
throw new RuntimeException(
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
$duplicateEmailMessage.
$remainingDuplicateMessage
);
}
DB::table('users')
->whereRaw('email <> LOWER(email)')
->update([
'email' => DB::raw('LOWER(email)'),
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

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

View File

@@ -68,6 +68,7 @@ RUN apt-get update; \
wget \ wget \
vim \ vim \
git \ git \
gosu \
ncdu \ ncdu \
procps \ procps \
unzip \ unzip \
@@ -193,9 +194,20 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public #COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \ RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \ storage/framework/{sessions,views,cache/data,testing} \
storage/logs \ storage/logs \
bootstrap/cache && chmod -R a+rw storage storage/app/public \
storage/app/private \
bootstrap/cache && \
ln -s ../storage/app/public public/storage && \
chmod -R a+rw storage bootstrap/cache
# OpenShift / arbitrary-UID compatibility: group 0 (root group) gets read+write+execute
# on writable paths. Any UID can run the container if it joins the root group.
# https://docs.openshift.com/container-platform/latest/openshift_images/create-images.html
USER root
RUN chgrp -R 0 storage bootstrap/cache && \
chmod -R g+rwX storage bootstrap/cache
#RUN composer install \ #RUN composer install \
# --classmap-authoritative \ # --classmap-authoritative \

View File

@@ -1,7 +1,6 @@
[program:octane] [program:octane]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
user = %(ENV_USER)s
priority = 1 priority = 1
autostart = true autostart = true
autorestart = true autorestart = true
@@ -14,7 +13,6 @@ stderr_logfile_maxbytes = 0
[program:horizon] [program:horizon]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 3 priority = 3
autostart = %(ENV_WITH_HORIZON)s autostart = %(ENV_WITH_HORIZON)s
autorestart = true autorestart = true
@@ -27,7 +25,6 @@ stopwaitsecs = 3600
[program:scheduler] [program:scheduler]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
@@ -38,7 +35,6 @@ stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache] [program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false autorestart = false
startsecs = 0 startsecs = 0
@@ -51,7 +47,6 @@ stderr_logfile_maxbytes = 200MB
[program:reverb] [program:reverb]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 2 priority = 2
autostart = %(ENV_WITH_REVERB)s autostart = %(ENV_WITH_REVERB)s
autorestart = true autorestart = true

View File

@@ -1,6 +1,240 @@
#!/usr/bin/env sh #!/bin/bash
set -e set -e
# ============================================================================
# Solidtime container entrypoint.
#
# Layout:
# 1. Storage tree bootstrap (idempotent, runs as any user)
# 2. UID/GID remap + chown (root only, controlled by PUID/PGID env vars)
# 3. Pre-flight write test (fails fast with a diagnosis message)
# 4. Privilege drop via gosu, then re-exec self as APP_USER
# 5. Original CONTAINER_MODE routing (runs as APP_USER)
#
# Env vars:
# PUID, PGID UID/GID for the application user. Defaults 1000:1000.
# Only takes effect when the container starts as root
# (which is the image's default — if you set a
# `user:` directive in compose, PUID/PGID are ignored
# and a startup warning is printed).
# SOLIDTIME_DROP_PRIVILEGES auto (default) | never
# auto: if started as root, drop privileges to APP_USER; otherwise just exec.
# never: never drop privileges. Run as whatever UID/GID was started.
# ============================================================================
APP_USER="octane"
APP_PATH="${ROOT:-/var/www/html}"
STORAGE_PATH="${APP_PATH}/storage"
CACHE_PATH="${APP_PATH}/bootstrap/cache"
DEFAULT_UID=1000
DEFAULT_GID=1000
TARGET_UID="${PUID:-${DEFAULT_UID}}"
TARGET_GID="${PGID:-${DEFAULT_GID}}"
DROP_PRIVS="${SOLIDTIME_DROP_PRIVILEGES:-auto}"
WRITABLE_PATHS=(
"${STORAGE_PATH}/framework/cache/data"
"${STORAGE_PATH}/framework/sessions"
"${STORAGE_PATH}/framework/views"
"${STORAGE_PATH}/framework/testing"
"${STORAGE_PATH}/logs"
"${STORAGE_PATH}/app/public"
"${STORAGE_PATH}/app/private"
"${CACHE_PATH}"
)
case "${DROP_PRIVS}" in
never) SHOULD_DROP=0 ;;
auto)
if [ "$(id -u)" = "0" ]; then
SHOULD_DROP=1
else
SHOULD_DROP=0
fi
;;
*)
echo "[entrypoint] ERROR: invalid SOLIDTIME_DROP_PRIVILEGES='${DROP_PRIVS}'" >&2
echo "[entrypoint] Valid values: auto (default), never" >&2
exit 1
;;
esac
# Warn if PUID/PGID are set but the container started non-root. PUID/PGID only
# take effect during the drop-privileges flow, which requires starting as root.
# A common cause is leaving `user:` in the compose file alongside PUID env vars.
if { [ -n "${PUID}" ] || [ -n "${PGID}" ]; } \
&& [ "$(id -u)" != "0" ] \
&& [ "${SOLIDTIME_PRIVILEGES_DROPPED:-0}" != "1" ]; then
cat >&2 <<EOF
[entrypoint] WARNING: PUID/PGID is set but the container started as UID $(id -u) (not root).
[entrypoint] WARNING: PUID/PGID only apply when the entrypoint runs as root and drops privileges.
[entrypoint] WARNING:
[entrypoint] WARNING: To use PUID/PGID: remove any 'user:' directive from your compose file.
[entrypoint] WARNING: To run as a fixed UID: remove PUID/PGID from your env.
[entrypoint] WARNING:
[entrypoint] WARNING: Continuing as UID $(id -u). See:
[entrypoint] WARNING: https://docs.solidtime.io/self-hosting/guides/permissions
EOF
fi
bootstrap_storage_tree() {
mkdir -p "${WRITABLE_PATHS[@]}" 2>/dev/null || return 1
}
# Proactive warning when the existing storage directory is owned by a non-default
# UID (typical on NAS systems where host users aren't UID 1000) and PUID/PGID
# aren't set. Without this nudge, the chown step silently re-owns the files to
# 1000:1000 and the user only discovers the mismatch later when host-side tools
# (backup, file browser, rsync) show unfamiliar ownership.
maybe_warn_ownership_mismatch() {
[ "${SHOULD_DROP}" = "1" ] || return 0
[ -n "${PUID}" ] && return 0
[ -n "${PGID}" ] && return 0
[ -d "${STORAGE_PATH}" ] || return 0
local owner_uid owner_gid
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null)" || return 0
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null)" || return 0
# Root-owned: probably freshly created by the entrypoint, will be chowned shortly.
[ "${owner_uid}" = "0" ] && return 0
# Already the target: nothing to warn about.
[ "${owner_uid}" = "${TARGET_UID}" ] && return 0
cat >&2 <<EOF
[entrypoint] NOTE: ${STORAGE_PATH} is owned by UID ${owner_uid}:${owner_gid},
[entrypoint] but the container is starting as UID ${TARGET_UID}:${TARGET_GID}.
[entrypoint] Files will be chowned to ${TARGET_UID}:${TARGET_GID} and may
[entrypoint] appear with an unfamiliar owner on the host.
[entrypoint]
[entrypoint] If you want the container to write as UID ${owner_uid} (common
[entrypoint] on Synology / TrueNAS / Unraid where host users aren't UID
[entrypoint] 1000), set in your env and restart:
[entrypoint]
[entrypoint] PUID=${owner_uid}
[entrypoint] PGID=${owner_gid}
[entrypoint]
[entrypoint] More: https://docs.solidtime.io/self-hosting/guides/permissions
EOF
}
print_write_test_failure() {
local owner
owner="$(stat -c '%u:%g' "${STORAGE_PATH}" 2>/dev/null || echo unknown)"
local runtime_uid
local runtime_gid
if [ "$(id -u)" = "0" ] && [ "${SHOULD_DROP}" = "1" ]; then
runtime_uid="${TARGET_UID}"
runtime_gid="${TARGET_GID}"
else
runtime_uid="$(id -u)"
runtime_gid="$(id -g)"
fi
local owner_uid
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
local owner_gid
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
cat >&2 <<EOF
============================================================
ERROR: Solidtime writable directories are not writable.
Diagnosis:
Container will run as: UID ${runtime_uid}, GID ${runtime_gid}
Storage directory owner: ${owner}
Likely cause: a bind-mounted host directory is owned by a different
user than the container's application user.
Fix on the host:
sudo chown -R ${runtime_uid}:${runtime_gid} <your-bind-mount-path>
Or set PUID/PGID to match the host directory owner:
PUID=${owner_uid}
PGID=${owner_gid}
To run intentionally as root, set:
SOLIDTIME_DROP_PRIVILEGES=never
For more help: https://docs.solidtime.io/self-hosting/guides/permissions
============================================================
EOF
}
write_test_as_user() {
local user="$1"
local script='
set -e
for dir in "$@"; do
test_file="${dir}/.solidtime-write-test"
touch "${test_file}"
rm -f "${test_file}"
done
'
if [ -n "${user}" ]; then
gosu "${user}" sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
else
sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
fi
}
# ----------------------------------------------------------------------------
# Root preamble: bootstrap, remap, chown, write-test, then drop and re-exec.
# ----------------------------------------------------------------------------
if [ "$(id -u)" = "0" ]; then
if ! bootstrap_storage_tree; then
echo "[entrypoint] ERROR: failed to create storage subdirectories at ${STORAGE_PATH}" >&2
exit 1
fi
if [ "${SHOULD_DROP}" = "1" ]; then
maybe_warn_ownership_mismatch
if [ "${TARGET_UID}" != "${DEFAULT_UID}" ] || [ "${TARGET_GID}" != "${DEFAULT_GID}" ]; then
echo "[entrypoint] Remapping ${APP_USER} to ${TARGET_UID}:${TARGET_GID}"
groupmod -o -g "${TARGET_GID}" "${APP_USER}"
usermod -o -u "${TARGET_UID}" "${APP_USER}"
fi
# Idempotent chown: only fix entries whose owner or group is wrong.
# On large storage volumes (lots of user uploads) this is dramatically
# faster than a blanket `chown -R` every restart. Pattern borrowed from
# docker-library/postgres and linuxserver.io's baseimage.
find "${STORAGE_PATH}" "${CACHE_PATH}" \
\( ! -user "${TARGET_UID}" -o ! -group "${TARGET_GID}" \) \
-exec chown "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true
if ! write_test_as_user "${APP_USER}"; then
print_write_test_failure
exit 1
fi
exec gosu "${APP_USER}" env SOLIDTIME_PRIVILEGES_DROPPED=1 "$0" "$@"
fi
if ! write_test_as_user ""; then
print_write_test_failure
exit 1
fi
else
if ! bootstrap_storage_tree; then
echo "[entrypoint] WARNING: could not create some storage subdirectories at ${STORAGE_PATH} (will continue if existing tree is writable)" >&2
fi
if ! write_test_as_user ""; then
print_write_test_failure
exit 1
fi
fi
# ----------------------------------------------------------------------------
# Application: runs as APP_USER (or whatever non-root UID was started).
# ----------------------------------------------------------------------------
unset SOLIDTIME_PRIVILEGES_DROPPED
container_mode=${CONTAINER_MODE:-"http"} container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER} octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false} auto_db_migrate=${AUTO_DB_MIGRATE:-false}
@@ -8,14 +242,16 @@ auto_db_migrate=${AUTO_DB_MIGRATE:-false}
initialStuff() { initialStuff() {
echo "Container mode: $container_mode" echo "Container mode: $container_mode"
if [ ${auto_db_migrate} = "true" ]; then if [ "${auto_db_migrate}" = "true" ]; then
echo "Auto database migration enabled." echo "Auto database migration enabled."
php artisan migrate --isolated --force php artisan migrate --isolated --force
fi fi
php artisan storage:link; \ if [ ! -L "${APP_PATH}/public/storage" ]; then
php artisan optimize:clear; \ php artisan storage:link
php artisan optimize; fi
php artisan optimize:clear
php artisan optimize
} }
if [ "$1" != "" ]; then if [ "$1" != "" ]; then
@@ -23,11 +259,11 @@ if [ "$1" != "" ]; then
elif [ "${container_mode}" = "http" ]; then elif [ "${container_mode}" = "http" ]; then
initialStuff initialStuff
echo "Octane Server: $octane_server" echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then if [ "${octane_server}" = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ "${octane_server}" = "swoole" ]; then elif [ "${octane_server}" = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ "${octane_server}" = "roadrunner" ]; then elif [ "${octane_server}" = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else else
echo "Invalid Octane server supplied." echo "Invalid Octane server supplied."

View File

@@ -1,6 +1,5 @@
[supervisord] [supervisord]
nodaemon = true nodaemon = true
user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid pidfile = /var/run/supervisord.pid

View File

@@ -1,7 +1,6 @@
[program:horizon] [program:horizon]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout

View File

@@ -1,7 +1,6 @@
[program:reverb] [program:reverb]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout

View File

@@ -1,7 +1,6 @@
[program:scheduler] [program:scheduler]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
@@ -12,7 +11,6 @@ stderr_logfile_maxbytes = 0
[program:clear-scheduler-cache] [program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = true autostart = true
autorestart = false autorestart = false
startsecs = 0 startsecs = 0

View File

@@ -1,7 +1,6 @@
[program:worker] [program:worker]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = %(ENV_WORKER_COMMAND)s command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout

View File

@@ -1,158 +0,0 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import { getInvitationAcceptUrl } from './utils/mailpit';
import { registerUser } from './utils/members';
// Invitation acceptance flows touch mail delivery + redirects.
test.describe.configure({ timeout: 45000 });
test.describe('invitation accept banners', () => {
test('shows success banner on dashboard when a logged-in registered user accepts an invitation', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `success+${memberId}@invite-banner.test`;
// Invitee already has an account and is logged in.
const invitee = await registerUser(browser, 'Banner Success', memberEmail);
// Owner sends the invitation.
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
await page.getByLabel('Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Invitee clicks the email link.
const acceptUrl = await getInvitationAcceptUrl(invitee.page.request, memberEmail);
await invitee.page.goto(acceptUrl);
await invitee.page.waitForURL(/\/dashboard$/);
const banner = invitee.page.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(
/Great! You have accepted the invitation to join the .* organization\./
);
await invitee.close();
});
test('shows info banner on login screen when a registered-but-logged-out invitee clicks the accept link', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `loggedout+${memberId}@invite-banner.test`;
// Invitee has an account, but the context that clicks the link has no session.
const invitee = await registerUser(browser, 'Banner Loggedout', memberEmail);
await invitee.close();
// Owner sends the invitation.
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
await page.getByLabel('Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Open the accept link in a fresh browser context (no session).
const context = await browser.newContext();
const inviteePage = await context.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/login$/);
const banner = inviteePage.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(
/Great! You have accepted the invitation to join the .* organization\. Please log in to access it\./
);
// Logging in lands the invitee on the dashboard — they were already added silently
// by the accept controller, so the inviter's members list shows them.
await inviteePage.getByLabel('Email').fill(memberEmail);
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
await inviteePage.getByRole('button', { name: 'Log in' }).click();
await inviteePage.waitForURL(/\/dashboard/);
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Loggedout' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
await context.close();
});
test('shows info banner on register screen when an unregistered email accepts an invitation, then auto-joins on registration', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `info+${memberId}@invite-banner.test`;
// Owner invites an email that has no account yet.
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
await page.getByLabel('Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Open the accept link in a fresh browser context (no session).
const context = await browser.newContext();
const inviteePage = await context.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/register$/);
const banner = inviteePage.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(
/Please create an account to finish joining the .* organization\./
);
// Complete registration — the invitee should auto-join the inviter's org
// (no fresh personal organization is created on top).
await inviteePage.getByLabel('Name').fill('Banner Info');
await inviteePage.getByLabel('Email').fill(memberEmail);
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
await inviteePage.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
await inviteePage.getByLabel('I agree to the Terms of').click();
await inviteePage.getByRole('button', { name: 'Register' }).click();
await inviteePage.waitForURL(/\/dashboard/);
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Info' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
await context.close();
});
});

View File

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

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