Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
6b84ba67cd add tanstack table, add clients table 2024-09-27 15:05:44 +02:00
763 changed files with 8960 additions and 39532 deletions

61
.env.ci
View File

@@ -1,58 +1,57 @@
# Application
APP_NAME=solidtime APP_NAME=solidtime
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true SESSION_SECURE_COOKIE=false
# Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql_test DB_CONNECTION=pgsql_test
DB_TEST_HOST=127.0.0.1 DB_TEST_HOST=127.0.0.1
DB_TEST_PORT=5432 DB_TEST_PORT=5432
DB_TEST_DATABASE=laravel 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
FILESYSTEM_DISK=local
# Queue
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
# Session
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
# Mail MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
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 S3_ACCESS_KEY_ID=
FILESYSTEM_DISK=local S3_SECRET_ACCESS_KEY=
PUBLIC_FILESYSTEM_DISK=public S3_REGION=us-east-1
S3_BUCKET=
S3_USE_PATH_STYLE_ENDPOINT=false
# Passport PUSHER_APP_ID=
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92" PUSHER_APP_KEY=
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf" PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Auditing VITE_APP_NAME="${APP_NAME}"
AUDITING_ENABLED=true VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
# Telescope VITE_PUSHER_PORT="${PUSHER_PORT}"
TELESCOPE_ENABLED=false VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Services
GOTENBERG_URL=http://0.0.0.0:3000

View File

@@ -1,21 +1,18 @@
# Application
APP_NAME=solidtime APP_NAME=solidtime
APP_ENV=local 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
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
# Logging
LOG_CHANNEL=single LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=laravel DB_DATABASE=laravel
@@ -28,20 +25,18 @@ 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 MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
@@ -49,11 +44,17 @@ 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 PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Storage
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail S3_ACCESS_KEY_ID=sail
@@ -64,24 +65,18 @@ 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
GOTENBERG_URL=http://gotenberg:3000
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
VITE_HOST_NAME=vite.solidtime.test VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage 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}"
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -1,10 +1,9 @@
APP_NAME=solidtime APP_NAME=solidtime
APP_VERSION=0.0.0
APP_BUILD=0
VITE_APP_NAME=solidtime 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'],
}

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: solidtime-io

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

@@ -10,8 +10,6 @@ on:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
name: Build - Private name: Build - Private
jobs: jobs:
@@ -19,59 +17,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Copy .env template for production"
run: cp .env.production .env && cat .env
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -110,24 +67,6 @@ jobs:
- name: "Install npm dependencies in services extension" - name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Invoicing"
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Setup PHP with PECL extension" - name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@@ -148,9 +87,6 @@ jobs:
- name: "Activate services extension" - name: "Activate services extension"
run: php artisan module:enable Services run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci

View File

@@ -0,0 +1,90 @@
on:
push:
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'
- 'docker/prod/**'
workflow_dispatch:
name: Build - Public (Release)
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
- name: "Copy .env template for production"
run: cp .env.production .env
- name: "Install dependencies"
uses: php-actions/composer@v6
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"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
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"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -3,93 +3,37 @@ on:
branches: branches:
- main - main
- develop - develop
tags:
- '*'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions: name: Build - Public
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write packages: write
contents: read contents: read
attestations: write attestations: write
id-token: write id-token: write
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
name: Build - Public
jobs:
build:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: release-version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production" - name: "Copy .env template for production"
run: | run: cp .env.production .env
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .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
@@ -102,31 +46,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
@@ -134,85 +76,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
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

@@ -3,9 +3,6 @@ on:
push: push:
branches: branches:
- main - main
permissions:
contents: read
jobs: jobs:
api_docs: api_docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,6 @@
name: NPM Build name: NPM Build
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,8 +1,6 @@
name: NPM Lint name: NPM Lint
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,8 +1,6 @@
name: Publish API package to NPM name: Publish API package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -10,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

@@ -1,8 +1,6 @@
name: Publish UI package to NPM name: Publish UI package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -10,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

