Compare commits

..

9 Commits

Author SHA1 Message Date
Constantin Graf
0a28be83a1 Add more tests; Add filter in filament resource; Added options for user create command 2025-02-06 12:15:49 -05:00
Constantin Graf
4eb716d2cc Fixed bugs causing incorrect computed attributes in imported data 2025-02-04 19:51:54 -05:00
Constantin Graf
55323fa4b4 Add missing serve option to local filesystem disk 2025-02-04 19:51:23 -05:00
Constantin Graf
6df20ed1e5 Updated composer dependencies 2025-02-04 19:50:34 -05:00
Constantin Graf
bc7c564eb2 Added estimated time to clockify project import 2025-02-04 13:45:10 -05:00
Constantin Graf
5423b03201 Fixed timezones in unit tests 2024-12-20 19:47:12 -05:00
Constantin Graf
0e910ba565 Updated composer dependencies 2024-12-20 19:28:18 -05:00
Constantin Graf
bad1cd1343 Fixed reports in deletion service 2024-12-20 19:28:10 -05:00
Constantin Graf
dd312b396b Deactivated registration 2024-12-20 19:05:21 -05:00
269 changed files with 4122 additions and 9722 deletions

42
.env.ci
View File

@@ -1,4 +1,3 @@
# Application
APP_NAME=solidtime APP_NAME=solidtime
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
@@ -6,6 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true APP_ENABLE_REGISTRATION=true
SESSION_SECURE_COOKIE=false
# Logging # Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack
@@ -20,39 +20,35 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root DB_TEST_PASSWORD=root
# Broadcasting BROADCAST_DRIVER=log
BROADCAST_DRIVER=null
# Cache
CACHE_DRIVER=file CACHE_DRIVER=file
# Queue
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
# Session
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
# Mail # Mail
MAIL_MAILER=log MAIL_MAILER=log
MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="solidtime" MAIL_FROM_NAME="${APP_NAME}"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
# Filesystems # Filesystems
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
PUBLIC_FILESYSTEM_DISK=public PUBLIC_FILESYSTEM_DISK=public
# Passport
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
# Auditing
AUDITING_ENABLED=true
# Telescope
TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://0.0.0.0:3000 GOTENBERG_URL=http://0.0.0.0:3000
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

View File

@@ -4,7 +4,7 @@ APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false AUDITING_ENABLED=true
APP_ENABLE_REGISTRATION=true APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500 PAGINATION_PER_PAGE_DEFAULT=500
@@ -49,9 +49,7 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="solidtime" MAIL_FROM_NAME="${APP_NAME}"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
# Filesystems # Filesystems
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
@@ -64,24 +62,14 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true S3_USE_PATH_STYLE_ENDPOINT=true
# Passport
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
# Auditing
AUDITING_ENABLED=true
# Telescope
TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://gotenberg:3000 GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
# Local setup # Local setup
NGINX_HOST_NAME=solidtime.test NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432 FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083 FORWARD_WEB_PORT=8083
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage

View File

@@ -5,6 +5,7 @@ VITE_APP_NAME=solidtime
APP_ENV=production APP_ENV=production
APP_DEBUG=false APP_DEBUG=false
APP_FORCE_HTTPS=true APP_FORCE_HTTPS=true
SESSION_SECURE_COOKIE=true
OCTANE_SERVER=frankenphp OCTANE_SERVER=frankenphp
PAGINATION_PER_PAGE_DEFAULT=500 PAGINATION_PER_PAGE_DEFAULT=500

13
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
rules: {
'vue/multi-word-component-names': 'off',
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
plugins: ['unused-imports'],
}

View File

@@ -1,47 +0,0 @@
name: Bug Report
description: "Report a bug"
body:
- type: markdown
attributes:
value: |
Before creating a new bug report, please check that there isn't already a similar issue.
- type: textarea
attributes:
label: Description
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: "Steps To Reproduce"
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
...
validations:
required: false
- type: dropdown
attributes:
label: "Self-hosted or Cloud?"
options:
- Self-Hosted
- solidtime Cloud
- Both
- type: input
attributes:
label: "Version of solidtime: (for self-hosted)"
validations:
required: false
- type: input
attributes:
label: "solidtime self-hosting guide: (for self-hosted)"
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
validations:
required: false

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 🚀 Feature Request
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
about: Share ideas for new features
- name: ❓ Ask a Question
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
about: Ask the community for help

View File

@@ -1,8 +0,0 @@
<!--
This project is early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->

View File

@@ -11,21 +11,10 @@ on:
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
name: Build - Public name: Build - Public
jobs: jobs:
build: build:
strategy: runs-on: ubuntu-latest
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
permissions: permissions:
packages: write packages: write
contents: read contents: read
@@ -40,7 +29,7 @@ jobs:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build" - name: "Get build"
id: release-build id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT" run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
@@ -51,7 +40,7 @@ jobs:
prefix: "v" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: version
run: | run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
@@ -72,23 +61,21 @@ jobs:
rm .env.production .env.ci .env.example rm .env.production .env.ci .env.example
- name: "Add version to .env" - name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env" - name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env" - name: "Output .env"
run: cat .env run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies" - name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative uses: php-actions/composer@v6
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
with:
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -101,31 +88,29 @@ jobs:
- name: "Build" - name: "Build"
run: npm run build run: npm run build
- name: "Prepare" - name: "Login to GitHub Container Registry"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -133,90 +118,16 @@ jobs:
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push"
id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
build-args: | build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/ DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }} platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.0 uses: codecov/codecov-action@v4.5.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -10,6 +10,6 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5 uses: aglipanci/laravel-pint-action@2.4
with: with:
configPath: "pint.json" configPath: "pint.json"

View File

@@ -27,47 +27,45 @@ jobs:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: "Setup node" - uses: actions/setup-node@v4
uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Setup PHP" - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: '8.3' php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none coverage: none
- name: "Run composer install" - name: Run composer install
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- name: "Prepare Laravel Application" - name: Prepare Laravel Application
run: | run: |
cp .env.ci .env cp .env.ci .env
php artisan key:generate php artisan key:generate
php artisan passport:keys
php artisan migrate --seed php artisan migrate --seed
php artisan passport:keys
- name: "Install dependencies" - name: Install dependencies
run: npm ci run: npm ci
- name: "Build Frontend" - name: Build Frontend
run: npm run build run: npm run build
- name: "Run Laravel Server" - name: Run Laravel Server
run: php artisan serve > /dev/null 2>&1 & run: php artisan serve > /dev/null 2>&1 &
- name: "Install Playwright Browsers" - name: Install Playwright Browsers
run: npx playwright install --with-deps run: npx playwright install --with-deps
- name: "Run Playwright tests" - name: Run Playwright tests
run: npx playwright test run: npx playwright test
env: env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000' PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
- name: "Upload test results" - uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: test-results name: test-results

View File

