mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 05:22:44 +01:00
Compare commits
77 Commits
v0.12.0
...
f826474f88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f826474f88 | ||
|
|
98bbe800f1 | ||
|
|
7035d5fd6e | ||
|
|
f32ec59bb5 | ||
|
|
d2b6be137f | ||
|
|
dc082b2b19 | ||
|
|
82ad8ee316 | ||
|
|
117c3c4b6c | ||
|
|
4c2586936d | ||
|
|
ca843168f6 | ||
|
|
67dcf77635 | ||
|
|
dcd21345b2 | ||
|
|
1f832a24a0 | ||
|
|
07cf3f7405 | ||
|
|
a880ccb32c | ||
|
|
5a41c356d4 | ||
|
|
72bddfba8b | ||
|
|
34a1a89c30 | ||
|
|
77e4d768d4 | ||
|
|
d42e3ffff0 | ||
|
|
4e26c8ad6d | ||
|
|
57794940f1 | ||
|
|
09827d3d83 | ||
|
|
64c5da5223 | ||
|
|
983e6c3815 | ||
|
|
f34b60874e | ||
|
|
8eab0485c9 | ||
|
|
0aa0f0bd77 | ||
|
|
eb63c4ef03 | ||
|
|
54fffd07bc | ||
|
|
da235dfdc8 | ||
|
|
0debdddef9 | ||
|
|
62354cfe8b | ||
|
|
396e7b2b6b | ||
|
|
221889ff87 | ||
|
|
7ce3fa2740 | ||
|
|
df34014bfe | ||
|
|
faf3ee471c | ||
|
|
866e5d8594 | ||
|
|
72cd0b6f05 | ||
|
|
6d93e48b1d | ||
|
|
09af0f775f | ||
|
|
1cc000a584 | ||
|
|
1a754f6756 | ||
|
|
d69d25d059 | ||
|
|
0e15d9d9c2 | ||
|
|
7d9ecd9526 | ||
|
|
3a17f80f99 | ||
|
|
e29ea2ea42 | ||
|
|
fb6e4639ce | ||
|
|
69bc41988a | ||
|
|
f7663b1c8b | ||
|
|
793bd11dcf | ||
|
|
77a62afd69 | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c |
2
.env.ci
2
.env.ci
@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
GOTENBERG_URL=http://localhost:3000
|
||||
|
||||
# Octane
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
||||
@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
# Octane
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
||||
# Local setup
|
||||
NGINX_HOST_NAME=solidtime.test
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
|
||||
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
||||
10
.github/workflows/build-private.yml
vendored
10
.github/workflows/build-private.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
@@ -68,12 +68,12 @@ jobs:
|
||||
run: cat .env
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Checkout billing extension"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: solidtime-io/extension-billing
|
||||
path: extensions/Billing
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
run: cd extensions/Billing && npm ci
|
||||
|
||||
- name: "Checkout services extension"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: solidtime-io/extension-services
|
||||
path: extensions/Services
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: solidtime-io/extension-invoicing
|
||||
path: extensions/Invoicing
|
||||
|
||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
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
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Setup PHP (for Ziggy)"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
27
.github/workflows/npm-test-unit.yml
vendored
Normal file
27
.github/workflows/npm-test-unit.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Setup PHP (for Ziggy)"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
uses: codecov/codecov-action@v5.5.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Check code style"
|
||||
uses: aglipanci/laravel-pint-action@2.5
|
||||
uses: aglipanci/laravel-pint-action@2.6
|
||||
with:
|
||||
configPath: "pint.json"
|
||||
|
||||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ yarn-error.log
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
/AGENTS.md
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# solidtime - The modern Open-Source Time Tracker
|
||||
# solidtime - The modern Open-Source TimeTracker
|
||||
|
||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@@ -3,3 +3,18 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
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)
|
||||
|
||||
@@ -16,7 +16,6 @@ use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Log;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
@@ -55,7 +54,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
'terms' => ['accepted', 'required'],
|
||||
'newsletter_consent' => [
|
||||
'boolean',
|
||||
],
|
||||
|
||||
@@ -4,13 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
@@ -24,56 +20,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
'nullable',
|
||||
'mimes:jpg,jpeg,png',
|
||||
'max:1024',
|
||||
],
|
||||
'timezone' => [
|
||||
'required',
|
||||
'timezone:all',
|
||||
],
|
||||
'week_start' => [
|
||||
'required',
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if (isset($input['photo'])) {
|
||||
$user->updateProfilePhoto($input['photo']);
|
||||
}
|
||||
|
||||
if ($input['email'] !== $user->email) {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
'timezone' => $input['timezone'],
|
||||
'week_start' => $input['week_start'],
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'timezone' => $input['timezone'],
|
||||
'week_start' => $input['week_start'],
|
||||
])->save();
|
||||
}
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
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;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
{
|
||||
/**
|
||||
* Add a new team member to the given team.
|
||||
*/
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
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.')
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\CreatesTeams;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateOrganization implements CreatesTeams
|
||||
{
|
||||
/**
|
||||
* Validate and create a new team for the given user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(User $user, array $input): Organization
|
||||
{
|
||||
Gate::forUser($user)->authorize('create', Jetstream::newTeamModel());
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$input['name'],
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
||||
|
||||
class DeleteOrganization implements DeletesTeams
|
||||
{
|
||||
/**
|
||||
* Delete the given team.
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
/** @see ValidateOrganizationDeletion */
|
||||
app(DeletionService::class)->deleteOrganization($organization);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||
|
||||
class DeleteUser implements DeletesUsers
|
||||
{
|
||||
/**
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
try {
|
||||
app(DeletionService::class)->deleteUser($user);
|
||||
} catch (ApiException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => $exception->getTranslatedMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class InviteOrganizationMember implements InvitesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Invite a new team member to the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
||||
|
||||
class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Remove the team member from the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
|
||||
class UpdateMemberRole
|
||||
{
|
||||
/**
|
||||
* Update the role for the given team member.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Rules\CurrencyRule;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\UpdatesTeamNames;
|
||||
|
||||
class UpdateOrganization implements UpdatesTeamNames
|
||||
{
|
||||
/**
|
||||
* Validate and update the given team's name.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, Organization $organization, array $input): void
|
||||
{
|
||||
Gate::forUser($user)->authorize('update', $organization);
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule,
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
$organization->forceFill([
|
||||
'name' => $input['name'],
|
||||
'currency' => $input['currency'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
class ValidateOrganizationDeletion
|
||||
{
|
||||
/**
|
||||
* Validate that the team can be deleted by the given user.
|
||||
*
|
||||
* @param User $user Authenticated user
|
||||
* @param Organization $organization Organization to be deleted
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ class UserCreateCommand extends Command
|
||||
);
|
||||
});
|
||||
/** @var Organization|null $organization */
|
||||
$organization = $user->ownedTeams->first();
|
||||
$organization = $user->ownedOrganizations->first();
|
||||
if ($organization === null) {
|
||||
throw new LogicException('User does not have an organization');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum Role: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Owner = 'owner';
|
||||
case Admin = 'admin';
|
||||
case Manager = 'manager';
|
||||
|
||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberAdded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public User $user;
|
||||
|
||||
public function __construct(Member $member, Organization $organization, User $user)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
$this->user = $user;
|
||||
}
|
||||
}
|
||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberAdding
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public User $user;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Role $role;
|
||||
|
||||
public function __construct(User $user, Organization $organization, Role $role)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->organization = $organization;
|
||||
$this->role = $role;
|
||||
}
|
||||
}
|
||||
35
app/Events/OrganizationInvitationAdding.php
Normal file
35
app/Events/OrganizationInvitationAdding.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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 OrganizationInvitationAdding
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public string $email;
|
||||
|
||||
public Role $role;
|
||||
|
||||
public User $inviter;
|
||||
|
||||
public function __construct(
|
||||
Organization $organization,
|
||||
string $email,
|
||||
Role $role,
|
||||
User $inviter
|
||||
) {
|
||||
$this->role = $role;
|
||||
$this->email = $email;
|
||||
$this->organization = $organization;
|
||||
$this->inviter = $inviter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||
}
|
||||
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
|
||||
TextInput::make('queue')->disabled(),
|
||||
|
||||
// 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),
|
||||
])->columns(4);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class OrganizationInvitationResource extends Resource
|
||||
->required(),
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
Select::make('organization_id')
|
||||
->label('Organization')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
|
||||
@@ -55,7 +55,7 @@ class OrganizationResource extends Resource
|
||||
->label('Is personal?')
|
||||
->hiddenOn(['create'])
|
||||
->required(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
Select::make('user_id')
|
||||
->label('Owner')
|
||||
->relationship(name: 'owner', titleAttribute: 'email')
|
||||
->searchable(['name', 'email'])
|
||||
@@ -76,7 +76,7 @@ class OrganizationResource extends Resource
|
||||
Select::make('time_format')
|
||||
->options(TimeFormat::toSelectArray())
|
||||
->required(),
|
||||
Forms\Components\Select::make('currency')
|
||||
Select::make('currency')
|
||||
->label('Currency')
|
||||
->options(function (): array {
|
||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||
@@ -114,22 +114,22 @@ class OrganizationResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('personal_team')
|
||||
->boolean()
|
||||
->label('Is personal?')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('owner.email')
|
||||
TextColumn::make('owner.email')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency'),
|
||||
TextColumn::make('currency'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@@ -223,7 +223,7 @@ class OrganizationResource extends Resource
|
||||
|
||||
return $select;
|
||||
}),
|
||||
Forms\Components\Select::make('timezone')
|
||||
Select::make('timezone')
|
||||
->label('Timezone')
|
||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||
->searchable()
|
||||
|
||||
@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class InvitationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'teamInvitations';
|
||||
protected static string $relationship = 'organizationInvitations';
|
||||
|
||||
protected static ?string $title = 'Invitations';
|
||||
|
||||
@@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager
|
||||
$ownerRecord = $this->getOwnerRecord();
|
||||
|
||||
return app(InvitationService::class)
|
||||
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
|
||||
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
|
||||
@@ -49,13 +49,13 @@ class UsersRelationManager extends RelationManager
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
TextColumn::make('name'),
|
||||
TextColumn::make('role'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money($organization->currency, divideBy: 100),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()
|
||||
AttachAction::make()
|
||||
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||
->form(fn (AttachAction $action): array => [
|
||||
$action->getRecordSelect(),
|
||||
|
||||
@@ -63,11 +63,11 @@ class ReportResource extends Resource
|
||||
return $record->getRawOriginal('properties');
|
||||
})
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
@@ -78,10 +78,10 @@ class ReportResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
TextColumn::make('description')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
ToggleColumn::make('is_public')
|
||||
@@ -90,10 +90,10 @@ class ReportResource extends Resource
|
||||
TextColumn::make('organization.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
@@ -93,11 +93,11 @@ class TimeEntryResource extends Resource
|
||||
($record->end?->toDateTimeString('minute') ?? '...').')';
|
||||
})
|
||||
->label('Time'),
|
||||
Tables\Columns\TextColumn::make('organization.name')
|
||||
TextColumn::make('organization.name')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
TextColumn::make('created_at')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
TextColumn::make('updated_at')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\TimezoneService;
|
||||
use App\Service\UserService;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Exception;
|
||||
use Filament\Forms;
|
||||
@@ -47,17 +48,17 @@ class UserResource extends Resource
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('id')
|
||||
TextInput::make('id')
|
||||
->label('ID')
|
||||
->disabled()
|
||||
->visibleOn(['update', 'show'])
|
||||
->readOnly()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('name')
|
||||
TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('email')
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->required()
|
||||
->rules($record?->is_placeholder ? [] : [
|
||||
@@ -179,7 +180,7 @@ class UserResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Impersonate::make()->before(function (User $record): void {
|
||||
if ($record->currentTeam === null) {
|
||||
if ($record->currentOrganization === null) {
|
||||
$organization = $record->organizations()->where('personal_team', '=', true)->first();
|
||||
if ($organization === null) {
|
||||
$organization = $record->organizations()->first();
|
||||
@@ -187,8 +188,7 @@ class UserResource extends Resource
|
||||
if ($organization === null) {
|
||||
throw new Exception('User has no organization');
|
||||
}
|
||||
$record->currentTeam()->associate($organization);
|
||||
$record->save();
|
||||
app(UserService::class)->switchCurrentOrganization($record, $organization);
|
||||
}
|
||||
}),
|
||||
Tables\Actions\EditAction::make(),
|
||||
|
||||
@@ -16,7 +16,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static ?string $title = 'Owned Organizations';
|
||||
|
||||
protected static string $relationship = 'ownedTeams';
|
||||
protected static string $relationship = 'ownedOrganizations';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
||||
/**
|
||||
* List all api token of the currently authenticated user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getApiTokens
|
||||
*
|
||||
|
||||
@@ -40,7 +40,7 @@ class InvitationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:view');
|
||||
|
||||
$invitations = $organization->teamInvitations()
|
||||
$invitations = $organization->organizationInvitations()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
@@ -63,7 +63,7 @@ class InvitationController extends Controller
|
||||
$email = $request->getEmail();
|
||||
$role = $request->getRole();
|
||||
|
||||
$invitationService->inviteUser($organization, $email, $role);
|
||||
$invitationService->inviteUser($organization, $email, $role, $this->user());
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ class MemberController extends Controller
|
||||
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
}
|
||||
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user());
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
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\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
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\Http\JsonResponse;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
@@ -80,4 +87,46 @@ class OrganizationController extends Controller
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
|
||||
class TimeEntryController extends Controller
|
||||
{
|
||||
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
||||
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
|
||||
{
|
||||
if (! $organization->prevent_overlapping_time_entries) {
|
||||
return;
|
||||
@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||
$this->checkPermission($organization, 'time-entries:update:own');
|
||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:update:all');
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
}
|
||||
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||
|
||||
33
app/Http/Controllers/Api/V1/TimeZoneController.php
Normal file
33
app/Http/Controllers/Api/V1/TimeZoneController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TimeZoneController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get all timezones
|
||||
*
|
||||
* @response object{key: string}[]
|
||||
*
|
||||
* @operationId getTimezones
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$timezones = app(TimezoneService::class)->getTimezones();
|
||||
|
||||
$response = [];
|
||||
|
||||
foreach ($timezones as $timezone) {
|
||||
$response[] = (object) [
|
||||
'key' => $timezone,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,29 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
|
||||
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use App\Mail\VerifyUpdatedEmailMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\UserService;
|
||||
use App\Support\Base64File;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMe
|
||||
*
|
||||
@@ -24,4 +38,169 @@ class UserController extends Controller
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current organization of the current user
|
||||
*
|
||||
* Switches the organization that the user is currently working in. The user
|
||||
* must be a member of the given organization. This endpoint is independent of
|
||||
* the organization.
|
||||
*
|
||||
* @operationId updateMyCurrentOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function updateMyCurrentOrganization(UserUpdateCurrentOrganizationRequest $request, UserService $userService): UserResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
/** @var Organization|null $organization */
|
||||
$organization = $user->organizations()
|
||||
->whereKey($request->getOrganizationId())
|
||||
->first();
|
||||
|
||||
if ($organization === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$userService->switchCurrentOrganization($user, $organization);
|
||||
|
||||
return new UserResource($user->refresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user
|
||||
*
|
||||
* 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('filesystems.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
|
||||
/**
|
||||
* Get the memberships of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMyMemberships
|
||||
*
|
||||
|
||||
@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
|
||||
/**
|
||||
* Get the active time entry of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMyActiveTimeEntry
|
||||
*/
|
||||
|
||||
@@ -59,7 +59,7 @@ class Controller extends BaseController
|
||||
protected function currentOrganization(): Organization
|
||||
{
|
||||
$user = $this->user();
|
||||
$organization = $user->currentTeam;
|
||||
$organization = $user->currentOrganization;
|
||||
if ($organization === null) {
|
||||
$organization = $user->organizations()->first();
|
||||
}
|
||||
|
||||
@@ -4,4 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
abstract class Controller extends \App\Http\Controllers\Controller {}
|
||||
use App\Models\Organization;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
abstract class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
{
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
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\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
||||
public function dashboard(): 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');
|
||||
}
|
||||
}
|
||||
|
||||
69
app/Http/Controllers/Web/OrganizationController.php
Normal file
69
app/Http/Controllers/Web/OrganizationController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the team creation screen.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Teams/Create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the organizatio details screen.
|
||||
*
|
||||
* @param string $organizationId The organization ID
|
||||
*/
|
||||
public function show(string $organizationId): Response|RedirectResponse
|
||||
{
|
||||
$organization = Str::isUuid($organizationId) ? Organization::find($organizationId) : null;
|
||||
if ($organization === null) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
if (! $this->hasPermission($organization, 'organizations:view')) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$owner = $organization->owner;
|
||||
|
||||
return Inertia::render('Teams/Show', [
|
||||
'team' => [
|
||||
'id' => $organization->getKey(),
|
||||
'name' => $organization->name,
|
||||
'currency' => $organization->currency,
|
||||
'owner' => [
|
||||
'id' => $owner->getKey(),
|
||||
'name' => $owner->name,
|
||||
'profile_photo_url' => $owner->profile_photo_url,
|
||||
],
|
||||
],
|
||||
'currencies' => array_map(function (Currency $currency): string {
|
||||
return $currency->getName();
|
||||
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
|
||||
'availableRoles' => [],
|
||||
'availablePermissions' => [],
|
||||
'defaultPermissions' => [],
|
||||
'permissions' => [
|
||||
'canAddTeamMembers' => true,
|
||||
'canDeleteTeam' => true,
|
||||
'canRemoveTeamMembers' => true,
|
||||
'canUpdateTeam' => true,
|
||||
'canUpdateTeamMembers' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use RuntimeException;
|
||||
|
||||
class OrganizationInvitationController extends Controller
|
||||
{
|
||||
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
|
||||
{
|
||||
$email = strtolower($invitation->email);
|
||||
$role = Role::tryFrom($invitation->role);
|
||||
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
|
||||
throw new RuntimeException('Invalid role');
|
||||
}
|
||||
|
||||
$organization = $invitation->organization;
|
||||
$invitee = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->first();
|
||||
|
||||
// No account yet — finish on registration.
|
||||
if ($invitee === null) {
|
||||
if ($invitation->accepted_at === null) {
|
||||
$invitation->accepted_at = now();
|
||||
$invitation->save();
|
||||
}
|
||||
|
||||
return redirect(route('register'))
|
||||
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'info');
|
||||
}
|
||||
|
||||
$alreadyMember = $memberService->isEmailAlreadyMember($organization, $email);
|
||||
if (! $alreadyMember) {
|
||||
$memberService->addMember($invitee, $organization, $role);
|
||||
$invitation->delete();
|
||||
}
|
||||
|
||||
// Logged out — banner on /login.
|
||||
if (! Auth::check()) {
|
||||
return redirect(route('login'))
|
||||
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization. Please log in to access it.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'success');
|
||||
}
|
||||
|
||||
// Logged in — banner on /dashboard.
|
||||
if ($alreadyMember) {
|
||||
return redirect(route('dashboard'))
|
||||
->with('bannerText', __('You are already a member of the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'danger');
|
||||
}
|
||||
|
||||
return redirect(route('dashboard'))
|
||||
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'success');
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Web/UserController.php
Normal file
53
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function verifyEmailChange(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$email = $request->query('email');
|
||||
if (! is_string($email)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
|
||||
if ($user->pending_email !== $email) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$emailAlreadyInUse = User::query()
|
||||
->where('email', '=', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->whereKeyNot($user->getKey())
|
||||
->exists();
|
||||
|
||||
if ($emailAlreadyInUse) {
|
||||
return redirect(route('dashboard'))
|
||||
->with('bannerStyle', 'danger')
|
||||
->with('bannerText', __('The email address is already in use.'));
|
||||
}
|
||||
|
||||
$user->email = $email;
|
||||
$user->pending_email = null;
|
||||
$user->email_verified_at = Carbon::now();
|
||||
$user->save();
|
||||
|
||||
return redirect(route('dashboard'))
|
||||
->with('bannerStyle', 'success')
|
||||
->with('bannerText', __('Your email address has been updated successfully.'));
|
||||
}
|
||||
}
|
||||
142
app/Http/Controllers/Web/UserProfileController.php
Normal file
142
app/Http/Controllers/Web/UserProfileController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Service\Dto\UserAgentDto;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
class UserProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Validate the two-factor authentication state for the request.
|
||||
*/
|
||||
protected function validateTwoFactorAuthenticationState(Request $request): void
|
||||
{
|
||||
if (! Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentTime = time();
|
||||
|
||||
// Notate totally disabled state in session...
|
||||
if ($this->twoFactorAuthenticationDisabled($request)) {
|
||||
$request->session()->put('two_factor_empty_at', $currentTime);
|
||||
}
|
||||
|
||||
// If was previously totally disabled this session but is now confirming, notate time...
|
||||
if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) {
|
||||
$request->session()->put('two_factor_confirming_at', $currentTime);
|
||||
}
|
||||
|
||||
// If the profile is reloaded and is not confirmed but was previously in confirming state, disable...
|
||||
if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) {
|
||||
app(DisableTwoFactorAuthentication::class)(Auth::user());
|
||||
|
||||
$request->session()->put('two_factor_empty_at', $currentTime);
|
||||
$request->session()->remove('two_factor_confirming_at');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if two-factor authentication is totally disabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function twoFactorAuthenticationDisabled(Request $request)
|
||||
{
|
||||
return is_null($request->user()->two_factor_secret) &&
|
||||
is_null($request->user()->two_factor_confirmed_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if two-factor authentication is just now being confirmed within the last request cycle.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request)
|
||||
{
|
||||
return ! is_null($request->user()->two_factor_secret) &&
|
||||
is_null($request->user()->two_factor_confirmed_at) &&
|
||||
$request->session()->has('two_factor_empty_at') &&
|
||||
is_null($request->session()->get('two_factor_confirming_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if two-factor authentication was never totally confirmed once confirmation started.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime)
|
||||
{
|
||||
return ! array_key_exists('code', $request->session()->getOldInput()) &&
|
||||
is_null($request->user()->two_factor_confirmed_at) &&
|
||||
$request->session()->get('two_factor_confirming_at', 0) !== $currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the general profile settings screen.
|
||||
*/
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$this->validateTwoFactorAuthenticationState($request);
|
||||
|
||||
return Inertia::render('Profile/Show', [
|
||||
'timezones' => app(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||
'sessions' => $this->sessions($request),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current sessions.
|
||||
*
|
||||
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&\stdClass>
|
||||
*/
|
||||
public function sessions(Request $request): array
|
||||
{
|
||||
if (config('session.driver') !== 'database') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(
|
||||
DB::connection(config('session.connection'))->table(config('session.table', 'sessions'))
|
||||
->where('user_id', $request->user()->getAuthIdentifier())
|
||||
->orderBy('last_activity', 'desc')
|
||||
->get()
|
||||
)->map(function (object $session) use ($request): object {
|
||||
$agent = $this->createAgent(is_string($session->user_agent) ? $session->user_agent : '');
|
||||
|
||||
return (object) [
|
||||
'agent' => [
|
||||
'is_desktop' => $agent->isDesktop(),
|
||||
'platform' => $agent->platform(),
|
||||
'browser' => $agent->browser(),
|
||||
],
|
||||
'ip_address' => is_string($session->ip_address) ? $session->ip_address : '',
|
||||
'is_current_device' => $session->id === $request->session()->getId(),
|
||||
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent instance from the given session.
|
||||
*/
|
||||
protected function createAgent(string $userAgent): UserAgentDto
|
||||
{
|
||||
return tap(new UserAgentDto, fn ($agent) => $agent->setUserAgent($userAgent));
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Http\Middleware\Authenticate;
|
||||
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\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\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
|
||||
{
|
||||
@@ -18,13 +46,13 @@ class Kernel extends HttpKernel
|
||||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
\App\Http\Middleware\ForceHttps::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
ForceHttps::class,
|
||||
TrustProxies::class,
|
||||
HandleCors::class,
|
||||
PreventRequestsDuringMaintenance::class,
|
||||
ValidatePostSize::class,
|
||||
TrimStrings::class,
|
||||
ConvertEmptyStringsToNull::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -34,21 +62,21 @@ class Kernel extends HttpKernel
|
||||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
\App\Http\Middleware\ShareInertiaData::class,
|
||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
HandleInertiaRequests::class,
|
||||
ShareInertiaData::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
CreateFreshApiToken::class,
|
||||
],
|
||||
|
||||
'api' => [
|
||||
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
ThrottleRequests::class.':api',
|
||||
SubstituteBindings::class,
|
||||
ForceJsonResponse::class,
|
||||
],
|
||||
|
||||
@@ -64,17 +92,17 @@ class Kernel extends HttpKernel
|
||||
* @var array<string, class-string|string>
|
||||
*/
|
||||
protected $middlewareAliases = [
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
||||
'auth' => Authenticate::class,
|
||||
'auth.basic' => AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => AuthenticateSession::class,
|
||||
'cache.headers' => SetCacheHeaders::class,
|
||||
'can' => Authorize::class,
|
||||
'guest' => RedirectIfAuthenticated::class,
|
||||
'password.confirm' => RequirePassword::class,
|
||||
'precognitive' => HandlePrecognitiveRequests::class,
|
||||
'signed' => ValidateSignature::class,
|
||||
'throttle' => ThrottleRequests::class,
|
||||
'verified' => EnsureEmailIsVerified::class,
|
||||
'check-organization-blocked' => CheckOrganizationBlocked::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ForceHttps
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ class ForceJsonResponse
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ class HandleInertiaRequests extends Middleware
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
|
||||
$currentOrganization = $request->user()?->currentTeam;
|
||||
$currentOrganization = $request->user()?->currentOrganization;
|
||||
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
'bannerText' => fn () => $request->session()->get('bannerText'),
|
||||
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class RedirectIfAuthenticated
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
|
||||
@@ -9,12 +9,10 @@ use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShareInertiaData
|
||||
@@ -27,28 +25,8 @@ class ShareInertiaData
|
||||
/** @var PermissionStore $permissions */
|
||||
$permissions = app(PermissionStore::class);
|
||||
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' => [
|
||||
'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
|
||||
'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [],
|
||||
'user' => function () use ($request): array {
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
@@ -57,6 +35,8 @@ class ShareInertiaData
|
||||
return [];
|
||||
}
|
||||
|
||||
$currentOrganization = $user->currentOrganization;
|
||||
|
||||
return array_merge([
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
@@ -69,12 +49,12 @@ class ShareInertiaData
|
||||
'profile_photo_url' => $user->profile_photo_url,
|
||||
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
|
||||
&& ! is_null($user->two_factor_secret),
|
||||
'current_team' => $user->currentTeam !== null ? [
|
||||
'id' => $user->currentTeam->id,
|
||||
'user_id' => $user->currentTeam->user_id,
|
||||
'name' => $user->currentTeam->name,
|
||||
'personal_team' => $user->currentTeam->personal_team,
|
||||
'currency' => $user->currentTeam->currency,
|
||||
'current_team' => $currentOrganization !== null ? [
|
||||
'id' => $currentOrganization->id,
|
||||
'user_id' => $currentOrganization->user_id,
|
||||
'name' => $currentOrganization->name,
|
||||
'personal_team' => $currentOrganization->personal_team,
|
||||
'currency' => $currentOrganization->currency,
|
||||
] : null,
|
||||
], array_filter([
|
||||
'all_teams' => $user->organizations->map(function (Organization $organization): array {
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\Member;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
@@ -19,7 +20,7 @@ class MemberMergeIntoRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
* @return array<string, array<string|ValidationRule|Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Organization;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class OrganizationStoreRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class UserUpdateCurrentOrganizationRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'organization_id' => [
|
||||
'required',
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getOrganizationId(): string
|
||||
{
|
||||
return (string) $this->input('organization_id');
|
||||
}
|
||||
}
|
||||
95
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
95
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\User;
|
||||
use App\Rules\Base64ImageRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property User $user User from model binding
|
||||
*/
|
||||
class UserUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('email') && is_string($this->input('email'))) {
|
||||
$this->merge([
|
||||
'email' => Str::lower((string) $this->input('email')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'email',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
'nullable',
|
||||
new Base64ImageRule,
|
||||
],
|
||||
'timezone' => [
|
||||
'timezone:all',
|
||||
],
|
||||
'week_start' => [
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->has('name') ? (string) $this->input('name') : null;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
|
||||
}
|
||||
|
||||
public function getTimezone(): ?string
|
||||
{
|
||||
return $this->has('timezone') ? (string) $this->input('timezone') : null;
|
||||
}
|
||||
|
||||
public function getWeekStart(): ?Weekday
|
||||
{
|
||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||
}
|
||||
|
||||
public function hasPhotoKey(): bool
|
||||
{
|
||||
return $this->has('photo');
|
||||
}
|
||||
|
||||
public function getPhoto(): ?string
|
||||
{
|
||||
$value = $this->input('photo');
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class UserResource extends BaseResource
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $email Email of user */
|
||||
'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 */
|
||||
'profile_photo_url' => $this->resource->profile_photo_url,
|
||||
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class RemovePlaceholder
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(TeamMemberAdded $event): void
|
||||
{
|
||||
$memberService = app(MemberService::class);
|
||||
$member = Member::query()
|
||||
->whereBelongsTo($event->team, 'organization')
|
||||
->whereBelongsTo($event->user, 'user')
|
||||
->firstOrFail();
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($event): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $event->user->email);
|
||||
})
|
||||
->whereBelongsTo($event->team, 'organization')
|
||||
->with(['user'])
|
||||
->get();
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
/** @var Member $placeholder */
|
||||
$placeholderUser = $placeholder->user;
|
||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
||||
$placeholder->delete();
|
||||
$placeholderUser->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class OrganizationInvitationMail extends Mailable
|
||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.organization-invitation', [
|
||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||
'invitation' => $this->invitation,
|
||||
]),
|
||||
'acceptUrl' => URL::to(URL::signedRoute(
|
||||
'organization-invitations.accept',
|
||||
['invitation' => $this->invitation->getKey()],
|
||||
Carbon::now()->addDays(90),
|
||||
false
|
||||
)),
|
||||
])->subject(__('Organization Invitation'));
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VerifyUpdatedEmailMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public User $user;
|
||||
|
||||
public string $email;
|
||||
|
||||
public function __construct(User $user, string $email)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->email = Str::lower($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'users.verify-email-change',
|
||||
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||
[
|
||||
'user' => $this->user->getKey(),
|
||||
'email' => $this->email,
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
return $this->markdown('emails.verify-updated-email', [
|
||||
'verificationUrl' => URL::to($verificationUrl),
|
||||
])->subject(__('Verify Email Address'));
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,11 @@ use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\MemberFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
class Member extends JetstreamMembership implements AuditableContract
|
||||
class Member extends Pivot implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -21,10 +22,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Events\TeamCreated;
|
||||
use Laravel\Jetstream\Events\TeamDeleted;
|
||||
use Laravel\Jetstream\Events\TeamUpdated;
|
||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -36,12 +33,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property bool $prevent_overlapping_time_entries
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Collection<int, User> $users
|
||||
* @property Collection<int, User> $realUsers
|
||||
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
|
||||
* @property-read Collection<int, OrganizationInvitation> $organizationInvitations
|
||||
* @property Member $membership
|
||||
* @property NumberFormat $number_format
|
||||
* @property CurrencyFormat $currency_format
|
||||
@@ -49,10 +47,9 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property IntervalFormat $interval_format
|
||||
* @property TimeFormat $time_format
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
*/
|
||||
class Organization extends JetstreamTeam implements AuditableContract
|
||||
class Organization extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
@@ -90,17 +87,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
'personal_team',
|
||||
];
|
||||
|
||||
/**
|
||||
* The event map for the model.
|
||||
*
|
||||
* @var array<string, class-string>
|
||||
*/
|
||||
protected $dispatchesEvents = [
|
||||
'created' => TeamCreated::class,
|
||||
'updated' => TeamUpdated::class,
|
||||
'deleted' => TeamDeleted::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The model's default values for attributes.
|
||||
*
|
||||
@@ -109,23 +95,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
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.
|
||||
*
|
||||
@@ -171,12 +140,21 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* This method prevents an unhandled exception when the ID is not a UUID.
|
||||
* Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
|
||||
*
|
||||
* @param array<string> $columns
|
||||
* @return HasMany<OrganizationInvitation, $this>
|
||||
*/
|
||||
public function findOrFail(string $id, array $columns = ['*']): \Laravel\Jetstream\Team
|
||||
public function organizationInvitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrganizationInvitation::class, 'organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a model by its primary key or throw an exception.
|
||||
*
|
||||
* @param array<int, string> $columns
|
||||
*
|
||||
* @throws ModelNotFoundException<Model>
|
||||
*/
|
||||
public static function findOrFail(string $id, array $columns = ['*']): Model
|
||||
{
|
||||
if (! Str::isUuid($id)) {
|
||||
throw (new ModelNotFoundException)->setModel(
|
||||
|
||||
@@ -8,9 +8,9 @@ use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationInvitationFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -18,13 +18,14 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property-read Organization $organization
|
||||
*
|
||||
* @method static OrganizationInvitationFactory factory()
|
||||
*/
|
||||
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
|
||||
class OrganizationInvitation extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
||||
protected $table = 'organization_invitations';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'role',
|
||||
];
|
||||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the organization that the invitation belongs to.
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
@@ -25,8 +26,6 @@ use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Jetstream\HasTeams;
|
||||
use Laravel\Passport\AuthCode;
|
||||
use Laravel\Passport\Contracts\OAuthenticatable;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
@@ -36,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string|null $pending_email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string|null $password
|
||||
* @property string|null $two_factor_secret
|
||||
@@ -44,13 +44,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property Weekday $week_start
|
||||
* @property string|null $profile_photo_path
|
||||
* @property-read Organization|null $currentOrganization
|
||||
* @property-read Organization|null $currentTeam
|
||||
* @property-read string $profile_photo_url
|
||||
* @property-read Collection<int, Token> $tokens
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property string|null $current_team_id
|
||||
* @property Collection<int, Organization> $organizations
|
||||
* @property Collection<int, Organization> $ownedOrganizations
|
||||
* @property Collection<int, TimeEntry> $timeEntries
|
||||
* @property Member $membership
|
||||
*
|
||||
@@ -68,8 +68,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasProfilePhoto;
|
||||
use HasTeams;
|
||||
use HasUuids;
|
||||
use Notifiable;
|
||||
use TwoFactorAuthenticatable;
|
||||
@@ -105,6 +103,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'email' => 'string',
|
||||
'pending_email' => 'string',
|
||||
'email_verified_at' => 'datetime',
|
||||
'is_admin' => 'boolean',
|
||||
'is_placeholder' => 'boolean',
|
||||
@@ -129,16 +128,39 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
{
|
||||
return Attribute::get(function (): string {
|
||||
return $this->profile_photo_path
|
||||
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
|
||||
? Storage::disk(config('filesystems.public'))->url($this->profile_photo_path)
|
||||
: $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
|
||||
{
|
||||
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
|
||||
{
|
||||
return $this->is_placeholder === false;
|
||||
@@ -159,6 +181,14 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
->as('membership');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
|
||||
*/
|
||||
public function ownedOrganizations(): BelongsToMany
|
||||
{
|
||||
return $this->organizations()->wherePivot('role', Role::Owner->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry, $this>
|
||||
*/
|
||||
@@ -213,12 +243,8 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
*/
|
||||
public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder
|
||||
{
|
||||
return $builder->where(function (Builder $builder) use ($organization): Builder {
|
||||
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
|
||||
$query->whereKey($organization->getKey());
|
||||
})->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {
|
||||
$query->whereKey($organization->getKey());
|
||||
});
|
||||
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
|
||||
$query->whereKey($organization->getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class OrganizationPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->belongsToTeam($organization);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can 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.
|
||||
*/
|
||||
public function updateTeamMember(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Note: since this policy is only used for jetstream endpoints, we can return false here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can remove team members.
|
||||
*/
|
||||
public function removeTeamMember(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Passport\AuthCode;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\RefreshToken;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Policies\OrganizationPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
@@ -22,7 +19,6 @@ class AuthServiceProvider extends ServiceProvider
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
Organization::class => OrganizationPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,11 +52,5 @@ class AuthServiceProvider extends ServiceProvider
|
||||
// Passport::tokensExpireIn(now()->addDays(15));
|
||||
// Passport::refreshTokensExpireIn(now()->addDays(30));
|
||||
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
|
||||
|
||||
// same as passport default above
|
||||
Jetstream::defaultApiTokenPermissions(['read']);
|
||||
|
||||
// use passport scopes for jetstream token permissions
|
||||
Jetstream::permissions(Passport::scopeIds());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RemovePlaceholder;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
TeamMemberAdded::class => [
|
||||
RemovePlaceholder::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,11 +15,13 @@ use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Fortify\Http\Responses\LoginResponse;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -41,6 +43,48 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::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::loginView(function () {
|
||||
return Inertia::render('Auth/Login', [
|
||||
'canResetPassword' => Route::has('password.request'),
|
||||
'status' => session('status'),
|
||||
]);
|
||||
});
|
||||
|
||||
Fortify::requestPasswordResetLinkView(function () {
|
||||
return Inertia::render('Auth/ForgotPassword', [
|
||||
'status' => session('status'),
|
||||
]);
|
||||
});
|
||||
|
||||
Fortify::resetPasswordView(function (Request $request) {
|
||||
return Inertia::render('Auth/ResetPassword', [
|
||||
'email' => $request->input('email'),
|
||||
'token' => $request->route('token'),
|
||||
]);
|
||||
});
|
||||
|
||||
Fortify::verifyEmailView(function () {
|
||||
return Inertia::render('Auth/VerifyEmail', [
|
||||
'status' => session('status'),
|
||||
]);
|
||||
});
|
||||
|
||||
Fortify::twoFactorChallengeView(function () {
|
||||
return Inertia::render('Auth/TwoFactorChallenge');
|
||||
});
|
||||
|
||||
Fortify::confirmPasswordView(function () {
|
||||
return Inertia::render('Auth/ConfirmPassword');
|
||||
});
|
||||
|
||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||
/** @var User|null $user */
|
||||
$user = User::query()
|
||||
@@ -65,7 +109,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
$this->app->instance(LoginResponse::class, new CustomLoginResponse);
|
||||
$this->app->instance(LoginResponseContract::class, new CustomLoginResponse);
|
||||
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Jetstream\AddOrganizationMember;
|
||||
use App\Actions\Jetstream\CreateOrganization;
|
||||
use App\Actions\Jetstream\DeleteOrganization;
|
||||
use App\Actions\Jetstream\DeleteUser;
|
||||
use App\Actions\Jetstream\InviteOrganizationMember;
|
||||
use App\Actions\Jetstream\RemoveOrganizationMember;
|
||||
use App\Actions\Jetstream\UpdateMemberRole;
|
||||
use App\Actions\Jetstream\UpdateOrganization;
|
||||
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\TimezoneService;
|
||||
use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class JetstreamServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configurePermissions();
|
||||
|
||||
Jetstream::createTeamsUsing(CreateOrganization::class);
|
||||
Jetstream::updateTeamNamesUsing(UpdateOrganization::class);
|
||||
Jetstream::addTeamMembersUsing(AddOrganizationMember::class);
|
||||
Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class);
|
||||
Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class);
|
||||
Jetstream::deleteTeamsUsing(DeleteOrganization::class);
|
||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
||||
Jetstream::useTeamModel(Organization::class);
|
||||
Jetstream::useMembershipModel(Member::class);
|
||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
||||
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) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the roles and permissions that are available within the application.
|
||||
*/
|
||||
protected function configurePermissions(): void
|
||||
{
|
||||
Jetstream::defaultApiTokenPermissions([]);
|
||||
|
||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||
'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.');
|
||||
|
||||
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()
|
||||
->whenRendering(
|
||||
'Profile/Show',
|
||||
function (Request $request, array $data): array {
|
||||
return array_merge($data, [
|
||||
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
]);
|
||||
}
|
||||
)
|
||||
->whenRendering(
|
||||
'Teams/Show',
|
||||
function (Request $request, array $data): array {
|
||||
/** @var Organization $teamModel */
|
||||
$teamModel = $data['team'];
|
||||
$owner = $teamModel->owner;
|
||||
|
||||
return array_merge($data, [
|
||||
'team' => [
|
||||
'id' => $teamModel->getKey(),
|
||||
'name' => $teamModel->name,
|
||||
'currency' => $teamModel->currency,
|
||||
'owner' => [
|
||||
'id' => $owner->getKey(),
|
||||
'name' => $owner->name,
|
||||
'email' => $owner->email,
|
||||
'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 {
|
||||
return $currency->getName();
|
||||
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/Rules/Base64ImageRule.php
Normal file
45
app/Rules/Base64ImageRule.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\Base64File;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class Base64ImageRule implements ValidationRule
|
||||
{
|
||||
private const array ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
];
|
||||
|
||||
private const int MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.string'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$file = Base64File::decode($value);
|
||||
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
|
||||
$fail(__('validation.mimes', ['values' => 'jpg, png']));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($file['data']) > self::MAX_BYTES) {
|
||||
$fail(__('validation.max.file', ['max' => (string) (self::MAX_BYTES / 1024)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ class DeletionService
|
||||
$user->authCodes()->delete();
|
||||
|
||||
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
|
||||
$user->deleteProfilePhoto();
|
||||
$this->userService->deleteProfilePhoto($user);
|
||||
|
||||
$user->delete();
|
||||
|
||||
|
||||
179
app/Service/Dto/UserAgentDto.php
Normal file
179
app/Service/Dto/UserAgentDto.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use Closure;
|
||||
use Detection\MobileDetect;
|
||||
|
||||
/**
|
||||
* @copyright Originally created by Jens Segers: https://github.com/jenssegers/agent
|
||||
*/
|
||||
class UserAgentDto extends MobileDetect
|
||||
{
|
||||
/**
|
||||
* List of additional operating systems.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected static array $additionalOperatingSystems = [
|
||||
'Windows' => 'Windows',
|
||||
'Windows NT' => 'Windows NT',
|
||||
'OS X' => 'Mac OS X',
|
||||
'Debian' => 'Debian',
|
||||
'Ubuntu' => 'Ubuntu',
|
||||
'Macintosh' => 'PPC',
|
||||
'OpenBSD' => 'OpenBSD',
|
||||
'Linux' => 'Linux',
|
||||
'ChromeOS' => 'CrOS',
|
||||
];
|
||||
|
||||
/**
|
||||
* List of additional browsers.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected static array $additionalBrowsers = [
|
||||
'Opera Mini' => 'Opera Mini',
|
||||
'Opera' => 'Opera|OPR',
|
||||
'Edge' => 'Edge|Edg',
|
||||
'Coc Coc' => 'coc_coc_browser',
|
||||
'UCBrowser' => 'UCBrowser',
|
||||
'Vivaldi' => 'Vivaldi',
|
||||
'Chrome' => 'Chrome',
|
||||
'Firefox' => 'Firefox',
|
||||
'Safari' => 'Safari',
|
||||
'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+',
|
||||
'Netscape' => 'Netscape',
|
||||
'Mozilla' => 'Mozilla',
|
||||
'WeChat' => 'MicroMessenger',
|
||||
];
|
||||
|
||||
/**
|
||||
* Key value store for resolved strings.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $store = [];
|
||||
|
||||
/**
|
||||
* Get the platform name from the User Agent.
|
||||
*/
|
||||
public function platform(): ?string
|
||||
{
|
||||
return $this->retrieveUsingCacheOrResolve('platform', function () {
|
||||
return $this->findDetectionRulesAgainstUserAgent(
|
||||
$this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the browser name from the User Agent.
|
||||
*/
|
||||
public function browser(): ?string
|
||||
{
|
||||
return $this->retrieveUsingCacheOrResolve('browser', function (): ?string {
|
||||
return $this->findDetectionRulesAgainstUserAgent(
|
||||
$this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the device is a desktop computer.
|
||||
*/
|
||||
public function isDesktop(): bool
|
||||
{
|
||||
return $this->retrieveUsingCacheOrResolve('desktop', function (): bool {
|
||||
// Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront'
|
||||
if (
|
||||
$this->getUserAgent() === static::$cloudFrontUA
|
||||
&& $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $this->isMobile() && ! $this->isTablet();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a detection rule and return the matched key.
|
||||
*
|
||||
* @param array<string, string|list<string>> $rules
|
||||
*/
|
||||
protected function findDetectionRulesAgainstUserAgent(array $rules): ?string
|
||||
{
|
||||
$userAgent = $this->getUserAgent();
|
||||
|
||||
foreach ($rules as $key => $regex) {
|
||||
if (is_array($regex)) {
|
||||
$regex = implode('|', $regex);
|
||||
}
|
||||
|
||||
if (empty($regex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->match($regex, $userAgent)) {
|
||||
if ($key !== '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$match = reset($this->matchesArray);
|
||||
|
||||
return is_string($match) ? $match : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve from the given key from the cache or resolve the value.
|
||||
*
|
||||
* @template TReturn of string|bool|null
|
||||
*
|
||||
* @param Closure():TReturn $callback
|
||||
* @return TReturn
|
||||
*/
|
||||
protected function retrieveUsingCacheOrResolve(string $key, Closure $callback): string|bool|null
|
||||
{
|
||||
$cacheKey = $this->createCacheKey($key);
|
||||
|
||||
if (! is_null($cacheItem = $this->store[$cacheKey] ?? null)) {
|
||||
return $cacheItem;
|
||||
}
|
||||
|
||||
return tap(call_user_func($callback), function ($result) use ($cacheKey): void {
|
||||
$this->store[$cacheKey] = $result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple rules into one array.
|
||||
*
|
||||
* @param array<string, string|list<string>> ...$all
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function mergeRules(array ...$all): array
|
||||
{
|
||||
$merged = [];
|
||||
|
||||
foreach ($all as $rules) {
|
||||
foreach ($rules as $key => $value) {
|
||||
$value = is_array($value) ? implode('|', $value) : $value;
|
||||
|
||||
if (empty($merged[$key])) {
|
||||
$merged[$key] = $value;
|
||||
} else {
|
||||
$merged[$key] .= '|'.$value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,25 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\OrganizationInvitationAdding;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
/**
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
|
||||
{
|
||||
if (Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists()) {
|
||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
|
||||
@@ -36,7 +34,7 @@ class InvitationService
|
||||
throw new InvitationForTheEmailAlreadyExistsApiException;
|
||||
}
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role->value);
|
||||
OrganizationInvitationAdding::dispatch($organization, $email, $role, $inviter);
|
||||
|
||||
$invitation = new OrganizationInvitation;
|
||||
$invitation->email = $email;
|
||||
@@ -48,4 +46,37 @@ class InvitationService
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Organization>
|
||||
*/
|
||||
public function processAcceptedInvitations(User $user): Collection
|
||||
{
|
||||
$organizations = new Collection;
|
||||
|
||||
$invitations = OrganizationInvitation::query()
|
||||
->where('email', $user->email)
|
||||
->whereNotNull('accepted_at')
|
||||
->get();
|
||||
|
||||
foreach ($invitations as $invitation) {
|
||||
$organization = $invitation->organization;
|
||||
$role = Role::tryFrom($invitation->role);
|
||||
if ($role === null) {
|
||||
Log::error('Invalid role in invitation', [
|
||||
'invitation' => $invitation->getKey(),
|
||||
'role' => $invitation->role,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
app(MemberService::class)->addMember($user, $organization, $role);
|
||||
|
||||
$invitation->delete();
|
||||
|
||||
$organizations->push($organization);
|
||||
}
|
||||
|
||||
return $organizations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,30 @@ class LocalizationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration for reporting contexts (PDF reports, places that display duration
|
||||
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
|
||||
* so totals stay narrow and reconcile with cost, which is always computed to the second.
|
||||
*/
|
||||
public function formatIntervalForReporting(CarbonInterval $interval): string
|
||||
{
|
||||
$promoted = [
|
||||
IntervalFormat::HoursMinutes,
|
||||
IntervalFormat::HoursMinutesColonSeparated,
|
||||
];
|
||||
if (! in_array($this->intervalFormat, $promoted, true)) {
|
||||
return $this->formatInterval($interval);
|
||||
}
|
||||
|
||||
$previous = $this->intervalFormat;
|
||||
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
|
||||
try {
|
||||
return $this->formatInterval($interval);
|
||||
} finally {
|
||||
$this->intervalFormat = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
public function formatCurrency(Money $money): string
|
||||
{
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberAdded;
|
||||
use App\Events\MemberAdding;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
@@ -21,8 +23,6 @@ use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class MemberService
|
||||
{
|
||||
@@ -36,7 +36,7 @@ class MemberService
|
||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||
{
|
||||
if (! $asSuperAdmin) {
|
||||
AddingTeamMember::dispatch($organization, $user);
|
||||
MemberAdding::dispatch($user, $organization, $role);
|
||||
}
|
||||
|
||||
$member = new Member;
|
||||
@@ -49,14 +49,36 @@ class MemberService
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
});
|
||||
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||
|
||||
if (! $asSuperAdmin) {
|
||||
TeamMemberAdded::dispatch($organization, $user);
|
||||
MemberAdded::dispatch($member, $organization, $user);
|
||||
}
|
||||
|
||||
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 EntityStillInUseApiException
|
||||
@@ -71,7 +93,7 @@ class MemberService
|
||||
$isPlaceholder = $user->is_placeholder;
|
||||
|
||||
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
|
||||
$user->currentTeam()->disassociate();
|
||||
$user->currentOrganization()->disassociate();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
@@ -190,7 +212,7 @@ class MemberService
|
||||
{
|
||||
$user = $member->user;
|
||||
if ($user->current_team_id === $member->organization_id) {
|
||||
$user->currentTeam()->disassociate();
|
||||
$user->currentOrganization()->disassociate();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
@@ -209,4 +231,13 @@ class MemberService
|
||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||
}
|
||||
}
|
||||
|
||||
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||
{
|
||||
return Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,238 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\Role;
|
||||
|
||||
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>>
|
||||
*/
|
||||
@@ -22,6 +246,37 @@ class PermissionStore
|
||||
$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
|
||||
{
|
||||
/** @var User|null $user */
|
||||
@@ -36,7 +291,7 @@ class PermissionStore
|
||||
public function userHas(Organization $organization, User $user, string $permission): bool
|
||||
{
|
||||
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
|
||||
if (! $user->belongsToTeam($organization)) {
|
||||
if (! $user->isMemberOfOrganization($organization)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -54,7 +309,7 @@ class PermissionStore
|
||||
*/
|
||||
private function getPermissionsByUser(Organization $organization, User $user): array
|
||||
{
|
||||
if (! $user->belongsToTeam($organization)) {
|
||||
if (! $user->isMemberOfOrganization($organization)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -68,14 +323,11 @@ class PermissionStore
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Role|null $roleObj */
|
||||
$roleObj = Jetstream::findRole($role);
|
||||
|
||||
$permissions = $roleObj->permissions ?? [];
|
||||
$permissions = self::permissionsForRole($role);
|
||||
|
||||
// If the organization allows employees to manage tasks and the user is an employee,
|
||||
// add the task management permissions for accessible projects
|
||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||
if ($role === Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||
$permissions = array_merge($permissions, [
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
|
||||
@@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use League\Csv\CannotInsertRecord;
|
||||
use League\Csv\Exception;
|
||||
use League\Csv\UnavailableStream;
|
||||
use League\Csv\Writer;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
|
||||
@@ -58,9 +61,9 @@ abstract class CsvExport
|
||||
abstract public function mapRow(Model $model): array;
|
||||
|
||||
/**
|
||||
* @throws \League\Csv\CannotInsertRecord
|
||||
* @throws \League\Csv\Exception
|
||||
* @throws \League\Csv\UnavailableStream
|
||||
* @throws CannotInsertRecord
|
||||
* @throws Exception
|
||||
* @throws UnavailableStream
|
||||
*/
|
||||
public function export(): void
|
||||
{
|
||||
@@ -72,6 +75,7 @@ abstract class CsvExport
|
||||
$writer->insertOne(static::HEADER);
|
||||
|
||||
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
|
||||
/** @var T $model */
|
||||
foreach ($models as $model) {
|
||||
$data = $this->mapRow($model);
|
||||
$row = $this->convertRow($data);
|
||||
|
||||
@@ -62,7 +62,7 @@ class TimeEntryFilter
|
||||
if ($start === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('start', '>', $start);
|
||||
$this->builder->where('start', '>=', $start);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UserService
|
||||
{
|
||||
@@ -38,7 +39,7 @@ class UserService
|
||||
): User {
|
||||
$user = new User;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
$user->email = strtolower($email);
|
||||
$user->password = Hash::make($password);
|
||||
$user->timezone = $timezone;
|
||||
$user->week_start = $weekStart;
|
||||
@@ -47,19 +48,21 @@ class UserService
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
if ($organizations->isEmpty()) {
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
@@ -100,13 +103,17 @@ class UserService
|
||||
true
|
||||
);
|
||||
|
||||
// Set the organization as the user's current organization
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
$this->switchCurrentOrganization($user, $organization);
|
||||
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
}
|
||||
|
||||
public function switchCurrentOrganization(User $user, Organization $organization): void
|
||||
{
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
}
|
||||
|
||||
public function getOrganizationNameForUserName(string $username): string
|
||||
{
|
||||
return explode(' ', $username, 2)[0]."'s Organization";
|
||||
@@ -154,4 +161,16 @@ class UserService
|
||||
$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();
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Support/Base64File.php
Normal file
45
app/Support/Base64File.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
|
||||
class Base64File
|
||||
{
|
||||
/**
|
||||
* @return array{data: string, mime_type: string}|null
|
||||
*/
|
||||
public static function decode(string $value): ?array
|
||||
{
|
||||
if (str_contains($value, ',')) {
|
||||
[, $value] = explode(',', $value, 2);
|
||||
}
|
||||
|
||||
$value = preg_replace('/\s+/', '', $value);
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
|
||||
if ($mimeType === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $decoded,
|
||||
'mime_type' => $mimeType,
|
||||
];
|
||||
}
|
||||
|
||||
public static function extension(string $mimeType): ?string
|
||||
{
|
||||
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use App\Exceptions\Handler;
|
||||
use App\Http\Kernel;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Foundation\Application;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -13,7 +17,7 @@ declare(strict_types=1);
|
||||
|
|
||||
*/
|
||||
|
||||
$app = new Illuminate\Foundation\Application(
|
||||
$app = new Application(
|
||||
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
||||
);
|
||||
|
||||
@@ -30,7 +34,7 @@ $app = new Illuminate\Foundation\Application(
|
||||
|
||||
$app->singleton(
|
||||
Illuminate\Contracts\Http\Kernel::class,
|
||||
App\Http\Kernel::class
|
||||
Kernel::class
|
||||
);
|
||||
|
||||
$app->singleton(
|
||||
@@ -39,8 +43,8 @@ $app->singleton(
|
||||
);
|
||||
|
||||
$app->singleton(
|
||||
Illuminate\Contracts\Debug\ExceptionHandler::class,
|
||||
App\Exceptions\Handler::class
|
||||
ExceptionHandler::class,
|
||||
Handler::class
|
||||
);
|
||||
|
||||
/*
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"korridor/laravel-computed-attributes": "^3.1",
|
||||
"korridor/laravel-has-many-sync": "^3.1",
|
||||
"korridor/laravel-model-validation-rules": "^3.0",
|
||||
"laravel/fortify": "^1.37",
|
||||
"laravel/framework": "^12.19.3",
|
||||
"laravel/jetstream": "^5.0",
|
||||
"laravel/octane": "^2.3",
|
||||
"laravel/passport": "^13.0.5",
|
||||
"laravel/tinker": "^2.8",
|
||||
@@ -27,6 +27,7 @@
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/iso3166": "^4.3",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"mobiledetect/mobiledetectlib": "^4.11",
|
||||
"novadaemon/filament-pretty-json": "^2.2",
|
||||
"nwidart/laravel-modules": "^12.0.4",
|
||||
"owen-it/laravel-auditing": "^14.0.0",
|
||||
|
||||
3431
composer.lock
generated
3431
composer.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user