@@ -1,8 +1,7 @@
name: NPM Typecheck name: NPM Typecheck
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: Static code analysis (PHPStan) name: Static code analysis (PHPStan)
on: push on: push
permissions:
contents: read
jobs: jobs:
phpstan: phpstan:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: PHPUnit Tests name: PHPUnit Tests
on: push on: push
permissions:
contents: read
jobs: jobs:
phpunit: phpunit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -22,15 +20,7 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -65,7 +55,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.2 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

@@ -1,7 +1,5 @@
name: PHP Linting name: PHP Linting
on: push on: push
permissions:
contents: read
jobs: jobs:
pint: pint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -12,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

@@ -1,7 +1,5 @@
name: Playwright Tests name: Playwright Tests
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -29,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

@@ -13,7 +13,7 @@ solidtime is a modern open-source time tracking application for Freelancers and
- Time tracking: Track your time with a modern and easy-to-use interface - Time tracking: Track your time with a modern and easy-to-use interface
- Projects: Create and manage projects and assign project members - Projects: Create and manage projects and assign project members
- Tasks: Create and manage tasks and assign tasks to projects - Tasks: Create and manage tasks and assign tasks to project members
- Clients: Create and manage clients and assign clients to projects - Clients: Create and manage clients and assign clients to projects
- Billable rates: Set billable rates for projects, project members, organization members and organizations - Billable rates: Set billable rates for projects, project members, organization members and organizations
- Multiple organizations: Create and manage multiple organizations with one account - Multiple organizations: Create and manage multiple organizations with one account
@@ -28,11 +28,6 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/) If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Issues & Feature Requests
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
## Contributing ## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable. This project is in a very early stage. The structure and APIs are still subject to change and not stable.
@@ -40,8 +35,6 @@ Therefore, we do not currently accept any contributions, unless you are a member
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions. 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.
## Security ## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -32,12 +34,6 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User public function create(array $input): User
{ {
if (! config('app.enable_registration')) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
@@ -47,9 +43,9 @@ class CreateNewUser implements CreatesNewUsers
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
'email:rfc,strict', 'email',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder { new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
}), }),
@@ -76,11 +72,6 @@ class CreateNewUser implements CreatesNewUsers
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$startOfWeek = Weekday::Monday; $startOfWeek = Weekday::Monday;
$numberFormat = null;
$currencyFormat = null;
$dateFormat = null;
$intervalFormat = null;
$timeFormat = null;
$currency = null; $currency = null;
if ($ipLookupResponse !== null) { if ($ipLookupResponse !== null) {
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday; $startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
@@ -90,21 +81,30 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency; $currency = $ipLookupResponse->currency;
} }
$user = null; $user = null;
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void { $organization = null;
$userService = app(UserService::class); DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
$user = $userService->createUser( $user = User::create([
$input['name'], 'name' => $input['name'],
$input['email'], 'email' => $input['email'],
$input['password'], 'password' => Hash::make($input['password']),
$timezone ?? 'UTC', 'timezone' => $timezone ?? 'UTC',
$startOfWeek, 'week_start' => $startOfWeek,
$currency, ]);
$numberFormat,
$currencyFormat, $organization = new Organization;
$dateFormat, $organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$intervalFormat, $organization->personal_team = true;
$timeFormat $organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
); );
$user->ownedTeams()->save($organization);
}); });
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];

View File

@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'required', 'required',
'email', 'email',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) { (new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false); return $query->where('is_placeholder', '=', false);
}), }),

View File