@@ -57,7 +57,7 @@ class UserCreateCommand extends Command
} }
$user = null; $user = null;
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void { DB::transaction(function () use (&$user, $name, $email, $password): void {
$user = app(UserService::class)->createUser( $user = app(UserService::class)->createUser(
$name, $name,
$email, $email,
@@ -65,7 +65,6 @@ class UserCreateCommand extends Command
'UTC', 'UTC',
Weekday::Monday, Weekday::Monday,
'EUR', 'EUR',
$verifyEmail
); );
}); });
/** @var Organization|null $organization */ /** @var Organization|null $organization */
@@ -74,6 +73,10 @@ class UserCreateCommand extends Command
throw new LogicException('User does not have an organization'); throw new LogicException('User does not have an organization');
} }
if ($verifyEmail) {
$user->markEmailAsVerified();
}
$this->info('Created user "'.$name.'" ("'.$email.'")'); $this->info('Created user "'.$name.'" ("'.$email.'")');
$this->line('ID: '.$user->getKey()); $this->line('ID: '.$user->getKey());
$this->line('Name: '.$name); $this->line('Name: '.$name);

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Enums\Role;
use App\Models\Member;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class CorrectionPlaceholderMembersCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:placeholder-members '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$members = Member::query()
->where('role', '!=', Role::Placeholder->value)
->whereHas('user', function (Builder $builder): void {
/** @var Builder<User> $builder */
$builder->where('is_placeholder', '=', true);
})
->get();
foreach ($members as $member) {
/** @var Member $member */
$member->role = Role::Placeholder->value;
if (! $dryRun) {
$member->save();
}
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
}
return self::SUCCESS;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TokenResource extends Resource
{
protected static ?string $model = Token::class;
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'Auth';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('user_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\Select::make('client_id')
->label('Client')
->relationship(name: 'client', titleAttribute: 'name')
->searchable(['name'])
->required(),
Forms\Components\Toggle::make('revoked')
->label('Revoked')
->required(),
Forms\Components\DateTimePicker::make('expires_at')
->label('Expires At')
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('user.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('client.personal_access_client')
->boolean()
->label('API token?')
->sortable(),
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
->sortable(),
Tables\Columns\TextColumn::make('expires_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
TernaryFilter::make('is_personal_access_client')
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */
return $query;
},
)
->label('API token?'),
TernaryFilter::make('revoked')
->label('Revoked?'),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTokens::route('/'),
'view' => Pages\ViewToken::route('/{record}'),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TokenResource\Pages;
use App\Filament\Resources\TokenResource;
use Filament\Resources\Pages\ListRecords;
class ListTokens extends ListRecords
{
protected static string $resource = TokenResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TokenResource\Pages;
use App\Filament\Resources\TokenResource;
use Filament\Resources\Pages\ViewRecord;
class ViewToken extends ViewRecord
{
protected static string $resource = TokenResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -25,7 +25,6 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Tables\Actions\Impersonate; use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource class UserResource extends Resource
@@ -40,9 +39,6 @@ class UserResource extends Resource
public static function form(Form $form): Form public static function form(Form $form): Form
{ {
/** @var User|null $record */
$record = $form->getRecord();
return $form return $form
->columns(1) ->columns(1)
->schema([ ->schema([
@@ -59,13 +55,6 @@ class UserResource extends Resource
Forms\Components\TextInput::make('email') Forms\Components\TextInput::make('email')
->label('Email') ->label('Email')
->required() ->required()
->rules($record?->is_placeholder ? [] : [
UniqueEloquent::make(User::class, 'email')
->ignore($record?->getKey()),
])
->rule([
'email',
])
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('is_placeholder') Forms\Components\Toggle::make('is_placeholder')
->label('Is Placeholder?') ->label('Is Placeholder?')
@@ -73,11 +62,7 @@ class UserResource extends Resource
->disabledOn(['edit']), ->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at') Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At') ->label('Email Verified At')
->hiddenOn(['create'])
->nullable(), ->nullable(),
Forms\Components\Toggle::make('is_email_verified')
->label('Email Verified?')
->visibleOn(['create']),
Forms\Components\Select::make('timezone') Forms\Components\Select::make('timezone')
->label('Timezone') ->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
@@ -89,16 +74,8 @@ class UserResource extends Resource
->required(), ->required(),
TextInput::make('password') TextInput::make('password')
->password() ->password()
->label('Password')
->dehydrateStateUsing(fn ($state) => Hash::make($state)) ->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state)) ->dehydrated(fn ($state) => filled($state))
->hiddenOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
TextInput::make('password_create')
->password()
->label('Password')
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create') ->required(fn (string $context): bool => $context === 'create')
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('currency') Forms\Components\Select::make('currency')

View File

@@ -20,11 +20,10 @@ class CreateUser extends CreateRecord
$user = $userService->createUser( $user = $userService->createUser(
$data['name'], $data['name'],
$data['email'], $data['email'],
$data['password_create'], $data['password'],
$data['timezone'], $data['timezone'],
Weekday::from($data['week_start']), Weekday::from($data['week_start']),
$data['currency'], $data['currency'],
(bool) $data['is_email_verified']
); );
return $user; return $user;

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ApiTokenController extends Controller
{
/**
* List all api token of the currently authenticated user
*
* This endpoint is independent of organization.
*
* @operationId getApiTokens
*
* @throws AuthorizationException
*/
public function index(): ApiTokenCollection
{
$user = $this->user();
$tokens = $user->tokens()
->where('client_id', '=', config('passport.personal_access_client.id'))
->get();
return new ApiTokenCollection($tokens);
}
/**
* Create a new api token for the currently authenticated user
*
* The response will contain the access token that can be used to send authenticated API requests.
* Please note that the access token is only shown in this response and cannot be retrieved later.
*
* @operationId createApiToken
*
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
*/
public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
* Revoke an api token
*
* @operationId revokeApiToken
*
* @throws AuthorizationException
* @throws PersonalAccessClientIsNotConfiguredException
*/
public function revoke(Token $apiToken): JsonResponse
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
$apiToken->revoke();
return response()->json(null, 204);
}
/**
* Delete an api token
*
* @operationId deleteApiToken
*
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
*/
public function destroy(Token $apiToken): JsonResponse
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
$apiToken->delete();
return response()->json(null, 204);
}
}

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ChartController extends Controller
{
/**
* @throws AuthorizationException
*
* @operationId weeklyProjectOverview
*
* @response array<int, array{value: int, name: string, color: string}>
*/
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
return response()->json($weeklyProjectOverview);
}
/**
* @throws AuthorizationException
*
* @operationId latestTasks
*
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
*/
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$latestTasks = $dashboardService->latestTasks($user, $organization);
return response()->json($latestTasks);
}
/**
* @throws AuthorizationException
*
* @operationId lastSevenDays
*
* @response array<int, array{ date: string, duration: int, history: array<int> }>
*/
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
return response()->json($lastSevenDays);
}
/**
* @throws AuthorizationException
*
* @operationId latestTeamActivity
*
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
*/
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
{
$this->checkPermission($organization, 'charts:view:all');
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
return response()->json($latestTeamActivity);
}
/**
* @throws AuthorizationException
*
* @operationId dailyTrackedHours
*
* @response array<int, array{date: string, duration: int}>
*/
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
return response()->json($dailyTrackedHours);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyTime
*
* @response int
*/
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
return response()->json($totalWeeklyTime);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableTime
*
* @response int
*/
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
return response()->json($totalWeeklyBillableTime);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableAmount
*
* @response array{value: int, currency: string}
*/
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
return response()->json($totalWeeklyBillableAmount);
}
/**
* @throws AuthorizationException
*
* @operationId weeklyHistory
*
* @response array<int, array{date: string, duration: int}>
*/
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
return response()->json($weeklyHistory);
}
}

View File

@@ -7,17 +7,12 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder; use App\Events\MemberMadeToPlaceholder;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest; use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection; use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource; use App\Http\Resources\V1\Member\MemberResource;
@@ -29,8 +24,6 @@ use App\Service\MemberService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MemberController extends Controller class MemberController extends Controller
{ {
@@ -70,7 +63,6 @@ class MemberController extends Controller
* @throws OrganizationNeedsAtLeastOneOwner * @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership * @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed * @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws ChangingRoleOfPlaceholderIsNotAllowed
* *
* @operationId updateMember * @operationId updateMember
*/ */
@@ -113,9 +105,7 @@ class MemberController extends Controller
/** /**
* Make a member a placeholder member * Make a member a placeholder member
* *
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed * @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*
* @operationId makePlaceholder
*/ */
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
@@ -124,9 +114,6 @@ class MemberController extends Controller
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization; throw new CanNotRemoveOwnerFromOrganization;
} }
if ($member->role === Role::Placeholder->value) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
$memberService->makeMemberToPlaceholder($member); $memberService->makeMemberToPlaceholder($member);
@@ -135,39 +122,10 @@ class MemberController extends Controller
return response()->json(null, 204); return response()->json(null, 204);
} }
/**
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
*
* @operationId mergeMember
*/
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:merge-into', $member);
$user = $member->user;
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
}
$memberTo = Member::findOrFail($request->getMemberId());
DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {
$memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);
$member->delete();
$user->delete();
});
return response()->json(null, 204);
}
/** /**
* Invite a placeholder member to become a real member of the organization * Invite a placeholder member to become a real member of the organization
* *
* @throws AuthorizationException * @throws AuthorizationException|UserNotPlaceholderApiException
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* *
* @operationId invitePlaceholder * @operationId invitePlaceholder
*/ */
@@ -180,10 +138,6 @@ class MemberController extends Controller
throw new UserNotPlaceholderApiException; throw new UserNotPlaceholderApiException;
} }
if (Str::endsWith($user->email, '@solidtime-import.test')) {
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee); $invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204); return response()->json(null, 204);

View File

@@ -73,7 +73,6 @@ class ReportController extends Controller
false, false,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true
); );
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(), $timeEntriesQuery->clone(),
@@ -84,7 +83,6 @@ class ReportController extends Controller
true, true,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true
); );
return new DetailedWithDataReportResource($report, $data, $historyData); return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
@@ -181,7 +180,6 @@ class TimeEntryController extends Controller
} }
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
@@ -213,8 +211,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
false, false,
null, null,
null, null
$showBillableRate
); );
$html = Blade::render($viewFile, [ $html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(), 'timeEntries' => $timeEntriesQuery->get(),
@@ -288,18 +285,18 @@ class TimeEntryController extends Controller
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
* } * }
* *
@@ -315,7 +312,6 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
@@ -329,8 +325,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
$request->getFillGapsInTimeGroups(), $request->getFillGapsInTimeGroups(),
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd()
$showBillableRate
); );
return [ return [
@@ -364,7 +359,6 @@ class TimeEntryController extends Controller
} }
$debug = $request->getDebug(); $debug = $request->getDebug();
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
@@ -378,8 +372,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
false, false,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd()
$showBillableRate
); );
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -389,8 +382,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
true, true,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd()
$showBillableRate
); );
$currency = $organization->currency; $currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService; use App\Service\DashboardService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -20,14 +19,30 @@ class DashboardController extends Controller
{ {
$user = $this->user(); $user = $this->user();
$organization = $this->currentOrganization(); $organization = $this->currentOrganization();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
$latestTasks = $dashboardService->latestTasks($user, $organization);
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
$latestTeamActivity = null; $latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) { if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization); $latestTeamActivity = $dashboardService->latestTeamActivity($organization);
} }
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview,
return Inertia::render('Dashboard'); 'latestTasks' => $latestTasks,
'lastSevenDays' => $lastSevenDays,
'latestTeamActivity' => $latestTeamActivity,
'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
'weeklyHistory' => $weeklyHistory,
]);
} }
} }

