mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 18:52:14 +01:00
Compare commits
41 Commits
feature/ex
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ec660243 | ||
|
|
37496a7547 | ||
|
|
8c71a43191 | ||
|
|
f4107a9a49 | ||
|
|
4afcb24dc9 | ||
|
|
f9271664f0 | ||
|
|
5dcd8197dc | ||
|
|
e8c8edb4d5 | ||
|
|
96ecc5335a | ||
|
|
c8024de452 | ||
|
|
eef2cdaec5 | ||
|
|
ed037d40a1 | ||
|
|
6c0041d249 | ||
|
|
e8f632f330 | ||
|
|
a865469d57 | ||
|
|
b18b4ef2ce | ||
|
|
cb42daecbf | ||
|
|
42f9efd570 | ||
|
|
4349b0dfb1 | ||
|
|
dba214f424 | ||
|
|
acb1a07ca0 | ||
|
|
1f4a957258 | ||
|
|
10a66fa065 | ||
|
|
b2a5b7a8c1 | ||
|
|
161cd258f3 | ||
|
|
d6ed3fb3fc | ||
|
|
7b6cdf1d45 | ||
|
|
19e1edca31 | ||
|
|
0335c355cc | ||
|
|
d645588aed | ||
|
|
dc35afdae8 | ||
|
|
12b4633e0d | ||
|
|
002c8e66a0 | ||
|
|
15fe8beecc | ||
|
|
15b5f4b586 | ||
|
|
390acf89e1 | ||
|
|
a54d914f13 | ||
|
|
bde3ec7a75 | ||
|
|
8637437808 | ||
|
|
0f6e0feeae | ||
|
|
08eba2d90b |
24
.github/workflows/build-onpremise.yml
vendored
24
.github/workflows/build-onpremise.yml
vendored
@@ -46,9 +46,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -124,27 +124,27 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -163,7 +163,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -177,25 +177,25 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|||||||
14
.github/workflows/build-private.yml
vendored
14
.github/workflows/build-private.yml
vendored
@@ -33,9 +33,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: version
|
id: version
|
||||||
@@ -160,7 +160,7 @@ jobs:
|
|||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
@@ -168,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
||||||
tags: |
|
tags: |
|
||||||
@@ -179,13 +179,13 @@ jobs:
|
|||||||
type=sha,format=long
|
type=sha,format=long
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push"
|
- name: "Build and push"
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
28
.github/workflows/build-public.yml
vendored
28
.github/workflows/build-public.yml
vendored
@@ -47,9 +47,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -109,34 +109,34 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKERHUB_REPO }}
|
${{ env.DOCKERHUB_REPO }}
|
||||||
${{ env.GHCR_REPO }}
|
${{ env.GHCR_REPO }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub Container Registry"
|
- name: "Login to Docker Hub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -169,31 +169,31 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GHCR"
|
- name: "Login to GHCR"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKERHUB_REPO }}
|
${{ env.DOCKERHUB_REPO }}
|
||||||
|
|||||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
|||||||
run: php artisan scramble:export --path=build/api-docs.json
|
run: php artisan scramble:export --path=build/api-docs.json
|
||||||
|
|
||||||
- name: "Upload API docs to GitHub"
|
- name: "Upload API docs to GitHub"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: api-docs.json
|
name: api-docs.json
|
||||||
path: build/api-docs.json
|
path: build/api-docs.json
|
||||||
|
|||||||
4
.github/workflows/npm-test-unit.yml
vendored
4
.github/workflows/npm-test-unit.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v6
|
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'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v7.0.0
|
uses: codecov/codecov-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
8
.github/workflows/playwright.yml
vendored
8
.github/workflows/playwright.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
|||||||
MAILPIT_BASE_URL: 'http://localhost:8025'
|
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||||
|
|
||||||
- name: "Upload blob report"
|
- name: "Upload blob report"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: "Download blob reports"
|
- name: "Download blob reports"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: all-blob-reports
|
path: all-blob-reports
|
||||||
pattern: blob-report-*
|
pattern: blob-report-*
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||||
|
|
||||||
- name: "Upload merged HTML report"
|
- name: "Upload merged HTML report"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Providers\FortifyServiceProvider;
|
|
||||||
use Illuminate\Auth\Passwords\PasswordBroker;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
class ResetUserPassword implements ResetsUserPasswords
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
@@ -24,16 +20,6 @@ class ResetUserPassword implements ResetsUserPasswords
|
|||||||
*/
|
*/
|
||||||
public function reset(User $user, array $input): void
|
public function reset(User $user, array $input): void
|
||||||
{
|
{
|
||||||
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
|
|
||||||
/** @var PasswordBroker $broker */
|
|
||||||
$broker = Password::broker(config('fortify.passwords'));
|
|
||||||
$broker->deleteToken($user);
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => [__('This password reset link is invalid.')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|||||||
@@ -145,19 +145,9 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSuperAdmin(): bool
|
|
||||||
{
|
|
||||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasLocalPassword(): bool
|
|
||||||
{
|
|
||||||
return is_string($this->password) && $this->password !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return $this->isSuperAdmin();
|
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isMemberOfOrganization(Organization $organization): bool
|
public function isMemberOfOrganization(Organization $organization): bool
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ use Illuminate\Session\Middleware\StartSession;
|
|||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
use Nwidart\Modules\Facades\Module;
|
use Nwidart\Modules\Facades\Module;
|
||||||
use Nwidart\Modules\Laravel\Module as LaravelModule;
|
|
||||||
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
@@ -92,77 +91,22 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
$modules = Module::allEnabled();
|
$modules = Module::allEnabled();
|
||||||
|
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
$moduleNamespace = $this->getModuleAppNamespace($module);
|
|
||||||
|
|
||||||
$panel->discoverResources(
|
$panel->discoverResources(
|
||||||
in: module_path($module->getName(), 'app/Filament/Resources'),
|
in: module_path($module->getName(), 'app/Filament/Resources'),
|
||||||
for: $moduleNamespace.'\\Filament\\Resources'
|
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
|
||||||
);
|
);
|
||||||
|
|
||||||
$panel->discoverPages(
|
$panel->discoverPages(
|
||||||
in: module_path($module->getName(), 'app/Filament/Pages'),
|
in: module_path($module->getName(), 'app/Filament/Pages'),
|
||||||
for: $moduleNamespace.'\\Filament\\Pages'
|
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
|
||||||
);
|
);
|
||||||
|
|
||||||
$panel->discoverWidgets(
|
$panel->discoverWidgets(
|
||||||
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
||||||
for: $moduleNamespace.'\\Filament\\Widgets'
|
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var array<string, string> Cache of module name => resolved app namespace. */
|
|
||||||
private static array $moduleAppNamespaces = [];
|
|
||||||
|
|
||||||
private function getModuleAppNamespace(LaravelModule $module): string
|
|
||||||
{
|
|
||||||
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the PHP namespace mapped to a module's app/ directory so the
|
|
||||||
* Filament panel can discover its Resources/Pages/Widgets under the right
|
|
||||||
* namespace.
|
|
||||||
*
|
|
||||||
* Two module layouts currently coexist in this repo:
|
|
||||||
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
|
|
||||||
* app/ — e.g. "Extensions\SSO\" => app/, so classes are
|
|
||||||
* Extensions\SSO\Filament\... (this is the current convention).
|
|
||||||
* - the older layout: an "...\App" namespace maps to app/ — e.g.
|
|
||||||
* "Extensions\Billing\App\" => app/, so classes are
|
|
||||||
* Extensions\Billing\App\Filament\...
|
|
||||||
*
|
|
||||||
* The package's own namespace derivation assumes the v12 (bare) layout and
|
|
||||||
* would mis-resolve the legacy modules, so we read each module's composer
|
|
||||||
* PSR-4 map and use whichever namespace actually points at app/. The legacy
|
|
||||||
* "...\App" shape is only a fallback for when composer is missing/unreadable.
|
|
||||||
* Once every module adopts the bare layout this collapses to
|
|
||||||
* config('modules.namespace').'\\'.$module->getName().
|
|
||||||
*/
|
|
||||||
private function resolveModuleAppNamespace(LaravelModule $module): string
|
|
||||||
{
|
|
||||||
$fallback = 'Extensions\\'.$module->getName().'\\App';
|
|
||||||
|
|
||||||
$composerPath = module_path($module->getName(), 'composer.json');
|
|
||||||
$psr4 = [];
|
|
||||||
if (is_file($composerPath)) {
|
|
||||||
$composer = json_decode((string) file_get_contents($composerPath), true);
|
|
||||||
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ((array) $psr4 as $namespace => $path) {
|
|
||||||
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
|
|
||||||
return rtrim($namespace, '\\');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeComposerPath(mixed $path): string
|
|
||||||
{
|
|
||||||
return trim(str_replace('\\', '/', (string) $path), '/');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,73 +25,6 @@ use Laravel\Fortify\Fortify;
|
|||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Dummy bcrypt hash compared against when no user matches the submitted
|
|
||||||
* email. Hash::check is run against it so login takes the same time whether
|
|
||||||
* or not the email exists — otherwise an unknown email would skip the
|
|
||||||
* (deliberately slow) hash and return faster, letting an attacker enumerate
|
|
||||||
* registered accounts by timing the response. The plaintext is irrelevant:
|
|
||||||
* it is only ever checked against attacker-supplied input and never matches.
|
|
||||||
*/
|
|
||||||
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization rules applied AFTER the password is verified. Each rule
|
|
||||||
* receives the authenticated user + request and returns whether the login
|
|
||||||
* may proceed; any rule returning false denies it. This is an extension
|
|
||||||
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
|
|
||||||
* instead of replacing this credential check — which would silently drift
|
|
||||||
* from the host logic the next time it changes.
|
|
||||||
*
|
|
||||||
* @var array<int, \Closure(User, Request): bool>
|
|
||||||
*/
|
|
||||||
protected static array $loginRules = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization rules applied before a password reset is completed. Rules
|
|
||||||
* receive the user being reset + submitted input and return whether the
|
|
||||||
* local reset flow may set a new password for that account.
|
|
||||||
*
|
|
||||||
* @var array<int, \Closure(User, array<string, mixed>): bool>
|
|
||||||
*/
|
|
||||||
protected static array $passwordResetRules = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an additional rule that gates password login (see $loginRules).
|
|
||||||
*
|
|
||||||
* @param \Closure(User, Request): bool $rule
|
|
||||||
*/
|
|
||||||
public static function authenticateUsingRule(\Closure $rule): void
|
|
||||||
{
|
|
||||||
static::$loginRules[] = $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an additional rule that gates password reset completion.
|
|
||||||
*
|
|
||||||
* @param \Closure(User, array<string, mixed>): bool $rule
|
|
||||||
*/
|
|
||||||
public static function resetPasswordUsingRule(\Closure $rule): void
|
|
||||||
{
|
|
||||||
static::$passwordResetRules[] = $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether the given user may complete the local password reset flow.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $input
|
|
||||||
*/
|
|
||||||
public static function canResetPassword(User $user, array $input = []): bool
|
|
||||||
{
|
|
||||||
foreach (static::$passwordResetRules as $rule) {
|
|
||||||
if (! $rule($user, $input)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register any application services.
|
* Register any application services.
|
||||||
*/
|
*/
|
||||||
@@ -159,23 +92,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
->where('is_placeholder', '=', false)
|
->where('is_placeholder', '=', false)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
// Always run the hash check — against the real hash, or a dummy when
|
if ($user !== null && Hash::check($request->password, $user->password)) {
|
||||||
// there is no user — so login timing is identical either way (see
|
|
||||||
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
|
|
||||||
// have password = null) fail here, so they cannot password-login.
|
|
||||||
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
|
|
||||||
|
|
||||||
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
|
|
||||||
|
|
||||||
if ($user !== null && $passwordIsValid) {
|
|
||||||
// Credentials are valid; now apply any registered authorization
|
|
||||||
// rules (e.g. SSO enforcement may still block password login).
|
|
||||||
foreach (static::$loginRules as $rule) {
|
|
||||||
if (! $rule($user, $request)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,56 +48,6 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$this->createDefaultOrganizationForUser(
|
|
||||||
$user,
|
|
||||||
$currency,
|
|
||||||
$numberFormat,
|
|
||||||
$currencyFormat,
|
|
||||||
$dateFormat,
|
|
||||||
$intervalFormat,
|
|
||||||
$timeFormat,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a user without a password (e.g. provisioned via SSO). Such users
|
|
||||||
* can only authenticate through a linked identity provider.
|
|
||||||
*/
|
|
||||||
public function createPasswordlessUser(
|
|
||||||
string $name,
|
|
||||||
string $email,
|
|
||||||
string $timezone,
|
|
||||||
Weekday $weekStart,
|
|
||||||
?string $currency,
|
|
||||||
bool $verifyEmail = false
|
|
||||||
): User {
|
|
||||||
$user = new User;
|
|
||||||
$user->name = $name;
|
|
||||||
$user->email = strtolower($email);
|
|
||||||
$user->password = null;
|
|
||||||
$user->timezone = $timezone;
|
|
||||||
$user->week_start = $weekStart;
|
|
||||||
if ($verifyEmail) {
|
|
||||||
$user->email_verified_at = Carbon::now();
|
|
||||||
}
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
$this->createDefaultOrganizationForUser($user, $currency);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createDefaultOrganizationForUser(
|
|
||||||
User $user,
|
|
||||||
?string $currency,
|
|
||||||
?NumberFormat $numberFormat = null,
|
|
||||||
?CurrencyFormat $currencyFormat = null,
|
|
||||||
?DateFormat $dateFormat = null,
|
|
||||||
?IntervalFormat $intervalFormat = null,
|
|
||||||
?TimeFormat $timeFormat = null,
|
|
||||||
): void {
|
|
||||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
|
|
||||||
if ($organizations->isEmpty()) {
|
if ($organizations->isEmpty()) {
|
||||||
@@ -114,6 +64,8 @@ class UserService
|
|||||||
);
|
);
|
||||||
$this->switchCurrentOrganization($user, $organization);
|
$this->switchCurrentOrganization($user, $organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,16 +7,10 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
|||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
|
|
||||||
withDefaults(
|
defineProps({
|
||||||
defineProps<{
|
canResetPassword: Boolean,
|
||||||
canResetPassword?: boolean;
|
status: String,
|
||||||
status?: string;
|
});
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
canResetPassword: false,
|
|
||||||
status: '',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -34,8 +28,8 @@ const submit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
flash?: {
|
flash: {
|
||||||
message?: string;
|
message: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -67,9 +61,6 @@ const page = usePage<{
|
|||||||
{{ page.props.flash?.message }}
|
{{ page.props.flash?.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
|
|
||||||
<slot name="error" />
|
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="email">Email</FieldLabel>
|
<FieldLabel for="email">Email</FieldLabel>
|
||||||
@@ -112,8 +103,5 @@ const page = usePage<{
|
|||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
|
|
||||||
<slot name="alternatives" />
|
|
||||||
</AuthenticationCard>
|
</AuthenticationCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -43,17 +43,17 @@ onMounted(async () => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||||
|
|
||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
<SectionBorder />
|
||||||
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
|
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
|
||||||
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
|
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
|
||||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
<template v-if="permissions.canDeleteTeam">
|
<template v-if="permissions.canDeleteTeam">
|
||||||
<SectionBorder />
|
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,73 +10,35 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
|||||||
import { type DefineComponent } from 'vue';
|
import { type DefineComponent } from 'vue';
|
||||||
import { setupPrefetching } from '@/utils/prefetch';
|
import { setupPrefetching } from '@/utils/prefetch';
|
||||||
|
|
||||||
interface ExtensionManifest {
|
|
||||||
name?: string;
|
|
||||||
alias?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
|
|
||||||
eager: true,
|
|
||||||
import: 'default',
|
|
||||||
}) as Record<string, ExtensionManifest>;
|
|
||||||
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
|
|
||||||
const extensionPages = import.meta.glob<DefineComponent>([
|
|
||||||
'../../extensions/**/resources/js/Pages/**/*.vue',
|
|
||||||
'!**/BillingPortal.vue',
|
|
||||||
]);
|
|
||||||
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
|
|
||||||
(directories, [path, manifest]) => {
|
|
||||||
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
|
|
||||||
const extensionDirectory = match?.[1];
|
|
||||||
|
|
||||||
if (extensionDirectory === undefined) {
|
|
||||||
return directories;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
|
|
||||||
if (typeof key !== 'string' || key === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
directories[key] = extensionDirectory;
|
|
||||||
directories[key.toLowerCase()] = extensionDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
return directories;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
function resolveExtensionDirectory(moduleName: string): string {
|
|
||||||
return (
|
|
||||||
extensionDirectories[moduleName] ??
|
|
||||||
extensionDirectories[moduleName.toLowerCase()] ??
|
|
||||||
moduleName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => `${title} - ${appName}`,
|
title: (title) => `${title} - ${appName}`,
|
||||||
resolve: (name) => {
|
resolve: (name) => {
|
||||||
// "Module::Page" (both halves present) resolves to that extension's page
|
if (name.includes('Invoicing::')) {
|
||||||
// directory; everything else is a host page under resources/js/Pages.
|
const [module, page] = name.split('::');
|
||||||
const [module, ...pageSegments] = name.split('::');
|
|
||||||
const page = pageSegments.join('::');
|
|
||||||
|
|
||||||
if (module && page) {
|
const pagePath = module
|
||||||
const extensionDirectory = resolveExtensionDirectory(module);
|
? `../../extensions/${module}/resources/js/Pages/${page}.vue`
|
||||||
const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
|
: `./Pages/${page}.vue`;
|
||||||
|
|
||||||
return resolvePageComponent(pagePath, extensionPages);
|
// BillingPortal is a Vue 2 Component and therefore should not be imported
|
||||||
|
const pages = module
|
||||||
|
? import.meta.glob<DefineComponent>([
|
||||||
|
'../../extensions/**/resources/js/Pages/*.vue',
|
||||||
|
'!**/BillingPortal.vue',
|
||||||
|
])
|
||||||
|
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
|
||||||
|
|
||||||
|
return resolvePageComponent(pagePath, pages);
|
||||||
|
} else {
|
||||||
|
return resolvePageComponent(
|
||||||
|
`./Pages/${name}.vue`,
|
||||||
|
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvePageComponent(
|
|
||||||
`./Pages/${name}.vue`,
|
|
||||||
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
setup({ el, App, props, plugin }) {
|
setup({ el, App, props, plugin }) {
|
||||||
const app = createApp({ render: () => h(App, props) });
|
const app = createApp({ render: () => h(App, props) });
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service;
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
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\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
@@ -137,60 +135,4 @@ class UserServiceTest extends TestCase
|
|||||||
$this->assertSame(Role::Owner->value, $newMember->role);
|
$this->assertSame(Role::Owner->value, $newMember->role);
|
||||||
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
|
|
||||||
{
|
|
||||||
// Arrange — an accepted invitation exists for the email (e.g. the user
|
|
||||||
// followed the invite link, then signs up via SSO). Casing differs to
|
|
||||||
// prove the email is normalised before the invitation is matched.
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
OrganizationInvitation::factory()
|
|
||||||
->forOrganization($organization)
|
|
||||||
->role(Role::Employee)
|
|
||||||
->accepted()
|
|
||||||
->create([
|
|
||||||
'email' => 'invitee@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$user = $this->userService->createPasswordlessUser(
|
|
||||||
'Invitee',
|
|
||||||
'Invitee@Example.com',
|
|
||||||
'UTC',
|
|
||||||
Weekday::Monday,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert — invitation is materialised, no personal organization is created
|
|
||||||
$this->assertNull($user->password);
|
|
||||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
|
||||||
'email' => 'invitee@example.com',
|
|
||||||
]);
|
|
||||||
$user->refresh();
|
|
||||||
$this->assertSame(1, $user->organizations()->count());
|
|
||||||
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
|
|
||||||
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
|
||||||
$this->assertSame(Role::Employee->value, $member->role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
$user = $this->userService->createPasswordlessUser(
|
|
||||||
'Solo User',
|
|
||||||
'solo@example.com',
|
|
||||||
'UTC',
|
|
||||||
Weekday::Monday,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert — a personal organization is created, owned by the user and set current
|
|
||||||
$user->refresh();
|
|
||||||
$this->assertNull($user->password);
|
|
||||||
$this->assertSame(1, $user->organizations()->count());
|
|
||||||
$organization = $user->organizations()->first();
|
|
||||||
$this->assertTrue($organization->personal_team);
|
|
||||||
$this->assertSame($user->getKey(), $organization->user_id);
|
|
||||||
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ async function getConfig() {
|
|||||||
const additionalPlugins = await collectModulePlugins('extensions');
|
const additionalPlugins = await collectModulePlugins('extensions');
|
||||||
|
|
||||||
return defineConfig({
|
return defineConfig({
|
||||||
base: './',
|
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true, // Source map generation must be turned on
|
sourcemap: true, // Source map generation must be turned on
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user