@@ -7,16 +7,18 @@ namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\MemberService;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In; use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Contracts\AddsTeamMembers;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class AddOrganizationMember implements AddsTeamMembers class AddOrganizationMember implements AddsTeamMembers
{ {
@@ -34,7 +36,15 @@ class AddOrganizationMember implements AddsTeamMembers
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->firstOrFail(); ->firstOrFail();
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role)); AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
$organization->users()->attach(
$newOrganizationMember, ['role' => $role]
);
});
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
} }
/** /**
@@ -61,10 +71,10 @@ class AddOrganizationMember implements AddsTeamMembers
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
ExistsEloquent::make(User::class, 'email', function (Builder $builder) { (new ExistsEloquent(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
})->withMessage(__('We were unable to find a registered user with this email address.')), }))->withMessage(__('We were unable to find a registered user with this email address.')),
], ],
'role' => [ 'role' => [
'required', 'required',
@@ -83,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{ {
return function ($validator) use ($team, $email): void { return function ($validator) use ($team, $email) {
$validator->errors()->addIf( $validator->errors()->addIf(
$team->hasRealUserWithEmail($email), $team->hasRealUserWithEmail($email),
'email', 'email',

View File

@@ -4,11 +4,10 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Events\AfterCreateOrganization; use App\Events\AfterCreateOrganization;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -34,18 +33,16 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam'); ])->validateWithBag('createTeam');
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); $organization = new Organization;
$organization->name = $input['name'];
$organization->personal_team = false;
$organization->owner()->associate($user);
$organization->save();
$currency = null; $organization->users()->attach(
if ($ipLookupResponse !== null) { $user, [
$currency = $ipLookupResponse->currency; 'role' => Role::Owner->value,
} ]
$organization = app(OrganizationService::class)->createOrganization(
$input['name'],
$user,
false,
$currency
); );
$user->switchTeam($organization); $user->switchTeam($organization);

View File

@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use App\Service\UserService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use LogicException;
class UserCreateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:user:create
{ name : The name of the user }
{ email : The email of the user }
{ --ask-for-password : Ask for the password, otherwise the command will generate a random one }
{ --verify-email : Verify the email address of the user }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new user';
/**
* Execute the console command.
*/
public function handle(): int
{
$name = $this->argument('name');
$email = $this->argument('email');
$askForPassword = (bool) $this->option('ask-for-password');
$verifyEmail = (bool) $this->option('verify-email');
if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {
$this->error('User with email "'.$email.'" already exists.');
return self::FAILURE;
}
if ($askForPassword) {
$outputPassword = false;
$password = $this->secret('Enter the password');
} else {
$outputPassword = true;
$password = bin2hex(random_bytes(16));
}
$user = null;
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
$user = app(UserService::class)->createUser(
$name,
$email,
$password,
'UTC',
Weekday::Monday,
null,
verifyEmail: $verifyEmail
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}
$this->info('Created user "'.$name.'" ("'.$email.'")');
$this->line('ID: '.$user->getKey());
$this->line('Name: '.$name);
$this->line('Email: '.$email);
if ($outputPassword) {
$this->line('Password: '.$password);
}
$this->line('Timezone: '.$user->timezone);
$this->line('Week start: '.$user->week_start->value);
// Organization
$this->line('Currency: '.$organization->currency);
return self::SUCCESS;
}
}

View File

@@ -35,9 +35,7 @@ class UserVerifyCommand extends Command
$this->info('Start verifying user with email "'.$email.'"'); $this->info('Start verifying user with email "'.$email.'"');
/** @var User|null $user */ /** @var User|null $user */
$user = User::query()->where('email', $email) $user = User::where('email', $email)->first();
->where('is_placeholder', '=', false)
->first();
if ($user === null) { if ($user === null) {
$this->error('User with email "'.$email.'" not found.'); $this->error('User with email "'.$email.'" not found.');

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,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Report;
use App\Models\Report;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use LogicException;
class ReportSetExpiredToPrivateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'report:set-expired-to-private '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Makes public reports private if the public_until date has passed.';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Makes public reports private if the public_until date has passed...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$resetReports = 0;
Report::query()
->where('public_until', '<', Carbon::now())
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
/** @var Collection<int, Report> $reports */
foreach ($reports as $report) {
$publicUntil = $report->public_until;
if ($publicUntil === null) {
throw new LogicException('public_until should not be null');
}
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
$resetReports++;
if (! $dryRun) {
$report->is_public = false;
$report->share_secret = null;
$report->save();
}
}
});
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
return self::SUCCESS;
}
}