View File

@@ -64,7 +64,6 @@ class HealthCheckController extends Controller
$response['app_env'] = app()->environment(); $response['app_env'] = app()->environment();
$response['app_timezone'] = config('app.timezone'); $response['app_timezone'] = config('app.timezone');
$response['app_force_https'] = config('app.force_https'); $response['app_force_https'] = config('app.force_https');
$response['session_secure'] = config('session.secure');
$response['trusted_proxies'] = config('trustedproxy.proxies'); $response['trusted_proxies'] = config('trustedproxy.proxies');
$headers = $request->headers->all(); $headers = $request->headers->all();
if (isset($headers['cookie'])) { if (isset($headers['cookie'])) {

View File

@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string> * @var array<int, class-string|string>
*/ */
protected $middleware = [ protected $middleware = [
\App\Http\Middleware\ForceHttps::class, // \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class, \Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class,

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
class ForceHttps
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
if (config('app.force_https', false)) {
URL::forceScheme('https');
$request->server->set('HTTPS', 'on');
$request->headers->set('X-Forwarded-Proto', 'https');
}
return $next($request);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use Illuminate\Foundation\Http\FormRequest;
class ApiTokenStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:1',
'max:255',
],
];
}
public function getName(): string
{
return $this->input('name');
}
}

View File

@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
// ID of the member to which the data should be transferred (destination)
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
];
}
public function getMemberId(): string
{
return (string) $this->input('member_id');
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ApiTokenCollection extends ResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ApiTokenResource::class;
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use App\Http\Resources\V1\BaseResource;
use App\Models\Passport\Token;
use Illuminate\Http\Request;
/**
* @property-read Token $resource
*/
class ApiTokenResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),
];
}
}

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use App\Http\Resources\V1\BaseResource;
use App\Models\Passport\Token;
use Illuminate\Http\Request;
/**
* @property-read Token $resource
*/
class ApiTokenWithAccessTokenResource extends BaseResource
{
private string $accessToken;
public function __construct(Token $resource, string $accessToken)
{
$this->accessToken = $accessToken;
parent::__construct($resource);
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),
// Additional fields
/** @var string $access_token Access token that can be used to authenticate requests */
'access_token' => $this->accessToken,
];
}
}

View File

@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
*/ */
class DetailedWithDataReportResource extends BaseResource class DetailedWithDataReportResource extends BaseResource

View File

@@ -6,7 +6,7 @@ namespace App\Listeners;
use App\Models\Member; use App\Models\Member;
use App\Models\User; use App\Models\User;
use App\Service\MemberService; use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded; use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -17,11 +17,8 @@ class RemovePlaceholder
*/ */
public function handle(TeamMemberAdded $event): void public function handle(TeamMemberAdded $event): void
{ {
$memberService = app(MemberService::class); /** @var UserService $userService */
$member = Member::query() $userService = app(UserService::class);
->whereBelongsTo($event->team, 'organization')
->whereBelongsTo($event->user, 'user')
->firstOrFail();
$placeholders = Member::query() $placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event): void { ->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
@@ -35,7 +32,7 @@ class RemovePlaceholder
foreach ($placeholders as $placeholder) { foreach ($placeholders as $placeholder) {
/** @var Member $placeholder */ /** @var Member $placeholder */
$placeholderUser = $placeholder->user; $placeholderUser = $placeholder->user;
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member); $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
$placeholder->delete(); $placeholder->delete();
$placeholderUser->delete(); $placeholderUser->delete();
} }

View File

@@ -7,7 +7,6 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory; use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -25,8 +24,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
* @property-read User $user * @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -62,14 +59,6 @@ class Member extends JetstreamMembership implements AuditableContract
return $this->belongsTo(Organization::class, 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
/**
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class, 'member_id');
}
/** /**
* @return HasMany<ProjectMember> * @return HasMany<ProjectMember>
*/ */

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode {}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Laravel\Passport\Client as PassportClient;
/**
* @property string $id
* @property string|null $user_id
* @property string $name
* @property string|null $secret
* @property string|null $provider
* @property string $redirect
* @property bool $personal_access_client
* @property bool $password_client
* @property bool $revoked
*/
class Client extends PassportClient
{
/** @use HasFactory<ClientFactory> */
use HasFactory;
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\RefreshToken as PassportRefreshToken;
class RefreshToken extends PassportRefreshToken {}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\Token as PassportToken;
/**
* @property string $id
* @property null|string $user_id
* @property string $client_id
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
*/
class Token extends PassportToken
{
/** @use HasFactory<TokenFactory> */
use HasFactory;
/**
* Get the client that the token belongs to.
*
* @return BelongsTo<Client, Token>
*/
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id', 'id');
}
}

View File

@@ -7,7 +7,6 @@ namespace App\Models;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Models\Passport\Token;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Panel; use Filament\Panel;
@@ -28,13 +27,14 @@ use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
* @property string $name * @property string $name
* @property string $email * @property string $email
* @property Carbon|null $email_verified_at * @property string|null $email_verified_at
* @property string|null $password * @property string|null $password
* @property string|null $two_factor_secret * @property string|null $two_factor_secret
* @property string $timezone * @property string $timezone
@@ -44,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Organization|null $currentOrganization * @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam * @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url * @property-read string $profile_photo_url
* @property-read Collection<int, Token> $tokens
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property string|null $current_team_id * @property string|null $current_team_id
@@ -197,17 +196,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return $this->hasMany(AuthCode::class); return $this->hasMany(AuthCode::class);
} }
/**
* Get the access tokens for the user.
*
* @return HasMany<Token>
*/
public function tokens(): HasMany
{
return $this->hasMany(Token::class, 'user_id')
->orderBy('created_at', 'desc');
}
/** /**
* @param Builder<User> $builder * @param Builder<User> $builder
*/ */

View File

@@ -9,7 +9,6 @@ use App\Models\FailedJob;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Passport\Token;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Tag; use App\Models\Tag;
@@ -30,6 +29,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -90,6 +90,12 @@ class AppServiceProvider extends ServiceProvider
); );
}); });
if (config('app.force_https', false)) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
request()->headers->set('X-Forwarded-Proto', 'https');
}
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore { $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
return new PermissionStore; return new PermissionStore;
}); });
@@ -101,6 +107,5 @@ class AppServiceProvider extends ServiceProvider
// Routing // Routing
Route::model('member', Member::class); Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class); Route::model('invitation', OrganizationInvitation::class);
Route::model('apiToken', Token::class);
} }
} }

View File

@@ -5,11 +5,6 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\PersonalAccessClient;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy; use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -47,16 +42,6 @@ class AuthServiceProvider extends ServiceProvider
// 'delete', // 'delete',
]); ]);
Passport::useTokenModel(Token::class);
Passport::useRefreshTokenModel(RefreshToken::class);
Passport::useAuthCodeModel(AuthCode::class);
Passport::useClientModel(Client::class);
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
// same as passport default above // same as passport default above
Jetstream::defaultApiTokenPermissions(['read']); Jetstream::defaultApiTokenPermissions(['read']);

View File

@@ -69,9 +69,6 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make() NavigationGroup::make()
->label('System') ->label('System')
->collapsed(), ->collapsed(),
NavigationGroup::make()
->label('Auth')
->collapsed(),
]) ])
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,

View File