View File

@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use App\Service\ApiService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class SelfHostCheckForUpdateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:check-for-update';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$apiService = app(ApiService::class);
$latestVersion = $apiService->checkForUpdate();
if ($latestVersion === null) {
$this->error('Failed to check for update, check the logs for more information.');
return self::FAILURE;
}
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
return self::SUCCESS;
}
}

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -18,7 +18,6 @@ class SelfHostGenerateKeysCommand extends Command
*/ */
protected $signature = 'self-host:generate-keys protected $signature = 'self-host:generate-keys
{ --length=4096 : The length of the passport private key } { --length=4096 : The length of the passport private key }
{ --multi-line : Whether to output the keys in multiple lines }
{ --format=env : The format of the output (env, yaml) }'; { --format=env : The format of the output (env, yaml) }';
/** /**
@@ -35,7 +34,6 @@ class SelfHostGenerateKeysCommand extends Command
{ {
$format = $this->option('format'); $format = $this->option('format');
$key = RSA::createKey((int) $this->option('length')); $key = RSA::createKey((int) $this->option('length'));
$multiLine = (bool) $this->option('multi-line');
$publicKey = (string) $key->getPublicKey(); $publicKey = (string) $key->getPublicKey();
$privateKey = (string) $key; $privateKey = (string) $key;
@@ -43,17 +41,12 @@ class SelfHostGenerateKeysCommand extends Command
if ($format === 'env') { if ($format === 'env') {
$this->line('APP_KEY="'.$appKey.'"'); $this->line('APP_KEY="'.$appKey.'"');
if ($multiLine) { $this->line('PASSPORT_PRIVATE_KEY="'.$privateKey.'"');
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"'); $this->line('PASSPORT_PUBLIC_KEY="'.$publicKey.'"');
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"');
} else {
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"');
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"');
}
} elseif ($format === 'yaml') { } elseif ($format === 'yaml') {
$this->line('APP_KEY: "'.$appKey.'"'); $this->line('APP_KEY: "'.$appKey.'"');
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey)); $this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\n", "\n ", $privateKey));
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey)); $this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\n", "\n ", $publicKey));
} else { } else {
$this->error('Invalid format'); $this->error('Invalid format');

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use App\Service\ApiService;
use Illuminate\Console\Command;
class SelfHostTelemetryCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:telemetry';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$apiService = app(ApiService::class);
$success = $apiService->telemetry();
if (! $success) {
$this->error('Failed to send telemetry data, check the logs for more information.');
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
$query->where('is_placeholder', '=', false); $query->where('is_placeholder', '=', false);
}) })
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
/** @var Collection<int, TimeEntry> $timeEntries */ /** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) { foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user; $user = $timeEntry->user;

View File

@@ -17,18 +17,6 @@ class Kernel extends ConsoleKernel
$schedule->command('time-entry:send-still-running-mails') $schedule->command('time-entry:send-still-running-mails')
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes(); ->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->twiceDaily();
} }
/** /**

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum CurrencyFormat: string
{
use LaravelEnumHelper;
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
case SymbolBefore = 'symbol-before';
case SymbolAfter = 'symbol-after';
case SymbolBeforeWithSpace = 'symbol-before-with-space';
case SymbolAfterWithSpace = 'symbol-after-with-space';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum DateFormat: string
{
use LaravelEnumHelper;
case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';
case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';
case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';
case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';
case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';
case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';
public function toCarbonFormat(): string
{
return match ($this->value) {
self::PointSeparatedDMYYYY->value => 'j.n.Y',
self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',
};
}
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Maatwebsite\Excel\Excel;
enum ExportFormat: string
{
case CSV = 'csv';
case PDF = 'pdf';
case XLSX = 'xlsx';
case ODS = 'ods';
public function getFileExtension(): string
{
return match ($this) {
self::CSV => 'csv',
self::PDF => 'pdf',
self::XLSX => 'xlsx',
self::ODS => 'ods',
};
}
public function getExportPackageType(): string
{
return match ($this) {
self::CSV => Excel::CSV,
self::PDF => Excel::MPDF,
self::XLSX => Excel::XLSX,
self::ODS => Excel::ODS,
};
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum IntervalFormat: string
{
use LaravelEnumHelper;
case Decimal = 'decimal';
case HoursMinutes = 'hours-minutes';
case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';
case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
/**
* @info https://en.wikipedia.org/wiki/Decimal_separator
*/
enum NumberFormat: string
{
use LaravelEnumHelper;
case ThousandsPointDecimalComma = 'point-comma';
case ThousandsCommaDecimalPoint = 'comma-point';
case ThousandsSpaceDecimalComma = 'space-comma';
case ThousandsSpaceDecimalPoint = 'space-point';
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -4,12 +4,8 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryAggregationType: string enum TimeEntryAggregationType: string
{ {
use LaravelEnumHelper;
case Day = 'day'; case Day = 'day';
case Week = 'week'; case Week = 'week';
case Month = 'month'; case Month = 'month';
@@ -21,16 +17,6 @@ enum TimeEntryAggregationType: string
case Billable = 'billable'; case Billable = 'billable';
case Description = 'description'; case Description = 'description';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{
return match ($timeEntryAggregationTypeInterval) {
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
};
}
public function toInterval(): ?TimeEntryAggregationTypeInterval public function toInterval(): ?TimeEntryAggregationTypeInterval
{ {
return match ($this) { return match ($this) {

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeFormat: string
{
use LaravelEnumHelper;
case TwelveHours = '12-hours';
case TwentyFourHours = '24-hours';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -4,13 +4,10 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
enum Weekday: string enum Weekday: string
{ {
use LaravelEnumHelper;
case Monday = 'monday'; case Monday = 'monday';
case Tuesday = 'tuesday'; case Tuesday = 'tuesday';
case Wednesday = 'wednesday'; case Wednesday = 'wednesday';

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

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 FeatureIsNotAvailableInFreePlanApiException extends ApiException
{
public const string KEY = 'feature_is_not_available_in_free_plan';
}

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 PdfRendererIsNotConfiguredException extends ApiException
{
public const string KEY = 'pdf_renderer_is_not_configured';
}

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

@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
*/ */
public function register(): void public function register(): void
{ {
$this->reportable(function (Throwable $e): void { $this->reportable(function (Throwable $e) {
// //
}); });
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Extensions\Scramble; namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection; use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension; use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response; use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\Schema;
@@ -45,18 +44,9 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null; return null;
} }
$newType = new OpenApiObjectType; $type = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType)); $type->addProperty('data', (new ArrayType)->setItems($collectingType));
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) { $type->addProperty(
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['total'])
);
$newType->setRequired(['data', 'meta']);
} else {
$newType->addProperty(
'links', 'links',
(new OpenApiObjectType) (new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true)) ->addProperty('first', (new StringType)->nullable(true))
@@ -65,7 +55,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
->addProperty('next', (new StringType)->nullable(true)) ->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next']) ->setRequired(['first', 'last', 'prev', 'next'])
); );
$newType->addProperty( $type->addProperty(
'meta', 'meta',
(new OpenApiObjectType) (new OpenApiObjectType)
->addProperty('current_page', new IntegerType) ->addProperty('current_page', new IntegerType)
@@ -84,10 +74,9 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total']) ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
); );
$newType->setRequired(['data', 'links', 'meta']); $type->setRequired(['data', 'links', 'meta']);
}
return $newType; return $type;
} }
/** /**

View File

@@ -13,7 +13,7 @@ use Filament\Tables;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField; use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource class AuditResource extends Resource
{ {
@@ -38,8 +38,8 @@ class AuditResource extends Resource
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('auditable_id') Forms\Components\TextInput::make('auditable_id')
->required(), ->required(),
PrettyJsonField::make('old_values'), PrettyJson::make('old_values'),
PrettyJsonField::make('new_values'), PrettyJson::make('new_values'),
Forms\Components\Textarea::make('url'), Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'), Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent') Forms\Components\TextInput::make('user_agent')

View File

@@ -60,13 +60,8 @@ class ClientResource extends Resource
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

@@ -15,8 +15,7 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField; use Novadaemon\FilamentPrettyJson\PrettyJson;
/** /**
* @source https://gitlab.com/amvisor/filament-failed-jobs * @source https://gitlab.com/amvisor/filament-failed-jobs
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
// make text a little bit smaller because often a complete Stack Trace is shown: // make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']), TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4), PrettyJson::make('payload')->disabled()->columnSpan(4),
])->columns(4); ])->columns(4);
} }

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
class OrganizationInvitationResource extends Resource
{
protected static ?string $model = OrganizationInvitation::class;
protected static ?string $label = 'Invitations';
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 9;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabledOn(['edit'])
->required(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->sortable(),
Tables\Columns\TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at')
->label('Created At')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Updated At')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend')
->label('Resend')
->action(function (Collection $records): void {
foreach ($records as $organizationInvite) {
app(OrganizationInvitationService::class)->resend($organizationInvite);
}
}),
]),
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

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

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewOrganizationInvitation extends ViewRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -4,16 +4,9 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization; use App\Models\Organization;
use App\Service\DeletionService;
use App\Service\Export\ExportService; use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -53,28 +46,10 @@ class OrganizationResource extends Resource
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('personal_team') Forms\Components\Toggle::make('personal_team')
->label('Is personal?') ->label('Is personal?')
->hiddenOn(['create'])
->required(), ->required(),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email') ->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email']) ->searchable(['name', 'email'])
->disabledOn(['edit'])
->required(),
Select::make('date_format')
->options(DateFormat::toSelectArray())
->required(),
Select::make('currency_format')
->options(CurrencyFormat::toSelectArray())
->required(),
Select::make('interval_format')
->options(IntervalFormat::toSelectArray())
->required(),
Select::make('number_format')
->options(NumberFormat::toSelectArray())
->required(),
Select::make('time_format')
->options(TimeFormat::toSelectArray())
->required(), ->required(),
Forms\Components\Select::make('currency') Forms\Components\Select::make('currency')
->label('Currency') ->label('Currency')
@@ -87,7 +62,6 @@ class OrganizationResource extends Resource
return $select; return $select;
}) })
->required()
->searchable(), ->searchable(),
Forms\Components\TextInput::make('billable_rate') Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)') ->label('Billable rate (in Cents)')
@@ -96,16 +70,13 @@ class OrganizationResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
]); ]);
} }
@@ -125,7 +96,7 @@ class OrganizationResource extends Resource
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('currency'), Tables\Columns\TextColumn::make('currency'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100), ->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
@@ -140,10 +111,6 @@ class OrganizationResource extends Resource
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
Action::make('Export') Action::make('Export')
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) { ->action(function (Organization $record) {
@@ -155,7 +122,7 @@ class OrganizationResource extends Resource
->persistent() ->persistent()
->send(); ->send();
return response()->streamDownload(function () use ($file): void { return response()->streamDownload(function () use ($file) {
echo Storage::disk(config('filesystems.private'))->get($file); echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip'); }, 'export.zip');
} catch (\Exception $exception) { } catch (\Exception $exception) {
@@ -170,7 +137,7 @@ class OrganizationResource extends Resource
}), }),
Action::make('Import') Action::make('Import')
->icon('heroicon-o-inbox-arrow-down') ->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void { ->action(function (Organization $record, array $data) {
try { try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
if ($file === null) { if ($file === null) {
@@ -231,6 +198,8 @@ class OrganizationResource extends Resource
]), ]),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
]),
]); ]);
} }
@@ -238,7 +207,6 @@ class OrganizationResource extends Resource
{ {
return [ return [
UsersRelationManager::class, UsersRelationManager::class,
InvitationsRelationManager::class,
]; ];
} }

View File

@@ -15,6 +15,7 @@ class DeleteOrganization extends DeleteAction
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash'); $this->icon('heroicon-m-trash');
$this->action(function (): void { $this->action(function (): void {
$result = $this->process(function (Organization $record): bool { $result = $this->process(function (Organization $record): bool {

View File

@@ -4,33 +4,10 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages; namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Enums\Role;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateOrganization extends CreateRecord class CreateOrganization extends CreateRecord
{ {
protected static string $resource = OrganizationResource::class; protected static string $resource = OrganizationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['personal_team'] = false;
return $data;
}
protected function afterCreate(): void
{
/** @var Organization $organization */
$organization = $this->record;
$user = $organization->owner;
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
}
} }

View File

@@ -15,8 +15,7 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static ?string $title = 'Invitations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class)
->label('Role')
->rules([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
])
->required(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('email')
->modelLabel('Invitation')
->pluralModelLabel('Invitations')
->columns([
Tables\Columns\TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'),
])
->headerActions([
Tables\Actions\CreateAction::make()
->icon('heroicon-s-plus')
->using(function (array $data, string $model): Model {
/** @var Organization $ownerRecord */
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
}),
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}
}

View File

@@ -5,24 +5,17 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers; namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\BillableRateService;
use App\Service\MemberService;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction; use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Validation\Rule;
class UsersRelationManager extends RelationManager class UsersRelationManager extends RelationManager
{ {
@@ -43,41 +36,21 @@ class UsersRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
return $table return $table
->recordTitleAttribute('name') ->recordTitleAttribute('name')
->columns([ ->columns([
Tables\Columns\TextColumn::make('name'), Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'), Tables\Columns\TextColumn::make('role'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money($organization->currency, divideBy: 100), ->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make() Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
->form(fn (AttachAction $action): array => [
$action->getRecordSelect(), $action->getRecordSelect(),
Select::make('role') Select::make('role')
->required() ->options(Role::class),
->options(Role::class)
->rule([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
]), ]),
]) ])
->label('Add user')
->modalHeading('Add user')
->icon('heroicon-s-plus')
->using(function (User $record, array $data): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
}),
])
->actions([ ->actions([
Action::make('view') Action::make('view')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
@@ -85,55 +58,13 @@ class UsersRelationManager extends RelationManager
->url(fn (User $record): string => UserResource::getUrl('view', [ ->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make() Tables\Actions\EditAction::make(),
->using(function (User $record, array $data): User { Tables\Actions\DetachAction::make(),
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['billable_rate'] !== $member->billable_rate) {
$member->billable_rate = $data['billable_rate'];
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
}
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (User $record): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($record, 'user')
->whereBelongsTo($organization, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $organization);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]); ]);
} }
} }