@@ -80,8 +80,6 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::defaultApiTokenPermissions([]); Jetstream::defaultApiTokenPermissions([]);
Jetstream::role(Role::Owner->value, 'Owner', [ Jetstream::role(Role::Owner->value, 'Owner', [
'charts:view:own',
'charts:view:all',
'projects:view', 'projects:view',
'projects:view:all', 'projects:view:all',
'projects:create', 'projects:create',
@@ -125,7 +123,6 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder', 'members:invite-placeholder',
'members:change-ownership', 'members:change-ownership',
'members:make-placeholder', 'members:make-placeholder',
'members:merge-into',
'members:update', 'members:update',
'members:delete', 'members:delete',
'billing', 'billing',
@@ -136,8 +133,6 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Owner users can perform any action. There is only one owner per organization.'); ])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [ Jetstream::role(Role::Admin->value, 'Administrator', [
'charts:view:own',
'charts:view:all',
'projects:view', 'projects:view',
'projects:view:all', 'projects:view:all',
'projects:create', 'projects:create',
@@ -177,10 +172,8 @@ class JetstreamServiceProvider extends ServiceProvider
'invitations:resend', 'invitations:resend',
'invitations:remove', 'invitations:remove',
'members:view', 'members:view',
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:update', 'members:update',
'members:invite-placeholder',
'reports:view', 'reports:view',
'reports:create', 'reports:create',
'reports:update', 'reports:update',
@@ -188,8 +181,6 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Administrator users can perform any action, except accessing the billing dashboard.'); ])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [ Jetstream::role(Role::Manager->value, 'Manager', [
'charts:view:own',
'charts:view:all',
'projects:view', 'projects:view',
'projects:view:all', 'projects:view:all',
'projects:create', 'projects:create',
@@ -230,7 +221,6 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).'); ])->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', [ Jetstream::role(Role::Employee->value, 'Employee', [
'charts:view:own',
'projects:view', 'projects:view',
'tags:view', 'tags:view',
'tasks:view', 'tasks:view',

View File

@@ -33,11 +33,6 @@ class ColorService
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function isBuiltInColor(string $color): bool
{
return in_array($color, self::COLORS, true);
}
public function getRandomColor(?string $seed = null): string public function getRandomColor(?string $seed = null): string
{ {
if ($seed !== null) { if ($seed !== null) {

View File

@@ -11,7 +11,6 @@ use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidFormatException;
use Exception; use Exception;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException; use League\Csv\Exception as CsvException;
use League\Csv\Reader; use League\Csv\Reader;
@@ -24,7 +23,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
*/ */
private function getTags(string $tags): array private function getTags(string $tags): array
{ {
if (Str::trim($tags) === '') { if (trim($tags) === '') {
return []; return [];
} }
$tagsParsed = explode(', ', $tags); $tagsParsed = explode(', ', $tags);

View File

@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Service\ColorService;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class GenericProjectsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'name',
];
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if (isset($record['client']) && $record['client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['client'],
'organization_id' => $this->organization->id,
]);
}
if ($record['name'] !== '') {
$archivedAt = null;
if (isset($record['archived_at']) && $record['archived_at'] !== '') {
try {
$archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid');
}
}
$this->projectImportHelper->getKey([
'name' => $record['name'],
'organization_id' => $this->organization->id,
], [
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
'archived_at' => $archivedAt,
]);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[Override]
public function getName(): string
{
return __('importer.generic_projects.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.generic_projects.description');
}
}

View File

@@ -1,208 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class GenericTimeEntriesImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'description',
'billable',
'client',
'project',
'tags',
'start',
'end',
'task',
'user_name',
'user_email',
];
/**
* @return array<string>
*
* @throws ImportException
*/
private function getTags(string $tags): array
{
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(',', $tags);
$tagIds = [];
foreach ($tagsParsed as $tagParsed) {
$tagId = $this->tagImportHelper->getKey([
'name' => Str::trim($tagParsed),
'organization_id' => $this->organization->id,
]);
$tagIds[] = $tagId;
}
return $tagIds;
}
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
'email' => $record['user_email'],
], [
'name' => $record['user_name'],
'timezone' => 'UTC',
'is_placeholder' => true,
]);
$memberId = $this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->description = $record['description'];
if (! in_array($record['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['billable'] === 'true';
$timeEntry->tags = $this->getTags($record['tags']);
$timeEntry->is_imported = true;
try {
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
}
if ($start === null) {
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
}
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.generic_time_entries.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.generic_time_entries.description');
}
}

View File

@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class HarvestClientsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Client Name',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$this->clientImportHelper->getKey([
'name' => $record['Client Name'],
'organization_id' => $this->organization->id,
]);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.harvest_clients.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_clients.description');
}
}

View File

@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class HarvestProjectsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Client',
'Project',
'Budget',
'Billable Hours',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
if ($record['Project'] !== '') {
if (! isset($record['Budget']) || ! is_string($record['Budget'])) {
throw new ImportException('The value for "Budget" is invalid');
}
$estimatedTimeField = Str::replace(',', '.', $record['Budget']);
$estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null;
if ($estimatedTime === 0) {
$estimatedTime = null;
}
if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) {
throw new ImportException('The value for "Billable Hours" is invalid');
}
$billableHoursField = Str::replace(',', '.', $record['Billable Hours']);
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
$this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'color' => $this->colorService->getRandomColor(),
'client_id' => $clientId,
'estimated_time' => $estimatedTime,
'is_billable' => $billableHours > 0,
]);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.harvest_projects.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_projects.description');
}
}

View File

@@ -1,191 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class HarvestTimeEntriesImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Date',
'Hours',
'Client',
'Project',
'Task',
'Billable?',
'First Name',
'Last Name',
'Notes',
];
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$firstname = $record['First Name'];
$lastname = $record['Last Name'];
$userId = $this->userImportHelper->getKey([
'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test',
], [
'name' => $firstname.' '.$lastname,
'timezone' => 'UTC',
'is_placeholder' => true,
]);
$memberId = $this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => true,
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long');
}
$timeEntry->description = $record['Notes'];
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable?'] === 'Yes';
$timeEntry->tags = [];
$timeEntry->is_imported = true;
// Start & End
try {
$date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone);
} catch (InvalidFormatException) {
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
}
if ($date === null) {
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
}
if (! isset($record['Hours']) || ! is_string($record['Hours'])) {
throw new ImportException('Hours ("'.($record['Hours'] ?? '<null>').'") is invalid');
}
$hoursField = Str::replace(',', '.', $record['Hours']);
if (! is_numeric($hoursField)) {
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
}
$hours = (float) $hoursField;
$timeEntry->start = $date->copy()->startOfDay()->utc();
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[Override]
public function getName(): string
{
return __('importer.harvest_time_entries.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.harvest_time_entries.description');
}
}

View File

@@ -15,11 +15,6 @@ class ImporterProvider
'clockify_time_entries' => ClockifyTimeEntriesImporter::class, 'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::class, 'clockify_projects' => ClockifyProjectsImporter::class,
'solidtime' => SolidtimeImporter::class, 'solidtime' => SolidtimeImporter::class,
'harvest_projects' => HarvestProjectsImporter::class,
'harvest_time_entries' => HarvestTimeEntriesImporter::class,
'harvest_clients' => HarvestClientsImporter::class,
'generic_projects' => GenericProjectsImporter::class,
'generic_time_entries' => GenericTimeEntriesImporter::class,
]; ];
/** /**

View File

@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
*/ */
private function getTags(string $tags): array private function getTags(string $tags): array
{ {
if (Str::trim($tags) === '') { if (trim($tags) === '') {
return []; return [];
} }
$tagsParsed = json_decode($tags); $tagsParsed = json_decode($tags);

View File

@@ -5,11 +5,8 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Service\TimezoneService;
use Exception; use Exception;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Override; use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError; use ValueError;
@@ -96,22 +93,11 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($workspaceUsers as $workspaceUser) { foreach ($workspaceUsers as $workspaceUser) {
$timezone = Str::trim($workspaceUser->timezone);
if ($timezone === '') {
$timezone = 'UTC';
}
if (! app(TimezoneService::class)->isValid($timezone)) {
Log::warning('TogglDateImporter: Invalid timezone', [
'timezone' => $timezone,
]);
$timezone = 'UTC';
}
$userId = $this->userImportHelper->getKey([ $userId = $this->userImportHelper->getKey([
'email' => $workspaceUser->email, 'email' => $workspaceUser->email,
], [ ], [
'name' => $workspaceUser->name, 'name' => $workspaceUser->name,
'timezone' => $timezone, 'timezone' => $workspaceUser->timezone ?? 'UTC',
'is_placeholder' => true, 'is_placeholder' => true,
], (string) $workspaceUser->uid); ], (string) $workspaceUser->uid);
$this->memberImportHelper->getKey([ $this->memberImportHelper->getKey([

View File

@@ -11,7 +11,6 @@ use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidFormatException;
use Exception; use Exception;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException; use League\Csv\Exception as CsvException;
use League\Csv\Reader; use League\Csv\Reader;
@@ -24,7 +23,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
*/ */
private function getTags(string $tags): array private function getTags(string $tags): array
{ {
if (Str::trim($tags) === '') { if (trim($tags) === '') {
return []; return [];
} }
$tagsParsed = explode(', ', $tags); $tagsParsed = explode(', ', $tags);

View File

@@ -7,18 +7,15 @@ namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberRemoved; use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember; use Laravel\Jetstream\Events\AddingTeamMember;
@@ -78,7 +75,6 @@ class MemberService
* @throws ChangingRoleToPlaceholderIsNotAllowed * @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws OnlyOwnerCanChangeOwnership * @throws OnlyOwnerCanChangeOwnership
* @throws OrganizationNeedsAtLeastOneOwner * @throws OrganizationNeedsAtLeastOneOwner
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*/ */
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
{ {
@@ -86,9 +82,6 @@ class MemberService
if ($oldRole === Role::Owner) { if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner; throw new OrganizationNeedsAtLeastOneOwner;
} }
if ($oldRole === Role::Placeholder) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
if ($newRole === Role::Placeholder) { if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed; throw new ChangingRoleToPlaceholderIsNotAllowed;
} }
@@ -103,39 +96,6 @@ class MemberService
} }
} }
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
{
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($fromMember, 'member')
->update([
'user_id' => $toMember->user_id,
'member_id' => $toMember->getKey(),
]);
// Project members
ProjectMember::query()
->whereBelongsToOrganization($organization)
->whereBelongsTo($fromMember, 'member')
->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {
/** @var Builder<Project> $builder */
$builder->whereHas('members', function (Builder $builder) use ($toMember): void {
/** @var Builder<ProjectMember> $builder */
$builder->where('member_id', $toMember->getKey());
});
})
->update([
'user_id' => $toMember->user_id,
'member_id' => $toMember->getKey(),
]);
ProjectMember::query()
->whereBelongsToOrganization($organization)
->whereBelongsTo($fromMember, 'member')
->delete();
}
/** /**
* Change the ownership of an organization to a new user. * Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin. * The previous owner will be demoted to an admin.
@@ -172,7 +132,7 @@ class MemberService
$member->role = Role::Placeholder->value; $member->role = Role::Placeholder->value;
$member->save(); $member->save();
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser); $this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
if ($makeSureUserHasAtLeastOneOrganization) { if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasAtLeastOneOrganization($user);
} }

View File

@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
*/ */
private array $data; private array $data;
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } $data * } $data
*/ */
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup) public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)

View File

@@ -27,21 +27,21 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
*/ */
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
{ {
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null; $group1Select = null;
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
$group2Response[] = [ $group2Response[] = [
'key' => $group2 === '' ? null : (string) $group2, 'key' => $group2 === '' ? null : (string) $group2,
'seconds' => (int) $aggregate->get(0)->aggregate, 'seconds' => (int) $aggregate->get(0)->aggregate,
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null, 'cost' => (int) $aggregate->get(0)->cost,
'grouped_type' => null, 'grouped_type' => null,
'grouped_data' => null, 'grouped_data' => null,
]; ];
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
$group1Response[] = [ $group1Response[] = [
'key' => $group1 === '' ? null : (string) $group1, 'key' => $group1 === '' ? null : (string) $group1,
'seconds' => $group2ResponseSum, 'seconds' => $group2ResponseSum,
'cost' => $showBillableRate ? $group2ResponseCost : null, 'cost' => $group2ResponseCost,
'grouped_type' => $group2Type?->value, 'grouped_type' => $group2Type?->value,
'grouped_data' => $group2Response, 'grouped_data' => $group2Response,
]; ];
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
return [ return [
'seconds' => $group1ResponseSum, 'seconds' => $group1ResponseSum,
'cost' => $showBillableRate ? $group1ResponseCost : null, 'cost' => $group1ResponseCost,
'grouped_type' => $group1Type?->value, 'grouped_type' => $group1Type?->value,
'grouped_data' => $group1Response, 'grouped_data' => $group1Response,
]; ];
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
*/ */
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
{ {
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate); $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
$keysGroup1 = []; $keysGroup1 = [];
$keysGroup2 = []; $keysGroup2 = [];
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
* @param array<array{ * @param array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null|mixed, * grouped_type: null|mixed,
* grouped_data: null|mixed * grouped_data: null|mixed
* }> * }>
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
* @return array<array{ * @return array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null|mixed, * grouped_type: null|mixed,
* grouped_data: null|mixed * grouped_data: null|mixed
* }> * }>

View File

@@ -12,12 +12,11 @@ use App\Models\Organization;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
class UserService class UserService
{ {
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency): User
{ {
$user = new User; $user = new User;
$user->name = $name; $user->name = $name;
@@ -25,9 +24,6 @@ class UserService
$user->password = Hash::make($password); $user->password = Hash::make($password);
$user->timezone = $timezone; $user->timezone = $timezone;
$user->week_start = $weekStart; $user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save(); $user->save();
$organization = new Organization; $organization = new Organization;
@@ -49,10 +45,24 @@ class UserService
} }
/** /**
* This does NOT change the member id. * Assign all organization entities (time entries, project members) from one user to another.
* This should only be used in if you want to change a member to a placeholder! * This is useful when a placeholder user is replaced with a real user.
*/ */
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
{
/** @var Member|null $toMember */
$toMember = Member::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($toUser, 'user')
->first();
if ($toMember === null) {
throw new \InvalidArgumentException('User is not a member of the organization');
}
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
}
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
{ {
// Time entries // Time entries
TimeEntry::query() TimeEntry::query()
@@ -60,6 +70,7 @@ class UserService
->whereBelongsTo($fromUser, 'user') ->whereBelongsTo($fromUser, 'user')
->update([ ->update([
'user_id' => $toUser->getKey(), 'user_id' => $toUser->getKey(),
'member_id' => $toMember->getKey(),
]); ]);
// Project members // Project members
@@ -68,6 +79,7 @@ class UserService
->whereBelongsTo($fromUser, 'user') ->whereBelongsTo($fromUser, 'user')
->update([ ->update([
'user_id' => $toUser->getKey(), 'user_id' => $toUser->getKey(),
'member_id' => $toMember->getKey(),
]); ]);
} }

116
composer.lock generated
View File

@@ -128,16 +128,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.339.7", "version": "3.339.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "7b7e48ce7970c0416c5fda045df7b93948fbf643" "reference": "cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7b7e48ce7970c0416c5fda045df7b93948fbf643", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31",
"reference": "7b7e48ce7970c0416c5fda045df7b93948fbf643", "reference": "cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -220,9 +220,9 @@
"support": { "support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions", "forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.339.7" "source": "https://github.com/aws/aws-sdk-php/tree/3.339.6"
}, },
"time": "2025-02-05T19:06:15+00:00" "time": "2025-02-04T19:03:40+00:00"
}, },
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -692,16 +692,16 @@
}, },
{ {
"name": "composer/class-map-generator", "name": "composer/class-map-generator",
"version": "1.6.0", "version": "1.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/composer/class-map-generator.git", "url": "https://github.com/composer/class-map-generator.git",
"reference": "ffe442c5974c44a9343e37a0abcb1cc37319f5b9" "reference": "4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/ffe442c5974c44a9343e37a0abcb1cc37319f5b9", "url": "https://api.github.com/repos/composer/class-map-generator/zipball/4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915",
"reference": "ffe442c5974c44a9343e37a0abcb1cc37319f5b9", "reference": "4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -745,7 +745,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/composer/class-map-generator/issues", "issues": "https://github.com/composer/class-map-generator/issues",
"source": "https://github.com/composer/class-map-generator/tree/1.6.0" "source": "https://github.com/composer/class-map-generator/tree/1.5.0"
}, },
"funding": [ "funding": [
{ {
@@ -761,7 +761,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-02-05T10:05:34+00:00" "time": "2024-11-25T16:11:06+00:00"
}, },
{ {
"name": "composer/composer", "name": "composer/composer",
@@ -1519,16 +1519,16 @@
}, },
{ {
"name": "dedoc/scramble", "name": "dedoc/scramble",
"version": "v0.12.5", "version": "v0.12.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/dedoc/scramble.git", "url": "https://github.com/dedoc/scramble.git",
"reference": "de055584b61338dd6de6cc1cc20d79da6a442230" "reference": "9482d3c6285ebb16a067bd28b01e94899e1fddd8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/de055584b61338dd6de6cc1cc20d79da6a442230", "url": "https://api.github.com/repos/dedoc/scramble/zipball/9482d3c6285ebb16a067bd28b01e94899e1fddd8",
"reference": "de055584b61338dd6de6cc1cc20d79da6a442230", "reference": "9482d3c6285ebb16a067bd28b01e94899e1fddd8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1583,7 +1583,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/dedoc/scramble/issues", "issues": "https://github.com/dedoc/scramble/issues",
"source": "https://github.com/dedoc/scramble/tree/v0.12.5" "source": "https://github.com/dedoc/scramble/tree/v0.12.2"
}, },
"funding": [ "funding": [
{ {
@@ -1591,7 +1591,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-02-05T11:22:52+00:00" "time": "2025-02-03T12:23:33+00:00"
}, },
{ {
"name": "defuse/php-encryption", "name": "defuse/php-encryption",
@@ -2249,16 +2249,16 @@
}, },
{ {
"name": "filament/actions", "name": "filament/actions",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/actions.git", "url": "https://github.com/filamentphp/actions.git",
"reference": "0eee8f8eeea4422c279c2622442b2a9a101d558c" "reference": "cdefacc18993050cdd37e8e980ec66ca4109ae9a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/0eee8f8eeea4422c279c2622442b2a9a101d558c", "url": "https://api.github.com/repos/filamentphp/actions/zipball/cdefacc18993050cdd37e8e980ec66ca4109ae9a",
"reference": "0eee8f8eeea4422c279c2622442b2a9a101d558c", "reference": "cdefacc18993050cdd37e8e980ec66ca4109ae9a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2298,20 +2298,20 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2025-02-06T11:47:20+00:00" "time": "2025-01-31T11:08:24+00:00"
}, },
{ {
"name": "filament/filament", "name": "filament/filament",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/panels.git", "url": "https://github.com/filamentphp/panels.git",
"reference": "407b52ee57feed6f67706af2a18bc81d32aea3f1" "reference": "c92daf4b6e4b478be5d32d5e1b404ba92bb45414"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/407b52ee57feed6f67706af2a18bc81d32aea3f1", "url": "https://api.github.com/repos/filamentphp/panels/zipball/c92daf4b6e4b478be5d32d5e1b404ba92bb45414",
"reference": "407b52ee57feed6f67706af2a18bc81d32aea3f1", "reference": "c92daf4b6e4b478be5d32d5e1b404ba92bb45414",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2363,20 +2363,20 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2025-02-06T11:47:25+00:00" "time": "2025-01-31T11:08:29+00:00"
}, },
{ {
"name": "filament/forms", "name": "filament/forms",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/forms.git", "url": "https://github.com/filamentphp/forms.git",
"reference": "6ab0419fd3599aacdd7b2a8f69641cab6f158674" "reference": "8856b3b3714a0efae65d9f817fcc1934b6f34fda"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/6ab0419fd3599aacdd7b2a8f69641cab6f158674", "url": "https://api.github.com/repos/filamentphp/forms/zipball/8856b3b3714a0efae65d9f817fcc1934b6f34fda",
"reference": "6ab0419fd3599aacdd7b2a8f69641cab6f158674", "reference": "8856b3b3714a0efae65d9f817fcc1934b6f34fda",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2419,11 +2419,11 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2025-02-06T11:47:21+00:00" "time": "2025-01-31T11:08:23+00:00"
}, },
{ {
"name": "filament/infolists", "name": "filament/infolists",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/infolists.git", "url": "https://github.com/filamentphp/infolists.git",
@@ -2474,7 +2474,7 @@
}, },
{ {
"name": "filament/notifications", "name": "filament/notifications",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/notifications.git", "url": "https://github.com/filamentphp/notifications.git",
@@ -2526,7 +2526,7 @@
}, },
{ {
"name": "filament/support", "name": "filament/support",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/support.git", "url": "https://github.com/filamentphp/support.git",
@@ -2585,16 +2585,16 @@
}, },
{ {
"name": "filament/tables", "name": "filament/tables",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/tables.git", "url": "https://github.com/filamentphp/tables.git",
"reference": "d420b0b0cbe605c712ab054a43d51baf5095dc87" "reference": "1b1d9c3b837c11408ad28240dc9e3e33340d870b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/d420b0b0cbe605c712ab054a43d51baf5095dc87", "url": "https://api.github.com/repos/filamentphp/tables/zipball/1b1d9c3b837c11408ad28240dc9e3e33340d870b",
"reference": "d420b0b0cbe605c712ab054a43d51baf5095dc87", "reference": "1b1d9c3b837c11408ad28240dc9e3e33340d870b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2633,11 +2633,11 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2025-02-06T11:47:34+00:00" "time": "2025-01-31T11:08:58+00:00"
}, },
{ {
"name": "filament/widgets", "name": "filament/widgets",
"version": "v3.2.137", "version": "v3.2.136",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/widgets.git", "url": "https://github.com/filamentphp/widgets.git",
@@ -9112,27 +9112,27 @@
}, },
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.19.0", "version": "1.18.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git", "url": "https://github.com/spatie/laravel-package-tools.git",
"reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa" "reference": "ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa", "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78",
"reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa", "reference": "ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", "illuminate/contracts": "^9.28|^10.0|^11.0",
"php": "^8.0" "php": "^8.0"
}, },
"require-dev": { "require-dev": {
"mockery/mockery": "^1.5", "mockery/mockery": "^1.5",
"orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", "orchestra/testbench": "^7.7|^8.0|^9.0",
"pestphp/pest": "^1.23|^2.1|^3.1", "pestphp/pest": "^1.22|^2",
"phpunit/phpunit": "^9.5.24|^10.5|^11.5", "phpunit/phpunit": "^9.5.24|^10.5",
"spatie/pest-plugin-test-time": "^1.1|^2.2" "spatie/pest-plugin-test-time": "^1.1|^2.2"
}, },
"type": "library", "type": "library",
@@ -9160,7 +9160,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues", "issues": "https://github.com/spatie/laravel-package-tools/issues",
"source": "https://github.com/spatie/laravel-package-tools/tree/1.19.0" "source": "https://github.com/spatie/laravel-package-tools/tree/1.18.3"
}, },
"funding": [ "funding": [
{ {
@@ -9168,7 +9168,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-02-06T14:58:20+00:00" "time": "2025-01-22T08:51:18+00:00"
}, },
{ {
"name": "spatie/regex", "name": "spatie/regex",
@@ -14188,16 +14188,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "11.5.7", "version": "11.5.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "e1cb706f019e2547039ca2c839898cd5f557ee5d" "reference": "3c3ae14c90f244cdda95028c3e469028e8d1c02c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e1cb706f019e2547039ca2c839898cd5f557ee5d", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3c3ae14c90f244cdda95028c3e469028e8d1c02c",
"reference": "e1cb706f019e2547039ca2c839898cd5f557ee5d", "reference": "3c3ae14c90f244cdda95028c3e469028e8d1c02c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -14269,7 +14269,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.7" "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.6"
}, },
"funding": [ "funding": [
{ {
@@ -14285,7 +14285,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-02-06T16:10:05+00:00" "time": "2025-01-31T07:03:30+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",

View File

@@ -168,7 +168,7 @@ return [
| |
*/ */
'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')), 'secure' => env('SESSION_SECURE_COOKIE'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Client>
*/
class ClientFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users',
'redirect' => $this->faker->url(),
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(),
];
}
public function personalAccessClient(): self
{
return $this->state(function (array $attributes) {
return [
'personal_access_client' => true,
];
});
}
public function forUser(User $user): self
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
}
}

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Token>
*/
class TokenFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'client_id' => $this->faker->uuid,
'name' => null,
'scopes' => [],
'revoked' => false,
'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime,
];
}
public function forUser(User $user): self
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
}
public function forClient(Client $client): self
{
return $this->state(function (array $attributes) use ($client): array {
return [
'client_id' => $client->getKey(),
];
});
}
}