View File

@@ -29,7 +29,6 @@ class ProjectMemberResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')

View File

@@ -15,8 +15,7 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -45,7 +45,6 @@ class ProjectResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('organization_id') Forms\Components\Select::make('organization_id')
@@ -72,13 +71,8 @@ class ProjectResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,8 +15,7 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -1,141 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('description')
->label('Description')
->nullable()
->maxLength(255),
Toggle::make('is_public')
->label('Is public?')
->required(),
DateTimePicker::make('public_until')
->label('Public until')
->nullable(),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJsonField::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties');
})
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->searchable()
->sortable(),
ToggleColumn::make('is_public')
->label('Is public?')
->sortable(),
TextColumn::make('organization.name')
->searchable()
->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([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->actions([
Action::make('public-view')
->label('Public')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
->url(fn (Report $record): string => $record->getShareableLink(), true),
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

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

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewReport extends ViewRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -60,13 +60,8 @@ class TagResource extends Resource
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

@@ -15,8 +15,7 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -61,13 +61,8 @@ class TaskResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,8 +15,7 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -92,13 +92,8 @@ class TimeEntryResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,8 +15,7 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

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

@@ -5,28 +5,21 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider;
use Exception; use Exception;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
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
@@ -41,9 +34,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([
@@ -60,25 +50,12 @@ 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'),
->hiddenOn(['create'])
->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())
@@ -90,39 +67,15 @@ 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') ->required(fn (string $context): bool => $context === 'create')
->maxLength(255), ->maxLength(255),
TextInput::make('password_create')
->password()
->label('Password')
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Forms\Components\Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
}
return $select;
})
->required()
->visibleOn(['create'])
->searchable(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
]); ]);
} }
@@ -192,30 +145,11 @@ class UserResource extends Resource
} }
}), }),
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void {
try {
app(DeletionService::class)->deleteUser($record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email') Tables\Actions\BulkActionGroup::make([
->icon('heroicon-o-paper-airplane') Tables\Actions\DeleteBulkAction::make(),
->action(function (Collection $records): void { ]),
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]); ]);
} }