View File

@@ -34,29 +34,6 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
$this->deleteAll(); $this->deleteAll();
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
$personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API';
$personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false;
$personalAccessClient->provider = null;
$personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
'name' => 'Mister Overemployed', 'name' => 'Mister Overemployed',
'email' => 'overemployed@acme.test', 'email' => 'overemployed@acme.test',
@@ -78,8 +55,6 @@ class DatabaseSeeder extends Seeder
'name' => 'Acme Manager', 'name' => 'Acme Manager',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
$userAcmeManager->createToken('Testing Token 1')->accessToken;
$userAcmeManager->createToken('Testing Token 2')->accessToken;
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Admin', 'name' => 'Acme Admin',
'email' => 'admin@acme.test', 'email' => 'admin@acme.test',
@@ -184,6 +159,15 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com', 'email' => 'admin@example.com',
]); ]);
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
} }
private function deleteAll(): void private function deleteAll(): void

View File

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

View File

@@ -182,7 +182,7 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
# && composer clear-cache # && composer clear-cache
RUN cat .env RUN cat .env
#RUN php artisan env RUN php artisan env
RUN php artisan storage:link RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container RUN chmod +x /usr/local/bin/start-container

View File

@@ -1,69 +1,25 @@
import {test, expect} from '../playwright/fixtures'; import { test, expect } from '../playwright/fixtures';
import {PLAYWRIGHT_BASE_URL} from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
test('test that user name can be updated', async ({page}) => { test('test that user name can be updated', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME'); await page.getByLabel('Name').fill('NEW NAME');
await Promise.all([ await Promise.all([
page.getByRole('button', {name: 'Save'}).first().click(), page.getByRole('button', { name: 'Save' }).first().click(),
page.waitForResponse('**/user/profile-information'), page.waitForResponse('**/user/profile-information'),
]); ]);
await page.reload(); await page.reload();
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME'); await expect(page.getByLabel('Name')).toHaveValue('NEW NAME');
}); });
test.skip('test that user email can be updated', async ({page}) => { test.skip('test that user email can be updated', async ({ page }) => {
// this does not work because of email verification currently // this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000); const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`); await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', {name: 'Save'}).first().click(); await page.getByRole('button', { name: 'Save' }).first().click();
await page.reload(); await page.reload();
await expect(page.getByLabel('Email')).toHaveValue( await expect(page.getByLabel('Email')).toHaveValue(
`newemail+${emailId}@test.com` `newemail+${emailId}@test.com`
); );
}); });
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([
page.getByRole('button', {name: 'Create API Key'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).toContainText('API Token created successfully');
await page.getByRole('dialog').getByText('Close').click();
await expect(page.locator('body')).toContainText('NEW API KEY');
}
test('test that user can create an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
});
test('test that user can delete an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).not.toContainText('NEW API KEY');
});
test('test that user can revoke an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});

View File

@@ -191,7 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
'time_entry_range_selector' 'time_entry_range_selector'
); );
await timeEntryRangeElement.click(); await timeEntryRangeElement.click();
await page.getByTestId('time_entry_range_start').first().fill('1'); await page.getByTestId('time_picker_input').first().fill('1');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
@@ -204,7 +204,10 @@ test('test that updating a the start of an existing time entry in the overview w
(await response.json()).data.end !== null (await response.json()).data.end !== null
); );
}), }),
page.getByTestId('time_entry_range_end').press('Enter'), page
.getByTestId('time_entry_range_end')
.getByTestId('time_picker_input')
.press('Enter'),
]); ]);
}); });

View File

@@ -136,7 +136,6 @@ test('test that starting and updating the time while running works', async ({
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.url().includes('/time-entries') &&
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) ===
'application/json' && 'application/json' &&
@@ -153,7 +152,7 @@ test('test that starting and updating the time while running works', async ({
JSON.stringify([]) JSON.stringify([])
); );
}), }),
page.getByTestId('time_entry_time').press('Enter'), page.getByTestId('time_entry_time').press('Tab'),
]); ]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);

View File

@@ -18,7 +18,6 @@ export function newTimeEntryResponse(
) { ) {
return page.waitForResponse(async (response) => { return page.waitForResponse(async (response) => {
return ( return (
response.url().includes('/time-entries') &&
response.status() === status && response.status() === status &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) ===
'application/json' && 'application/json' &&

View File

@@ -1,36 +0,0 @@
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals';
import typescriptEslint from 'typescript-eslint';
import unusedImports from "eslint-plugin-unused-imports";
export default typescriptEslint.config(
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
{
extends: [
eslint.configs.recommended,
...typescriptEslint.configs.recommended,
...eslintPluginVue.configs['flat/recommended'],
],
files: ['**/*.{ts,vue,js}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.browser,
parserOptions: {
parser: typescriptEslint.parser,
},
},
plugins: {
"unused-imports": unusedImports,
},
rules: {
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
},
eslintConfigPrettier
);

View File

@@ -4,18 +4,14 @@ declare(strict_types=1);
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -41,10 +37,6 @@ return [
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members', OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured', PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan', FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
], ],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
]; ];

View File

@@ -5,28 +5,18 @@ declare(strict_types=1);
return [ return [
'clockify_time_entries' => [ 'clockify_time_entries' => [
'name' => 'Clockify Time Entries', 'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'. 'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '.
'2. In the same preferences page change the language of Clockfiy to English.<br>'. '2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '3. Now select the date range that you want to export in the right top. '.
'4. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'. 'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '. '<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
], ],
'generic_projects' => [
'name' => 'Generic Projects',
'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],
'generic_time_entries' => [
'name' => 'Generic Time Entries',
'description' => 'If you want to import many time entries yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],
'clockify_projects' => [ 'clockify_projects' => [
'name' => 'Clockify Projects', 'name' => 'Clockify Projects',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'. 'description' => '1. Go to PROJECTS in the navigation on the left.<br> '.
'2. Go to PROJECTS in the navigation on the left.<br> '. '2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '. '3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
'4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
], ],
'toggl_data_importer' => [ 'toggl_data_importer' => [
'name' => 'Toggl Data Importer', 'name' => 'Toggl Data Importer',
@@ -46,22 +36,4 @@ return [
'name' => 'Solidtime', 'name' => 'Solidtime',
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.', 'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
], ],
'harvest_clients' => [
'name' => 'Harvest Clients',
'description' => '1. Go to "Manage" (top navigation)<br>2. Click on the "Clients"'.
'<br>3. Click on "Import/Export" and in the dropdown "Export clients to CSV" '.
'<br>',
],
'harvest_projects' => [
'name' => 'Harvest Projects',
'description' => '1. Go to "Projects" (top navigation)<br>2. Click on the "Export" button'.
'<br>3. Select which projects you would like to export and select CSV format '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],
'harvest_time_entries' => [
'name' => 'Harvest Time Entries',
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],
]; ];

3414
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,60 +4,53 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint resources/js", "lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"lint:fix": "eslint --fix resources/js", "lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test", "test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api" "zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@inertiajs/vue3": "^1.0.0", "@inertiajs/vue3": "^1.0.0",
"@playwright/test": "^1.41.1", "@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.10.10", "@types/node": "^20.11.5",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^4.5.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.6.4", "axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^3.1.0",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"openapi-zod-client": "^1.16.2", "openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-nesting": "^12.1.5", "postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "^5.7.3", "typescript": "^5.3.3",
"vite": "^6.0.11", "vite": "^5.0.0",
"vite-plugin-checker": "^0.8.0", "vite-plugin-checker": "^0.7.2",
"vue": "^3.5.0", "vue": "^3.4.0",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.0.28"
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6", "@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1", "@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5", "@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-query": "^5.56.2", "@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0", "@tanstack/vue-query-devtools": "^5.58.0",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^11.1.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"focus-trap": "^7.6.0", "focus-trap": "^7.6.0",
"parse-duration": "^2.0.1", "parse-duration": "^1.1.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"vue-echarts": "^7.0.3" "vue-echarts": "^6.7.2"
},
"overrides": {
"vite-plugin-checker": {
"vue-tsc": "$vue-tsc"
}
} }
} }

View File

@@ -29,8 +29,6 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_FORCE_HTTPS" value="false"/>
<env name="TRUSTED_PROXIES" value="0.0.0.0/0,2000:0:0:0:0:0:0:0/3"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="pgsql_test"/> <env name="DB_CONNECTION" value="pgsql_test"/>
@@ -41,7 +39,5 @@
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="AUDITING_ENABLED" value="true"/> <env name="AUDITING_ENABLED" value="true"/>
<env name="NEWSLETTER_URL" value="null"/> <env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -112,7 +112,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideBlackFridayBanner = true"> <button @click="hideBlackFridayBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
@@ -142,7 +142,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideTrialBanner = true"> <button @click="hideTrialBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
@@ -174,7 +174,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideBlockedBanner = true"> <button @click="hideBlockedBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
@@ -206,7 +206,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true"> <button @click="hideFreeUpgradeBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>

View File

@@ -45,10 +45,10 @@ useFocus(clientNameInput, { initialValue: true });
v-model="client.name" v-model="client.name"
type="text" type="text"
placeholder="Client Name" placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="clientName" autocomplete="clientName" />
@keydown.enter="submit" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -46,10 +46,10 @@ useFocus(clientNameInput, { initialValue: true });
v-model="clientBody.name" v-model="clientBody.name"
type="text" type="text"
placeholder="Client Name" placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="clientName" autocomplete="clientName" />
@keydown.enter="submit" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -23,28 +23,28 @@ const props = defineProps<{
<div class="min-w-[150px]"> <div class="min-w-[150px]">
<button <button
v-if="canUpdateClients()" v-if="canUpdateClients()"
@click="emit('edit')"
:aria-label="'Edit Client ' + props.client.name" :aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit" data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('edit')">
<PencilSquareIcon <PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon> class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </button>
<button <button
@click.prevent="emit('archive')"
v-if="canUpdateClients()" v-if="canUpdateClients()"
:aria-label="'Archive Client ' + props.client.name" :aria-label="'Archive Client ' + props.client.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span> <span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button> </button>
<button <button
v-if="canDeleteClients()" v-if="canDeleteClients()"
@click="emit('delete')"
:aria-label="'Delete Client ' + props.client.name" :aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete" data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Client) {
<template> <template>
<MultiselectDropdown <MultiselectDropdown
search-placeholder="Search for a Client..." searchPlaceholder="Search for a Client..."
:items="clients" :items="clients"
:get-key-from-item="getKeyFromItem" :get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"> :get-name-for-item="getNameForItem">

View File

@@ -25,18 +25,18 @@ const createClient = ref(false);
style="grid-template-columns: 1fr 150px 200px 80px"> style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading> <ClientTableHeading></ClientTableHeading>
<div <div
v-if="clients.length === 0" class="col-span-2 py-24 text-center"
class="col-span-2 py-24 text-center"> v-if="clients.length === 0">
<UserCircleIcon <UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon> class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-white font-semibold">No clients found</h3> <h3 class="text-white font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5"> <p class="pb-5" v-if="canCreateClients()">
Create your first client now! Create your first client now!
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateClients()" v-if="canCreateClients()"
:icon="PlusIcon as Component"
@click="createClient = true" @click="createClient = true"
:icon="PlusIcon as Component"
>Create your First Client >Create your First Client
</SecondaryButton> </SecondaryButton>
</div> </div>

View File

@@ -38,8 +38,8 @@ const showEditModal = ref(false);
<template> <template>
<TableRow> <TableRow>
<ClientEditModal <ClientEditModal
v-model:show="showEditModal" :client="client"
:client="client"></ClientEditModal> v-model:show="showEditModal"></ClientEditModal>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span> <span>

View File

@@ -10,16 +10,16 @@ const emit = defineEmits<{
<template> <template>
<MoreOptionsDropdown label="Actions for the invitation"> <MoreOptionsDropdown label="Actions for the invitation">
<button <button
@click="emit('resend')"
data-testid="invitation_delete" data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('resend')">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon> <ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span> <span>Resend Invitation</span>
</button> </button>
<button <button
@click="emit('delete')"
data-testid="invitation_delete" data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>

View File

@@ -39,7 +39,7 @@ async function resendInvitation() {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.resendInvitationEmail( api.resendInvitationEmail(
undefined, {},
{ {
params: { params: {
invitation: props.invitation.id, invitation: props.invitation.id,

View File

@@ -18,10 +18,10 @@ defineEmits<{
<template> <template>
<BillableRateModal <BillableRateModal
@submit="$emit('submit')"
v-model:show="show" v-model:show="show"
v-model:saving="saving" v-model:saving="saving"
title="Update Member Billable Rate" title="Update Member Billable Rate">
@submit="$emit('submit')">
<p class="py-1 text-center"> <p class="py-1 text-center">
The billable rate of {{ memberName }} will be updated to The billable rate of {{ memberName }} will be updated to
<strong>{{ <strong>{{

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid'; import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
@@ -17,8 +17,8 @@ const model = defineModel<string>({
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
hiddenMembers?: ProjectMember[]; hiddenMembers: ProjectMember[];
disabled?: boolean; disabled: boolean;
}>(), }>(),
{ {
hiddenMembers: () => [] as ProjectMember[], hiddenMembers: () => [] as ProjectMember[],
@@ -46,6 +46,22 @@ const filteredMembers = computed<Member[]>(() => {
}); });
}); });
watch(filteredMembers, () => {
resetHighlightedItem();
});
onMounted(() => {
resetHighlightedItem();
});
function resetHighlightedItem() {
if (filteredMembers.value.length > 0) {
highlightedItemId.value = filteredMembers.value[0].id;
}
}
const highlightedItemId = ref<string | null>(null);
const currentValue = computed(() => { const currentValue = computed(() => {
if (model.value) { if (model.value) {
return members.value.find((member) => member.id === model.value)?.name; return members.value.find((member) => member.id === model.value)?.name;
@@ -60,7 +76,7 @@ const currentValue = computed(() => {
:items="filteredMembers" :items="filteredMembers"
:get-key-from-item="(member) => member.id" :get-key-from-item="(member) => member.id"
:get-name-for-item="(member) => member.name"> :get-name-for-item="(member) => member.name">
<template #trigger> <template v-slot:trigger>
<Badge <Badge
tag="button" tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5"> class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
@@ -68,7 +84,7 @@ const currentValue = computed(() => {
<div v-if="currentValue" class="flex-1 truncate"> <div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }} {{ currentValue }}
</div> </div>
<div v-else class="flex-1">Select a member...</div> <div class="flex-1" v-else>Select a member...</div>
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon> <ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
</Badge> </Badge>
</template> </template>

View File

@@ -108,11 +108,11 @@ const roleDescription = computed(() => {
v-model:saving="saving" v-model:saving="saving"
v-model:show="showBillableRateModal" v-model:show="showBillableRateModal"
:member-name="member.name" :member-name="member.name"
:new-billable-rate="memberBody.billable_rate" :newBillableRate="memberBody.billable_rate"
@submit="submitBillableRate"></MemberBillableRateModal> @submit="submitBillableRate"></MemberBillableRateModal>
<MemberOwnershipTransferConfirmModal <MemberOwnershipTransferConfirmModal
v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name" :member-name="member.name"
v-model:show="showOwnershipTransferConfirmModal"
@submit="submit"></MemberOwnershipTransferConfirmModal> @submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
@@ -127,9 +127,9 @@ const roleDescription = computed(() => {
<div> <div>
<InputLabel for="role" value="Role" /> <InputLabel for="role" value="Role" />
<MemberRoleSelect <MemberRoleSelect
v-model="memberBody.role"
class="mt-2" class="mt-2"
name="role"></MemberRoleSelect> name="role"
v-model="memberBody.role"></MemberRoleSelect>
</div> </div>
<div class="flex-1 text-xs flex items-center pt-6"> <div class="flex-1 text-xs flex items-center pt-6">
<p>{{ roleDescription }}</p> <p>{{ roleDescription }}</p>
@@ -140,28 +140,28 @@ const roleDescription = computed(() => {
<div> <div>
<InputLabel for="billableType" value="Billable" /> <InputLabel for="billableType" value="Billable" />
<MemberBillableSelect <MemberBillableSelect
class="mt-2"
name="billableType"
v-model=" v-model="
billableRateSelect billableRateSelect
" "></MemberBillableSelect>
class="mt-2"
name="billableType"></MemberBillableSelect>
</div> </div>
<div <div
v-if="billableRateSelect === 'custom-rate'" class="flex-1"
class="flex-1"> v-if="billableRateSelect === 'custom-rate'">
<InputLabel <InputLabel
for="memberBillableRate" for="memberBillableRate"
class="mb-2" class="mb-2"
value="Billable Rate" /> value="Billable Rate" />
<BillableRateInput <BillableRateInput
v-model="
memberBody.billable_rate
"
focus focus
class="w-full" class="w-full"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
@keydown.enter="saveWithChecks()"
name="memberBillableRate" name="memberBillableRate"
@keydown.enter="saveWithChecks()"></BillableRateInput> v-model="
memberBody.billable_rate
"></BillableRateInput>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -112,11 +112,11 @@ useFocus(clientNameInput, { initialValue: true });
v-if="isBillingActivated() && canManageBilling()" v-if="isBillingActivated() && canManageBilling()"
href="/billing"> href="/billing">
<PrimaryButton <PrimaryButton
type="button"
class="mt-6"
v-if=" v-if="
isBillingActivated() && canUpdateOrganization() isBillingActivated() && canUpdateOrganization()
" ">
type="button"
class="mt-6">
<CreditCardIcon class="w-5 h-5 me-2" /> <CreditCardIcon class="w-5 h-5 me-2" />
Go to Billing Go to Billing
</PrimaryButton> </PrimaryButton>
@@ -128,15 +128,15 @@ useFocus(clientNameInput, { initialValue: true });
<InputLabel for="email" value="Email" /> <InputLabel for="email" value="Email" />
<TextInput <TextInput
id="email" id="email"
name="email"
ref="memberEmailInput" ref="memberEmailInput"
v-model="addTeamMemberForm.email" v-model="addTeamMemberForm.email"
name="email"
type="text" type="text"
placeholder="Member Email" placeholder="Member Email"
@keydown.enter="submit"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="memberName" autocomplete="memberName" />
@keydown.enter="submit" />
<InputError :message="errors.email" class="mt-2" /> <InputError :message="errors.email" class="mt-2" />
</div> </div>

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import {ref} from 'vue';
import {api, type Member} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {useMutation} from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
import {useMembersStore} from "@/utils/useMembers";
const {handleApiRequestNotifications} = useNotificationsStore();
const show = defineModel('show', {default: false});
const saving = ref(false);
const props = defineProps<{
member: Member;
}>();
const turnToPlaceholderMutation = useMutation({
mutationFn: async () => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.makePlaceholder(undefined, {
params: {
organization: organizationId,
member: props.member.id
},
});
},
});
async function submit() {
saving.value = true;
await handleApiRequestNotifications(
() =>
turnToPlaceholderMutation.mutateAsync(),
'Deactivating the member was successful!',
'There was an error deactivating the user.',
() => {
show.value = false;
useMembersStore().fetchMembers()
}
);
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Deactivate User </span>
</div>
</template>
<template #content>
<p>
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
the organization. You will not be billed for inactive users and all time entries will be preserved.
</p>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Deactivate
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,109 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import {api, type Member} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
import {Badge} from "@/packages/ui/src";
import { useMutation } from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
member: Member;
}>();
const newMember = ref<string>('');
const mergeMember = useMutation({
mutationFn: async (newMemberId: string) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.mergeMember({
member_id: newMemberId,
}, {
params: {
organization: organizationId,
member: props.member.id
},
});
},
});
async function submit() {
const newMemberId = newMember.value;
if(newMemberId !== ''){
saving.value = true;
await handleApiRequestNotifications(
() =>
mergeMember.mutateAsync(newMemberId),
'Members successfully merged!',
'There was an error merging the members.',
() => {
show.value = false;
}
);
}
else{
addNotification(
'error',
'Please select a member to merge into.',
);
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Merge Member </span>
</div>
</template>
<template #content>
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
<div class="flex-1">
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<div class="flex-1 font-medium truncate">
{{ member.name }}
</div>
</Badge>
</div>
<div>
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
</div>
<div class="flex-1">
<MemberCombobox
v-model="newMember"
></MemberCombobox>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Merge Member
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,19 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid'; import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src'; import type { Member } from '@/packages/api/src';
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions'; import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
edit: []; edit: [];
merge: [];
makePlaceholder: [];
}>(); }>();
const props = defineProps<{ const props = defineProps<{
member: Member; member: Member;
}>(); }>();
</script> </script>
<template> <template>
@@ -23,39 +20,22 @@ const props = defineProps<{
<div class="min-w-[150px]"> <div class="min-w-[150px]">
<button <button
v-if="canUpdateMembers()" v-if="canUpdateMembers()"
@click="emit('edit')"
:aria-label="'Edit Member ' + props.member.name" :aria-label="'Edit Member ' + props.member.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('edit')">
<PencilSquareIcon <PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon> class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </button>
<button <button
v-if="canDeleteMembers()" v-if="canDeleteMembers()"
@click="emit('delete')"
:aria-label="'Delete Member ' + props.member.name" :aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete" data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>
<button
v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name"
data-testid="member_merge"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('merge')">
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
<span>Merge</span>
</button>
<button
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
:aria-label="'Make Member ' + props.member.name + ' a placeholder'"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('makePlaceholder')">
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon>
<span>Deactivate</span>
</button>
</div> </div>
</MoreOptionsDropdown> </MoreOptionsDropdown>
</template> </template>

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