View File

@@ -4,29 +4,24 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Enums\Weekday;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\UserService;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
protected function handleRecordCreation(array $data): User protected function afterCreate(): void
{ {
$userService = app(UserService::class); /** @var User $user */
$user = $userService->createUser( $user = $this->record;
$data['name'],
$data['email'],
$data['password_create'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
verifyEmail: (bool) $data['is_email_verified']
);
return $user; $user->ownedTeams()->save(Organization::forceCreate([
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
'personal_team' => true,
]));
} }
} }

View File

@@ -15,8 +15,7 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -7,7 +7,6 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class ViewUser extends ViewRecord class ViewUser extends ViewRecord
{ {
@@ -16,7 +15,6 @@ class ViewUser extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Impersonate::make()->record($this->getRecord()),
EditAction::make('edit') EditAction::make('edit')
->icon('heroicon-s-pencil'), ->icon('heroicon-s-pencil'),
]; ];

View File

@@ -5,18 +5,15 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\RelationManagers; namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -30,6 +27,10 @@ class OrganizationsRelationManager extends RelationManager
->schema([ ->schema([
Select::make('role') Select::make('role')
->options(Role::class), ->options(Role::class),
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->numeric(),
]); ]);
} }
@@ -40,11 +41,15 @@ class OrganizationsRelationManager extends RelationManager
->columns([ ->columns([
TextColumn::make('name'), TextColumn::make('name'),
TextColumn::make('role'), TextColumn::make('role'),
TextColumn::make('membership.billable_rate') TextColumn::make('billable_rate')
->label('Billable rate') ->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Select::make('role')
->options(Role::class),
]),
]) ])
->actions([ ->actions([
Action::make('view') Action::make('view')
@@ -53,48 +58,13 @@ class OrganizationsRelationManager extends RelationManager
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make() Tables\Actions\EditAction::make(),
->using(function (Organization $record, array $data): Organization { Tables\Actions\DetachAction::make(),
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (Organization $record): void {
/** @var User $user */
$user = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($user, 'user')
->whereBelongsTo($record, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]); ]);
} }
} }

View File

@@ -14,7 +14,7 @@ class ActiveUserOverview extends BaseWidget
{ {
protected static ?int $sort = 1; protected static ?int $sort = 1;
protected ?string $heading = 'A Registrations'; protected static ?string $heading = 'A Registrations';
protected function getCards(): array protected function getCards(): array
{ {

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use Filament\Widgets\Widget;
use Illuminate\Support\Facades\Cache;
class ServerOverview extends Widget
{
protected static string $view = 'filament.widgets.server-overview';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
/** @var string|null $currentVersion */
$currentVersion = config('app.version');
/** @var string|null $build */
$build = config('app.build');
$latestVersion = Cache::get('latest_version', null);
$needsUpdate = false;
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
$needsUpdate = true;
}
return [
'version' => $currentVersion,
'build' => $build,
'environment' => config('app.env'),
'currentVersion' => $latestVersion,
'needsUpdate' => $needsUpdate,
];
}
}

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);
}
}

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