mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
18 Commits
feature/do
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5aff20fc | ||
|
|
9e5aa77e41 | ||
|
|
0791a68283 | ||
|
|
e66679274d | ||
|
|
717fd35d76 | ||
|
|
5a3a5995cc | ||
|
|
a8e6d28eab | ||
|
|
9c9aeeab0f | ||
|
|
8a1253e101 | ||
|
|
661fa25da1 | ||
|
|
d77048a7dd | ||
|
|
4676af9b40 | ||
|
|
18c8e62228 | ||
|
|
e7703aef64 | ||
|
|
86d0497000 | ||
|
|
522f7d2bd2 | ||
|
|
2f807e4808 | ||
|
|
93d9db349b |
@@ -1,54 +0,0 @@
|
|||||||
.git
|
|
||||||
**/.git
|
|
||||||
.gitmodules
|
|
||||||
**/.gitmodules
|
|
||||||
.github
|
|
||||||
.DS_Store
|
|
||||||
.fleet
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.log
|
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
k8s
|
|
||||||
docs
|
|
||||||
e2e
|
|
||||||
tests
|
|
||||||
|
|
||||||
docker-compose.yml
|
|
||||||
docker/local
|
|
||||||
|
|
||||||
.phpunit.cache
|
|
||||||
.phpunit.result.cache
|
|
||||||
coverage
|
|
||||||
test-results
|
|
||||||
playwright-report
|
|
||||||
blob-report
|
|
||||||
playwright/.cache
|
|
||||||
openapi.json
|
|
||||||
playwright
|
|
||||||
playwright.config.ts
|
|
||||||
vitest.config.ts
|
|
||||||
phpunit.xml
|
|
||||||
phpstan.neon
|
|
||||||
pint.json
|
|
||||||
eslint.config.mjs
|
|
||||||
tsconfig.json
|
|
||||||
jsconfig.json
|
|
||||||
postcss.config.js
|
|
||||||
tailwind.config.js
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
extensions/*/node_modules
|
|
||||||
|
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
|
||||||
auth.json
|
|
||||||
.env.backup
|
|
||||||
.rnd
|
|
||||||
|
|
||||||
_ide_helper.php
|
|
||||||
.phpstorm.meta.php
|
|
||||||
|
|
||||||
storage/logs/*
|
|
||||||
storage/*.key
|
|
||||||
12
.env.ci
12
.env.ci
@@ -34,12 +34,7 @@ SESSION_DRIVER=database
|
|||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=log
|
||||||
MAIL_HOST=localhost
|
|
||||||
MAIL_PORT=1025
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=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="solidtime"
|
||||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||||
@@ -60,7 +55,4 @@ AUDITING_ENABLED=true
|
|||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://localhost:3000
|
GOTENBERG_URL=http://0.0.0.0:3000
|
||||||
|
|
||||||
# Octane
|
|
||||||
OCTANE_SERVER=frankenphp
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
|
APP_ENABLE_REGISTRATION=true
|
||||||
APP_ENABLE_REGISTRATION=on
|
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
@@ -78,9 +77,6 @@ TELESCOPE_ENABLED=false
|
|||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
# Octane
|
|
||||||
OCTANE_SERVER=frankenphp
|
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
|
|||||||
27
.github/VOUCHED.td
vendored
27
.github/VOUCHED.td
vendored
@@ -1,27 +0,0 @@
|
|||||||
# Vouched contributors for solidtime.
|
|
||||||
#
|
|
||||||
# One handle per line, without the leading @, sorted alphabetically.
|
|
||||||
# Prefix a handle with - to denounce them, optionally followed by a reason.
|
|
||||||
# Format reference: https://github.com/mitchellh/vouch
|
|
||||||
#
|
|
||||||
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
|
|
||||||
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
|
|
||||||
# discussion and the vouch workflows will update this file.
|
|
||||||
#
|
|
||||||
# Collaborators with write access and bots are always allowed and do not need
|
|
||||||
# an entry here.
|
|
||||||
#
|
|
||||||
# Seeded 2026-07-25 from the authors of every merged pull request.
|
|
||||||
|
|
||||||
agross
|
|
||||||
akolenda
|
|
||||||
bufferhead-code
|
|
||||||
candideu
|
|
||||||
kasparrosin
|
|
||||||
korridor
|
|
||||||
nikbucher tasks table sorting
|
|
||||||
onatcer
|
|
||||||
shrootbuck
|
|
||||||
smilebeda
|
|
||||||
thespyder
|
|
||||||
utlark
|
|
||||||
43
.github/workflows/build-onpremise.yml
vendored
43
.github/workflows/build-onpremise.yml
vendored
@@ -8,8 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-onpremise.yml'
|
- '.github/workflows/build-onpremise.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'extensions/manifest.json'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -48,9 +46,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -93,23 +91,14 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Read extension manifest"
|
|
||||||
id: extension-manifest
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
repository: solidtime-io/extension-invoicing
|
||||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
@@ -135,27 +124,27 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -174,7 +163,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -188,25 +177,25 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|||||||
47
.github/workflows/build-private.yml
vendored
47
.github/workflows/build-private.yml
vendored
@@ -8,8 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-private.yml'
|
- '.github/workflows/build-private.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'extensions/manifest.json'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -35,9 +33,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: version
|
id: version
|
||||||
@@ -70,27 +68,14 @@ jobs:
|
|||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Read extension manifest"
|
|
||||||
id: extension-manifest
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
|
|
||||||
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
|
|
||||||
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
|
|
||||||
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
repository: solidtime-io/extension-billing
|
||||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
|
||||||
path: extensions/Billing
|
path: extensions/Billing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||||
|
|
||||||
@@ -108,10 +93,9 @@ jobs:
|
|||||||
run: cd extensions/Billing && npm ci
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
- name: "Checkout services extension"
|
- name: "Checkout services extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
repository: solidtime-io/extension-services
|
||||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
|
||||||
path: extensions/Services
|
path: extensions/Services
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||||
|
|
||||||
@@ -127,10 +111,9 @@ jobs:
|
|||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
repository: solidtime-io/extension-invoicing
|
||||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
@@ -177,7 +160,7 @@ jobs:
|
|||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
@@ -185,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
||||||
tags: |
|
tags: |
|
||||||
@@ -196,13 +179,13 @@ jobs:
|
|||||||
type=sha,format=long
|
type=sha,format=long
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push"
|
- name: "Build and push"
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
33
.github/workflows/build-public.yml
vendored
33
.github/workflows/build-public.yml
vendored
@@ -8,7 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-public.yml'
|
- '.github/workflows/build-public.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -48,9 +47,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -93,7 +92,7 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -110,34 +109,34 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKERHUB_REPO }}
|
${{ env.DOCKERHUB_REPO }}
|
||||||
${{ env.GHCR_REPO }}
|
${{ env.GHCR_REPO }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub Container Registry"
|
- name: "Login to Docker Hub Container Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -156,7 +155,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -170,31 +169,31 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GHCR"
|
- name: "Login to GHCR"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKERHUB_REPO }}
|
${{ env.DOCKERHUB_REPO }}
|
||||||
|
|||||||
4
.github/workflows/generate-api-docs.yml
vendored
4
.github/workflows/generate-api-docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
run: php artisan scramble:export --path=build/api-docs.json
|
run: php artisan scramble:export --path=build/api-docs.json
|
||||||
|
|
||||||
- name: "Upload API docs to GitHub"
|
- name: "Upload API docs to GitHub"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: api-docs.json
|
name: api-docs.json
|
||||||
path: build/api-docs.json
|
path: build/api-docs.json
|
||||||
|
|||||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
27
.github/workflows/npm-test-unit.yml
vendored
27
.github/workflows/npm-test-unit.yml
vendored
@@ -1,27 +0,0 @@
|
|||||||
name: NPM Test Unit
|
|
||||||
|
|
||||||
on: [push]
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
env:
|
|
||||||
TZ: UTC
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: "Use Node.js"
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: "Run vitest"
|
|
||||||
run: npm run test:unit
|
|
||||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: "Run composer install"
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v7.0.0
|
uses: codecov/codecov-action@v5.4.3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.6
|
uses: aglipanci/laravel-pint-action@2.5
|
||||||
with:
|
with:
|
||||||
configPath: "pint.json"
|
configPath: "pint.json"
|
||||||
|
|||||||
69
.github/workflows/playwright.yml
vendored
69
.github/workflows/playwright.yml
vendored
@@ -6,18 +6,10 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
|
||||||
shardTotal: [8]
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mailpit:
|
mailpit:
|
||||||
image: 'axllent/mailpit:latest'
|
image: 'axllent/mailpit:latest'
|
||||||
ports:
|
|
||||||
- 1025:1025
|
|
||||||
- 8025:8025
|
|
||||||
pgsql_test:
|
pgsql_test:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
env:
|
env:
|
||||||
@@ -35,10 +27,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -65,63 +57,22 @@ jobs:
|
|||||||
- name: "Build Frontend"
|
- name: "Build Frontend"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Install FrankenPHP"
|
- name: "Run Laravel Server"
|
||||||
run: |
|
run: php artisan serve > /dev/null 2>&1 &
|
||||||
ARCH="$(uname -m)"
|
|
||||||
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
|
|
||||||
chmod +x /usr/local/bin/frankenphp
|
|
||||||
|
|
||||||
- name: "Run Laravel Octane Server"
|
|
||||||
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
|
|
||||||
env:
|
|
||||||
OCTANE_SERVER: frankenphp
|
|
||||||
|
|
||||||
- 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 --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
run: npx playwright test
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||||
MAILPIT_BASE_URL: 'http://localhost:8025'
|
|
||||||
|
|
||||||
- name: "Upload blob report"
|
- name: "Upload test results"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
name: test-results
|
||||||
path: blob-report/
|
path: test-results/
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
merge-reports:
|
|
||||||
if: always()
|
|
||||||
needs: [test]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: "Setup node"
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: "Download blob reports"
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
path: all-blob-reports
|
|
||||||
pattern: blob-report-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: "Merge reports"
|
|
||||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
|
||||||
|
|
||||||
- name: "Upload merged HTML report"
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
|||||||
75
.github/workflows/vouch-check-pr.yml
vendored
75
.github/workflows/vouch-check-pr.yml
vendored
@@ -1,75 +0,0 @@
|
|||||||
name: Vouch (check PR)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
(github.event_name == 'issue_comment' &&
|
|
||||||
github.event.issue.pull_request &&
|
|
||||||
contains(github.event.comment.body, '/recheck'))
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
|
|
||||||
- name: "Measure diff size"
|
|
||||||
id: size
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
|
|
||||||
# Changes to these files do not count towards the 50-line limit.
|
|
||||||
# One extended regex per line, matched against the whole repo-relative
|
|
||||||
# path, so use a leading .* to match a file in any directory.
|
|
||||||
IGNORED: |
|
|
||||||
package-lock\.json
|
|
||||||
composer\.lock
|
|
||||||
tests/.*
|
|
||||||
e2e/.*
|
|
||||||
.*\.(test|spec)\.(ts|js|vue)
|
|
||||||
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# An empty list yields "^()$", which matches no filename. grep exits
|
|
||||||
# 1 on an empty list, so swallow that rather than fail the step.
|
|
||||||
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
|
|
||||||
ignored="^($(join <<<"$IGNORED"))$"
|
|
||||||
|
|
||||||
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
|
|
||||||
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
|
|
||||||
awk -F'\t' -v ignored="$ignored" '
|
|
||||||
$1 ~ ignored { next }
|
|
||||||
{ n += $2 }
|
|
||||||
END { print n+0 }')
|
|
||||||
echo "total=$total" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Countable diff size: $total line(s)"
|
|
||||||
|
|
||||||
- name: "Small patch (denounced users still blocked)"
|
|
||||||
if: fromJSON(steps.size.outputs.total) <= 50
|
|
||||||
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
|
||||||
with:
|
|
||||||
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
auto-close: true
|
|
||||||
require-vouch: false
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Full vouch required"
|
|
||||||
if: fromJSON(steps.size.outputs.total) > 50
|
|
||||||
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
|
||||||
with:
|
|
||||||
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
auto-close: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Vouch (manage by discussion)
|
|
||||||
|
|
||||||
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
|
|
||||||
|
|
||||||
on:
|
|
||||||
discussion_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: vouch-manage
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
discussions: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
manage:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: "Apply vouch command"
|
|
||||||
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
|
|
||||||
with:
|
|
||||||
discussion-number: ${{ github.event.discussion.number }}
|
|
||||||
comment-node-id: ${{ github.event.comment.node_id }}
|
|
||||||
roles: admin,maintain,write
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
35
.github/workflows/vouch-manage-by-issue.yml
vendored
35
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -1,35 +0,0 @@
|
|||||||
name: Vouch (manage by issue)
|
|
||||||
|
|
||||||
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
|
|
||||||
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: vouch-manage
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
manage:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: "Apply vouch command"
|
|
||||||
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
|
|
||||||
with:
|
|
||||||
issue-id: ${{ github.event.issue.number }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
roles: admin,maintain,write
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,10 +26,9 @@ yarn-error.log
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/coverage
|
/coverage
|
||||||
/extensions/*
|
/extensions
|
||||||
!/extensions/.gitkeep
|
!/extensions/.gitkeep
|
||||||
!/extensions/extensions_autoload.php
|
!/extensions/extensions_autoload.php
|
||||||
!/extensions/manifest.json
|
|
||||||
/auth.json
|
/auth.json
|
||||||
/modules_statuses.json
|
/modules_statuses.json
|
||||||
/k8s
|
/k8s
|
||||||
@@ -43,4 +42,3 @@ yarn-error.log
|
|||||||
/data
|
/data
|
||||||
/config/caddy
|
/config/caddy
|
||||||
/config/composer
|
/config/composer
|
||||||
/AGENTS.md
|
|
||||||
|
|||||||
@@ -12,22 +12,6 @@ In order to keep the issues of the repository clean we decided to only use them
|
|||||||
|
|
||||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
|
|
||||||
### Vouched contributors
|
|
||||||
|
|
||||||
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
|
|
||||||
|
|
||||||
Your pull request is not affected if any of the following applies:
|
|
||||||
|
|
||||||
- You have write access to this repository.
|
|
||||||
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
|
|
||||||
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
|
|
||||||
|
|
||||||
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
|
|
||||||
|
|
||||||
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
|
|
||||||
|
|
||||||
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
|
|
||||||
|
|
||||||
### Contributor License Agreement
|
### Contributor License Agreement
|
||||||
|
|
||||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# solidtime - The modern Open-Source TimeTracker
|
# solidtime - The modern Open-Source Time Tracker
|
||||||
|
|
||||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||||
@@ -37,10 +37,6 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
|||||||
|
|
||||||
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
|
|
||||||
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
|
||||||
|
|
||||||
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
|
|
||||||
|
|
||||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
|||||||
15
SECURITY.md
15
SECURITY.md
@@ -3,18 +3,3 @@
|
|||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
|
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
|
|
||||||
Reports we typically won't issue an advisory for:
|
|
||||||
|
|
||||||
* Theoretical findings without a working PoC
|
|
||||||
* Raw scanner output without manual validation
|
|
||||||
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
|
|
||||||
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
|
|
||||||
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
|
|
||||||
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
|
|
||||||
* Org owners or admins acting destructively within their own organization
|
|
||||||
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
|
|
||||||
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)
|
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Enums\RegistrationMode;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Events\NewsletterRegistered;
|
use App\Events\NewsletterRegistered;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\InvitationService;
|
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use App\Service\UserService;
|
use App\Service\UserService;
|
||||||
@@ -18,6 +16,7 @@ use Illuminate\Support\Facades\Validator;
|
|||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
use Laravel\Jetstream\Jetstream;
|
||||||
use Log;
|
use Log;
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
@@ -33,14 +32,13 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
|
if (! config('app.enable_registration')) {
|
||||||
if ($registrationMode === RegistrationMode::Off) {
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => [__('Registration is disabled.')],
|
'email' => [__('Registration is disabled.')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -57,26 +55,12 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
'terms' => ['accepted', 'required'],
|
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||||
'newsletter_consent' => [
|
'newsletter_consent' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
if ($registrationMode === RegistrationMode::InviteOnly) {
|
|
||||||
$invitationService = app(InvitationService::class);
|
|
||||||
$email = (string) $validated['email'];
|
|
||||||
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
|
|
||||||
$message = $invitationService->hasPendingInvitationForEmail($email)
|
|
||||||
? __('Please accept the organization invitation sent to your email address before registering.')
|
|
||||||
: __('Registration is only available to invited users.');
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => [$message],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$timezone = null;
|
$timezone = null;
|
||||||
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
||||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Exceptions\MovedToApiException;
|
use App\Enums\Weekday;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
|
|
||||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||||
@@ -20,6 +24,56 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
throw new MovedToApiException;
|
Validator::make($input, [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
return $query->where('is_placeholder', '=', false);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'photo' => [
|
||||||
|
'nullable',
|
||||||
|
'mimes:jpg,jpeg,png',
|
||||||
|
'max:1024',
|
||||||
|
],
|
||||||
|
'timezone' => [
|
||||||
|
'required',
|
||||||
|
'timezone:all',
|
||||||
|
],
|
||||||
|
'week_start' => [
|
||||||
|
'required',
|
||||||
|
Rule::enum(Weekday::class),
|
||||||
|
],
|
||||||
|
])->validateWithBag('updateProfileInformation');
|
||||||
|
|
||||||
|
if (isset($input['photo'])) {
|
||||||
|
$user->updateProfilePhoto($input['photo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input['email'] !== $user->email) {
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'email_verified_at' => null,
|
||||||
|
'timezone' => $input['timezone'],
|
||||||
|
'week_start' => $input['week_start'],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
} else {
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'timezone' => $input['timezone'],
|
||||||
|
'week_start' => $input['week_start'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
94
app/Actions/Jetstream/AddOrganizationMember.php
Normal file
94
app/Actions/Jetstream/AddOrganizationMember.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\In;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add a new team member to the given team.
|
||||||
|
*/
|
||||||
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
|
{
|
||||||
|
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||||
|
|
||||||
|
$this->validate($organization, $email, $role);
|
||||||
|
|
||||||
|
$newOrganizationMember = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the add member operation.
|
||||||
|
*/
|
||||||
|
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||||
|
{
|
||||||
|
Validator::make([
|
||||||
|
'email' => $email,
|
||||||
|
'role' => $role,
|
||||||
|
], $this->rules())->after(
|
||||||
|
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||||
|
)->validateWithBag('addTeamMember');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules for adding a team member.
|
||||||
|
*
|
||||||
|
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||||
|
*/
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||||
|
/** @var Builder<User> $builder */
|
||||||
|
return $builder->where('is_placeholder', '=', false);
|
||||||
|
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||||
|
],
|
||||||
|
'role' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::in([
|
||||||
|
Role::Admin->value,
|
||||||
|
Role::Manager->value,
|
||||||
|
Role::Employee->value,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that the user is not already on the team.
|
||||||
|
*/
|
||||||
|
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||||
|
{
|
||||||
|
return function ($validator) use ($team, $email): void {
|
||||||
|
$validator->errors()->addIf(
|
||||||
|
$team->hasRealUserWithEmail($email),
|
||||||
|
'email',
|
||||||
|
__('This user already belongs to the team.')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Actions/Jetstream/CreateOrganization.php
Normal file
59
app/Actions/Jetstream/CreateOrganization.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Events\AfterCreateOrganization;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Jetstream\Contracts\CreatesTeams;
|
||||||
|
use Laravel\Jetstream\Jetstream;
|
||||||
|
|
||||||
|
class CreateOrganization implements CreatesTeams
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate and create a new team for the given user.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function create(User $user, array $input): Organization
|
||||||
|
{
|
||||||
|
Gate::forUser($user)->authorize('create', Jetstream::newTeamModel());
|
||||||
|
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
])->validateWithBag('createTeam');
|
||||||
|
|
||||||
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||||
|
|
||||||
|
$currency = null;
|
||||||
|
if ($ipLookupResponse !== null) {
|
||||||
|
$currency = $ipLookupResponse->currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
|
$input['name'],
|
||||||
|
$user,
|
||||||
|
false,
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->switchTeam($organization);
|
||||||
|
|
||||||
|
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||||
|
$organization = $organization->refresh();
|
||||||
|
AfterCreateOrganization::dispatch($organization);
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Actions/Jetstream/DeleteOrganization.php
Normal file
21
app/Actions/Jetstream/DeleteOrganization.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Laravel\Jetstream\Contracts\DeletesTeams;
|
||||||
|
|
||||||
|
class DeleteOrganization implements DeletesTeams
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Delete the given team.
|
||||||
|
*/
|
||||||
|
public function delete(Organization $organization): void
|
||||||
|
{
|
||||||
|
/** @see ValidateOrganizationDeletion */
|
||||||
|
app(DeletionService::class)->deleteOrganization($organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Actions/Jetstream/DeleteUser.php
Normal file
30
app/Actions/Jetstream/DeleteUser.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||||
|
|
||||||
|
class DeleteUser implements DeletesUsers
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Delete the given user.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function delete(User $user): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
app(DeletionService::class)->deleteUser($user);
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => $exception->getTranslatedMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Actions/Jetstream/InviteOrganizationMember.php
Normal file
24
app/Actions/Jetstream/InviteOrganizationMember.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Exceptions\MovedToApiException;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||||
|
|
||||||
|
class InviteOrganizationMember implements InvitesTeamMembers
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Invite a new team member to the given team.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||||
|
{
|
||||||
|
throw new MovedToApiException;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Actions/Jetstream/RemoveOrganizationMember.php
Normal file
24
app/Actions/Jetstream/RemoveOrganizationMember.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Exceptions\MovedToApiException;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
||||||
|
|
||||||
|
class RemoveOrganizationMember implements RemovesTeamMembers
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Remove the team member from the given team.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||||
|
{
|
||||||
|
throw new MovedToApiException;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Actions/Jetstream/UpdateMemberRole.php
Normal file
25
app/Actions/Jetstream/UpdateMemberRole.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Exceptions\MovedToApiException;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class UpdateMemberRole
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the role for the given team member.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||||
|
{
|
||||||
|
throw new MovedToApiException;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Actions/Jetstream/UpdateOrganization.php
Normal file
48
app/Actions/Jetstream/UpdateOrganization.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Rules\CurrencyRule;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Jetstream\Contracts\UpdatesTeamNames;
|
||||||
|
|
||||||
|
class UpdateOrganization implements UpdatesTeamNames
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate and update the given team's name.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function update(User $user, Organization $organization, array $input): void
|
||||||
|
{
|
||||||
|
Gate::forUser($user)->authorize('update', $organization);
|
||||||
|
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
'currency' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
new CurrencyRule,
|
||||||
|
],
|
||||||
|
])->validateWithBag('updateTeamName');
|
||||||
|
|
||||||
|
$organization->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'currency' => $input['currency'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
|
||||||
|
class ValidateOrganizationDeletion
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate that the team can be deleted by the given user.
|
||||||
|
*
|
||||||
|
* @param User $user Authenticated user
|
||||||
|
* @param Organization $organization Organization to be deleted
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function validate(User $user, Organization $organization): void
|
||||||
|
{
|
||||||
|
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use Illuminate\Auth\EloquentUserProvider;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User provider that only resolves non-placeholder users.
|
|
||||||
*
|
|
||||||
* Placeholder users are created by imports and when members are removed from an
|
|
||||||
* organization. They can share an email address with a real user, so resolving a user by
|
|
||||||
* email can return a placeholder instead of the real account. The login flow filters them
|
|
||||||
* out explicitly, but the password broker and the guard credential checks (for example the
|
|
||||||
* password confirmation) resolve users through the configured user provider.
|
|
||||||
*
|
|
||||||
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
|
|
||||||
* built-in one for every provider in config/auth.php.
|
|
||||||
*/
|
|
||||||
class ActiveUserProvider extends EloquentUserProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param Model|null $model
|
|
||||||
* @return Builder<Model>
|
|
||||||
*/
|
|
||||||
#[\Override]
|
|
||||||
protected function newModelQuery($model = null): Builder
|
|
||||||
{
|
|
||||||
$query = parent::newModelQuery($model);
|
|
||||||
$query->getQuery()->where('is_placeholder', '=', false);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,7 +69,7 @@ class UserCreateCommand extends Command
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
/** @var Organization|null $organization */
|
/** @var Organization|null $organization */
|
||||||
$organization = $user->ownedOrganizations->first();
|
$organization = $user->ownedTeams->first();
|
||||||
if ($organization === null) {
|
if ($organization === null) {
|
||||||
throw new LogicException('User does not have an organization');
|
throw new LogicException('User does not have an organization');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
|||||||
])
|
])
|
||||||
->whereHas('user', function (Builder $query): void {
|
->whereHas('user', function (Builder $query): void {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
$query->where('is_placeholder', '=', false)
|
$query->where('is_placeholder', '=', false);
|
||||||
->where('send_time_entry_still_running_email', '=', true);
|
|
||||||
})
|
})
|
||||||
->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): void {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum RegistrationMode: string
|
|
||||||
{
|
|
||||||
case On = 'on';
|
|
||||||
case InviteOnly = 'invite-only';
|
|
||||||
case Off = 'off';
|
|
||||||
|
|
||||||
public static function fromConfig(mixed $value): self
|
|
||||||
{
|
|
||||||
if ($value === true) {
|
|
||||||
return self::On;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value === false || $value === null) {
|
|
||||||
return self::Off;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (strtolower(trim((string) $value))) {
|
|
||||||
'1', 'on', 'true' => self::On,
|
|
||||||
'invite-only' => self::InviteOnly,
|
|
||||||
default => self::Off,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Enums;
|
namespace App\Enums;
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum Role: string
|
enum Role: string
|
||||||
{
|
{
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Owner = 'owner';
|
case Owner = 'owner';
|
||||||
case Admin = 'admin';
|
case Admin = 'admin';
|
||||||
case Manager = 'manager';
|
case Manager = 'manager';
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum TagMatchType: string
|
|
||||||
{
|
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Contains = 'contains';
|
|
||||||
|
|
||||||
case NotContains = 'not_contains';
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ enum TimeEntryAggregationType: string
|
|||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
case Tag = 'tag';
|
case Tag = 'tag';
|
||||||
case Type = 'type';
|
|
||||||
|
|
||||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum TimeEntryType: string
|
|
||||||
{
|
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Work = 'work';
|
|
||||||
case Break = 'break';
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces legacy TeamMemberAdded event.
|
|
||||||
*/
|
|
||||||
class MemberAdded
|
|
||||||
{
|
|
||||||
use Dispatchable;
|
|
||||||
|
|
||||||
public Member $member;
|
|
||||||
|
|
||||||
public Organization $organization;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
|
|
||||||
public function __construct(Member $member, Organization $organization, User $user)
|
|
||||||
{
|
|
||||||
$this->member = $member;
|
|
||||||
$this->organization = $organization;
|
|
||||||
$this->user = $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces legacy AddingTeamMember event.
|
|
||||||
*/
|
|
||||||
class MemberAdding
|
|
||||||
{
|
|
||||||
use Dispatchable;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
|
|
||||||
public Organization $organization;
|
|
||||||
|
|
||||||
public Role $role;
|
|
||||||
|
|
||||||
public function __construct(User $user, Organization $organization, Role $role)
|
|
||||||
{
|
|
||||||
$this->user = $user;
|
|
||||||
$this->organization = $organization;
|
|
||||||
$this->role = $role;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces legacy InvitingTeamMember event.
|
|
||||||
*/
|
|
||||||
class OrganizationInvitationAdding
|
|
||||||
{
|
|
||||||
use Dispatchable;
|
|
||||||
|
|
||||||
public Organization $organization;
|
|
||||||
|
|
||||||
public string $email;
|
|
||||||
|
|
||||||
public Role $role;
|
|
||||||
|
|
||||||
public User $inviter;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
Organization $organization,
|
|
||||||
string $email,
|
|
||||||
Role $role,
|
|
||||||
User $inviter
|
|
||||||
) {
|
|
||||||
$this->role = $role;
|
|
||||||
$this->email = $email;
|
|
||||||
$this->organization = $organization;
|
|
||||||
$this->inviter = $inviter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,7 @@ abstract class ApiException extends Exception
|
|||||||
*/
|
*/
|
||||||
public function report(): bool
|
public function report(): bool
|
||||||
{
|
{
|
||||||
|
// TODO: temporary activated
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class CanNotRemoveOwnerFromOrganization extends ApiException
|
class CanNotRemoveOwnerFromOrganization extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'can_not_remove_owner_from_organization';
|
public const string KEY = 'can_not_remove_owner_from_organization';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ class EntityStillInUseApiException extends ApiException
|
|||||||
|
|
||||||
public const string KEY = 'entity_still_in_use';
|
public const string KEY = 'entity_still_in_use';
|
||||||
|
|
||||||
/**
|
|
||||||
* Report the exception.
|
|
||||||
*
|
|
||||||
* @return bool true means the exception handler will not report it again
|
|
||||||
*/
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the translated message for the exception.
|
* Get the translated message for the exception.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'feature_is_not_available_in_free_plan';
|
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class InactiveUserCanNotBeUsedApiException extends ApiException
|
class InactiveUserCanNotBeUsedApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'inactive_user_can_not_be_used';
|
public const string KEY = 'inactive_user_can_not_be_used';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class OnlyOwnerCanChangeOwnership extends ApiException
|
class OnlyOwnerCanChangeOwnership extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'only_owner_can_change_ownership';
|
public const string KEY = 'only_owner_can_change_ownership';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'organization_needs_at_least_one_owner';
|
public const string KEY = 'organization_needs_at_least_one_owner';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class OverlappingTimeEntryApiException extends ApiException
|
class OverlappingTimeEntryApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'overlapping_time_entry';
|
public const string KEY = 'overlapping_time_entry';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'time_entry_can_not_be_restarted';
|
public const string KEY = 'time_entry_can_not_be_restarted';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class TimeEntryStillRunningApiException extends ApiException
|
class TimeEntryStillRunningApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'time_entry_still_running';
|
public const string KEY = 'time_entry_still_running';
|
||||||
|
|
||||||
/**
|
|
||||||
* Report the exception.
|
|
||||||
*
|
|
||||||
* @return bool true means the exception handler will not report it again
|
|
||||||
*/
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_is_already_member_of_organization';
|
public const string KEY = 'user_is_already_member_of_organization';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_is_already_member_of_project';
|
public const string KEY = 'user_is_already_member_of_project';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
|
|||||||
class UserNotPlaceholderApiException extends ApiException
|
class UserNotPlaceholderApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_not_placeholder';
|
public const string KEY = 'user_not_placeholder';
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
|
||||||
|
|
||||||
public function report(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,7 @@ namespace App\Exceptions;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
|
||||||
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class Handler extends ExceptionHandler
|
class Handler extends ExceptionHandler
|
||||||
@@ -34,33 +30,6 @@ class Handler extends ExceptionHandler
|
|||||||
$this->reportable(function (Throwable $e): void {
|
$this->reportable(function (Throwable $e): void {
|
||||||
//
|
//
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
|
|
||||||
&& $e->getErrorType() === 'access_denied'
|
|
||||||
&& $e->getHttpStatusCode() === 401);
|
|
||||||
|
|
||||||
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
|
|
||||||
// otherwise renders as a bare "Bad request." 400. Show a message that
|
|
||||||
// says how to fix it instead. The framework has already converted the
|
|
||||||
// SuspiciousOperationException into a BadRequestHttpException by the time
|
|
||||||
// renderables run, so we match that and inspect the original.
|
|
||||||
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
|
|
||||||
$previous = $e->getPrevious();
|
|
||||||
|
|
||||||
if (! $previous instanceof SuspiciousOperationException
|
|
||||||
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
|
|
||||||
return null; // any other bad request keeps the default response
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = 'This hostname is not configured for this instance. '
|
|
||||||
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
|
|
||||||
|
|
||||||
if ($request->expectsJson()) {
|
|
||||||
return response()->json(['message' => $message], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->view('errors.untrusted-host', ['message' => $message], 400);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render($request, Throwable $e): Response|RedirectResponse
|
public function render($request, Throwable $e): Response|RedirectResponse
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
|
|||||||
TextInput::make('queue')->disabled(),
|
TextInput::make('queue')->disabled(),
|
||||||
|
|
||||||
// make text a little bit smaller because often a complete Stack Trace is shown:
|
// make text a little bit smaller because often a complete Stack Trace is shown:
|
||||||
Textarea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||||
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
||||||
])->columns(4);
|
])->columns(4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class OrganizationInvitationResource extends Resource
|
|||||||
->required(),
|
->required(),
|
||||||
Select::make('role')
|
Select::make('role')
|
||||||
->options(Role::class),
|
->options(Role::class),
|
||||||
Select::make('organization_id')
|
Forms\Components\Select::make('organization_id')
|
||||||
->label('Organization')
|
->label('Organization')
|
||||||
->relationship(name: 'organization', titleAttribute: 'name')
|
->relationship(name: 'organization', titleAttribute: 'name')
|
||||||
->searchable(['name'])
|
->searchable(['name'])
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class OrganizationResource extends Resource
|
|||||||
->label('Is personal?')
|
->label('Is personal?')
|
||||||
->hiddenOn(['create'])
|
->hiddenOn(['create'])
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
->label('Owner')
|
->label('Owner')
|
||||||
->relationship(name: 'owner', titleAttribute: 'email')
|
->relationship(name: 'owner', titleAttribute: 'email')
|
||||||
->searchable(['name', 'email'])
|
->searchable(['name', 'email'])
|
||||||
@@ -76,7 +76,7 @@ class OrganizationResource extends Resource
|
|||||||
Select::make('time_format')
|
Select::make('time_format')
|
||||||
->options(TimeFormat::toSelectArray())
|
->options(TimeFormat::toSelectArray())
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('currency')
|
Forms\Components\Select::make('currency')
|
||||||
->label('Currency')
|
->label('Currency')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||||
@@ -114,22 +114,22 @@ class OrganizationResource extends Resource
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\IconColumn::make('personal_team')
|
Tables\Columns\IconColumn::make('personal_team')
|
||||||
->boolean()
|
->boolean()
|
||||||
->label('Is personal?')
|
->label('Is personal?')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('owner.email')
|
Tables\Columns\TextColumn::make('owner.email')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('currency'),
|
Tables\Columns\TextColumn::make('currency'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('billable_rate')
|
||||||
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||||
TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('updated_at')
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
@@ -223,7 +223,7 @@ class OrganizationResource extends Resource
|
|||||||
|
|
||||||
return $select;
|
return $select;
|
||||||
}),
|
}),
|
||||||
Select::make('timezone')
|
Forms\Components\Select::make('timezone')
|
||||||
->label('Timezone')
|
->label('Timezone')
|
||||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||||
->searchable()
|
->searchable()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
|
|||||||
|
|
||||||
class InvitationsRelationManager extends RelationManager
|
class InvitationsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
protected static string $relationship = 'organizationInvitations';
|
protected static string $relationship = 'teamInvitations';
|
||||||
|
|
||||||
protected static ?string $title = 'Invitations';
|
protected static ?string $title = 'Invitations';
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager
|
|||||||
$ownerRecord = $this->getOwnerRecord();
|
$ownerRecord = $this->getOwnerRecord();
|
||||||
|
|
||||||
return app(InvitationService::class)
|
return app(InvitationService::class)
|
||||||
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
|
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ class UsersRelationManager extends RelationManager
|
|||||||
return $table
|
return $table
|
||||||
->recordTitleAttribute('name')
|
->recordTitleAttribute('name')
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name'),
|
Tables\Columns\TextColumn::make('name'),
|
||||||
TextColumn::make('role'),
|
Tables\Columns\TextColumn::make('role'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('billable_rate')
|
||||||
->money($organization->currency, divideBy: 100),
|
->money($organization->currency, divideBy: 100),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
AttachAction::make()
|
Tables\Actions\AttachAction::make()
|
||||||
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||||
->form(fn (AttachAction $action): array => [
|
->form(fn (AttachAction $action): array => [
|
||||||
$action->getRecordSelect(),
|
$action->getRecordSelect(),
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ class ReportResource extends Resource
|
|||||||
return $record->getRawOriginal('properties');
|
return $record->getRawOriginal('properties');
|
||||||
})
|
})
|
||||||
->disabled(),
|
->disabled(),
|
||||||
DateTimePicker::make('created_at')
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
->label('Created At')
|
->label('Created At')
|
||||||
->hiddenOn(['create'])
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
DateTimePicker::make('updated_at')
|
Forms\Components\DateTimePicker::make('updated_at')
|
||||||
->label('Updated At')
|
->label('Updated At')
|
||||||
->hiddenOn(['create'])
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
@@ -78,10 +78,10 @@ class ReportResource extends Resource
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('description')
|
Tables\Columns\TextColumn::make('description')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
ToggleColumn::make('is_public')
|
ToggleColumn::make('is_public')
|
||||||
@@ -90,10 +90,10 @@ class ReportResource extends Resource
|
|||||||
TextColumn::make('organization.name')
|
TextColumn::make('organization.name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('updated_at')
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource\Pages;
|
use App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -17,7 +16,6 @@ use Filament\Tables;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class TimeEntryResource extends Resource
|
class TimeEntryResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -53,23 +51,15 @@ class TimeEntryResource extends Resource
|
|||||||
->rules([
|
->rules([
|
||||||
'after_or_equal:start',
|
'after_or_equal:start',
|
||||||
]),
|
]),
|
||||||
Select::make('member_id')
|
Select::make('user_id')
|
||||||
->relationship(
|
->relationship(name: 'user', titleAttribute: 'email')
|
||||||
name: 'member',
|
->searchable(['name', 'email'])
|
||||||
titleAttribute: 'id',
|
|
||||||
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
|
|
||||||
)
|
|
||||||
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
|
|
||||||
->searchable()
|
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->relationship(name: 'project', titleAttribute: 'name')
|
->relationship(name: 'project', titleAttribute: 'name')
|
||||||
->searchable(['name'])
|
->searchable(['name'])
|
||||||
->nullable(),
|
->nullable(),
|
||||||
Select::make('task_id')
|
// TODO
|
||||||
->relationship(name: 'task', titleAttribute: 'name')
|
|
||||||
->searchable(['name'])
|
|
||||||
->nullable(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +83,11 @@ class TimeEntryResource extends Resource
|
|||||||
($record->end?->toDateTimeString('minute') ?? '...').')';
|
($record->end?->toDateTimeString('minute') ?? '...').')';
|
||||||
})
|
})
|
||||||
->label('Time'),
|
->label('Time'),
|
||||||
TextColumn::make('organization.name')
|
Tables\Columns\TextColumn::make('organization.name')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('updated_at')
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
|||||||
@@ -5,28 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
use App\Models\Member;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTimeEntry extends CreateRecord
|
class CreateTimeEntry extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TimeEntryResource::class;
|
protected static string $resource = TimeEntryResource::class;
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
if (isset($data['member_id'])) {
|
|
||||||
/** @var Member|null $member */
|
|
||||||
$member = Member::query()->find($data['member_id']);
|
|
||||||
if ($member !== null) {
|
|
||||||
$data['user_id'] = $member->user_id;
|
|
||||||
$data['organization_id'] = $member->organization_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
use App\Models\Member;
|
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -20,22 +19,4 @@ class EditTimeEntry extends EditRecord
|
|||||||
->icon('heroicon-m-trash'),
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
|
||||||
{
|
|
||||||
if (isset($data['member_id'])) {
|
|
||||||
/** @var Member|null $member */
|
|
||||||
$member = Member::query()->find($data['member_id']);
|
|
||||||
if ($member !== null) {
|
|
||||||
$data['user_id'] = $member->user_id;
|
|
||||||
$data['organization_id'] = $member->organization_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\DeletionService;
|
use App\Service\DeletionService;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use App\Service\UserService;
|
|
||||||
use Brick\Money\ISOCurrencyProvider;
|
use Brick\Money\ISOCurrencyProvider;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
@@ -48,17 +47,17 @@ class UserResource extends Resource
|
|||||||
return $form
|
return $form
|
||||||
->columns(1)
|
->columns(1)
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('id')
|
Forms\Components\TextInput::make('id')
|
||||||
->label('ID')
|
->label('ID')
|
||||||
->disabled()
|
->disabled()
|
||||||
->visibleOn(['update', 'show'])
|
->visibleOn(['update', 'show'])
|
||||||
->readOnly()
|
->readOnly()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('name')
|
Forms\Components\TextInput::make('name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('email')
|
Forms\Components\TextInput::make('email')
|
||||||
->label('Email')
|
->label('Email')
|
||||||
->required()
|
->required()
|
||||||
->rules($record?->is_placeholder ? [] : [
|
->rules($record?->is_placeholder ? [] : [
|
||||||
@@ -66,7 +65,7 @@ class UserResource extends Resource
|
|||||||
->ignore($record?->getKey()),
|
->ignore($record?->getKey()),
|
||||||
])
|
])
|
||||||
->rule([
|
->rule([
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
])
|
])
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('is_placeholder')
|
Forms\Components\Toggle::make('is_placeholder')
|
||||||
@@ -180,7 +179,7 @@ class UserResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Impersonate::make()->before(function (User $record): void {
|
Impersonate::make()->before(function (User $record): void {
|
||||||
if ($record->currentOrganization === null) {
|
if ($record->currentTeam === null) {
|
||||||
$organization = $record->organizations()->where('personal_team', '=', true)->first();
|
$organization = $record->organizations()->where('personal_team', '=', true)->first();
|
||||||
if ($organization === null) {
|
if ($organization === null) {
|
||||||
$organization = $record->organizations()->first();
|
$organization = $record->organizations()->first();
|
||||||
@@ -188,7 +187,8 @@ class UserResource extends Resource
|
|||||||
if ($organization === null) {
|
if ($organization === null) {
|
||||||
throw new Exception('User has no organization');
|
throw new Exception('User has no organization');
|
||||||
}
|
}
|
||||||
app(UserService::class)->switchCurrentOrganization($record, $organization);
|
$record->currentTeam()->associate($organization);
|
||||||
|
$record->save();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static ?string $title = 'Owned Organizations';
|
protected static ?string $title = 'Owned Organizations';
|
||||||
|
|
||||||
protected static string $relationship = 'ownedOrganizations';
|
protected static string $relationship = 'ownedTeams';
|
||||||
|
|
||||||
public function form(Form $form): Form
|
public function form(Form $form): Form
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
|||||||
/**
|
/**
|
||||||
* List all api token of the currently authenticated user
|
* List all api token of the currently authenticated user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of the organization.
|
* This endpoint is independent of organization.
|
||||||
*
|
*
|
||||||
* @operationId getApiTokens
|
* @operationId getApiTokens
|
||||||
*
|
*
|
||||||
@@ -35,7 +35,6 @@ class ApiTokenController extends Controller
|
|||||||
/** @var Builder<Client> $query */
|
/** @var Builder<Client> $query */
|
||||||
$query->whereJsonContains('grant_types', 'personal_access');
|
$query->whereJsonContains('grant_types', 'personal_access');
|
||||||
})
|
})
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return new ApiTokenCollection($tokens);
|
return new ApiTokenCollection($tokens);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class ChartController extends Controller
|
|||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||||
|
|
||||||
return response()->json($dailyTrackedHours);
|
return response()->json($dailyTrackedHours);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ class ClientController extends Controller
|
|||||||
|
|
||||||
$clientsQuery = Client::query()
|
$clientsQuery = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc');
|
||||||
->orderBy('id');
|
|
||||||
|
|
||||||
if (! $canViewAllClients) {
|
if (! $canViewAllClients) {
|
||||||
$clientsQuery->visibleByEmployee($user);
|
$clientsQuery->visibleByEmployee($user);
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ class ExportController extends Controller
|
|||||||
|
|
||||||
$filepath = $exportService->export($organization);
|
$filepath = $exportService->export($organization);
|
||||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
|
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
||||||
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ class InvitationController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'invitations:view');
|
$this->checkPermission($organization, 'invitations:view');
|
||||||
|
|
||||||
$invitations = $organization->organizationInvitations()
|
$invitations = $organization->teamInvitations()
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
@@ -64,7 +62,7 @@ class InvitationController extends Controller
|
|||||||
$email = $request->getEmail();
|
$email = $request->getEmail();
|
||||||
$role = $request->getRole();
|
$role = $request->getRole();
|
||||||
|
|
||||||
$invitationService->inviteUser($organization, $email, $role, $this->user());
|
$invitationService->inviteUser($organization, $email, $role);
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ class MemberController extends Controller
|
|||||||
$members = Member::query()
|
$members = Member::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
@@ -193,7 +191,7 @@ class MemberController extends Controller
|
|||||||
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user());
|
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\AfterCreateOrganization;
|
|
||||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
|
||||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use App\Service\DeletionService;
|
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
|
||||||
use App\Service\OrganizationService;
|
|
||||||
use App\Service\UserService;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -51,15 +43,9 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getName() !== null) {
|
if ($request->getName() !== null) {
|
||||||
$organization->name = $request->getName();
|
$organization->name = $request->getName();
|
||||||
}
|
}
|
||||||
if ($request->getCurrency() !== null) {
|
|
||||||
$organization->currency = $request->getCurrency();
|
|
||||||
}
|
|
||||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
|
||||||
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
|
||||||
}
|
|
||||||
if ($request->getNumberFormat() !== null) {
|
if ($request->getNumberFormat() !== null) {
|
||||||
$organization->number_format = $request->getNumberFormat();
|
$organization->number_format = $request->getNumberFormat();
|
||||||
}
|
}
|
||||||
@@ -78,9 +64,6 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
||||||
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
||||||
}
|
}
|
||||||
if ($request->getBreaksEnabled() !== null) {
|
|
||||||
$organization->breaks_enabled = $request->getBreaksEnabled();
|
|
||||||
}
|
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$hasBillableRate = $request->has('billable_rate');
|
||||||
if ($hasBillableRate) {
|
if ($hasBillableRate) {
|
||||||
$oldBillableRate = $organization->billable_rate;
|
$oldBillableRate = $organization->billable_rate;
|
||||||
@@ -94,46 +77,4 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
return new OrganizationResource($organization, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create organization
|
|
||||||
*
|
|
||||||
* @operationId createOrganization
|
|
||||||
*/
|
|
||||||
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
|
||||||
|
|
||||||
$currency = $ipLookupResponse?->currency;
|
|
||||||
|
|
||||||
$organization = $organizationService->createOrganization(
|
|
||||||
$request->getName(),
|
|
||||||
$user,
|
|
||||||
false,
|
|
||||||
$currency
|
|
||||||
);
|
|
||||||
|
|
||||||
app(UserService::class)->switchCurrentOrganization($user, $organization);
|
|
||||||
|
|
||||||
AfterCreateOrganization::dispatch($organization);
|
|
||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete organization
|
|
||||||
*
|
|
||||||
* @operationId deleteOrganization
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'organizations:delete');
|
|
||||||
|
|
||||||
$deletionService->deleteOrganization($organization);
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,10 +60,7 @@ class ProjectController extends Controller
|
|||||||
$projectsQuery->whereNull('archived_at');
|
$projectsQuery->whereNull('archived_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$projects = $projectsQuery
|
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
@@ -79,7 +76,7 @@ class ProjectController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Organization $organization, Project $project): JsonResource
|
public function show(Organization $organization, Project $project): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:view:all', $project);
|
$this->checkPermission($organization, 'projects:view', $project);
|
||||||
|
|
||||||
// Note: There is currently no need to check if a user is a member of the project,
|
// Note: There is currently no need to check if a user is a member of the project,
|
||||||
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
|
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||||
@@ -42,14 +41,12 @@ class ProjectMemberController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getProjectMembers
|
* @operationId getProjectMembers
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
|
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:view', $project);
|
$this->checkPermission($organization, 'project-members:view', $project);
|
||||||
|
|
||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -57,10 +57,9 @@ class ReportController extends Controller
|
|||||||
$filter->addEnd($properties->end);
|
$filter->addEnd($properties->end);
|
||||||
$filter->addActive($properties->active);
|
$filter->addActive($properties->active);
|
||||||
$filter->addBillable($properties->billable);
|
$filter->addBillable($properties->billable);
|
||||||
$filter->addType($properties->timeEntryType);
|
|
||||||
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
||||||
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
||||||
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
|
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||||
$timeEntriesQuery = $filter->get();
|
$timeEntriesQuery = $filter->get();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Requests\V1\Report\ReportIndexRequest;
|
|
||||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||||
@@ -41,13 +40,12 @@ class ReportController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getReports
|
* @operationId getReports
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
|
public function index(Organization $organization): ReportCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'reports:view');
|
$this->checkPermission($organization, 'reports:view');
|
||||||
|
|
||||||
$reports = Report::query()
|
$reports = Report::query()
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
@@ -97,7 +95,6 @@ class ReportController extends Controller
|
|||||||
$properties->setClientIds($request->input('properties.client_ids', null));
|
$properties->setClientIds($request->input('properties.client_ids', null));
|
||||||
$properties->setProjectIds($request->input('properties.project_ids', null));
|
$properties->setProjectIds($request->input('properties.project_ids', null));
|
||||||
$properties->setTagIds($request->input('properties.tag_ids', null));
|
$properties->setTagIds($request->input('properties.tag_ids', null));
|
||||||
$properties->setTagMatchType($request->getPropertyTagMatchType());
|
|
||||||
$properties->setTaskIds($request->input('properties.task_ids', null));
|
$properties->setTaskIds($request->input('properties.task_ids', null));
|
||||||
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
|
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
@@ -112,7 +109,6 @@ class ReportController extends Controller
|
|||||||
$properties->timezone = $timezone;
|
$properties->timezone = $timezone;
|
||||||
$properties->roundingType = $request->getPropertyRoundingType();
|
$properties->roundingType = $request->getPropertyRoundingType();
|
||||||
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||||
$properties->timeEntryType = $request->getPropertyTimeEntryType();
|
|
||||||
$report->properties = $properties;
|
$report->properties = $properties;
|
||||||
if ($isPublic) {
|
if ($isPublic) {
|
||||||
$report->share_secret = $reportService->generateSecret();
|
$report->share_secret = $reportService->generateSecret();
|
||||||
@@ -154,9 +150,6 @@ class ReportController extends Controller
|
|||||||
$report->share_secret = null;
|
$report->share_secret = null;
|
||||||
$report->public_until = null;
|
$report->public_until = null;
|
||||||
}
|
}
|
||||||
} elseif ($report->is_public && $request->has('public_until')) {
|
|
||||||
// Allow updating expiration date on already-public reports
|
|
||||||
$report->public_until = $request->getPublicUntil();
|
|
||||||
}
|
}
|
||||||
$report->save();
|
$report->save();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Http\Requests\V1\Tag\TagIndexRequest;
|
|
||||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||||
use App\Http\Resources\V1\Tag\TagCollection;
|
use App\Http\Resources\V1\Tag\TagCollection;
|
||||||
@@ -35,14 +34,13 @@ class TagController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, TagIndexRequest $request): TagCollection
|
public function index(Organization $organization): TagCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tags:view');
|
$this->checkPermission($organization, 'tags:view');
|
||||||
|
|
||||||
$tags = Tag::query()
|
$tags = Tag::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TagCollection($tags);
|
return new TagCollection($tags);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
|||||||
use App\Http\Resources\V1\Task\TaskCollection;
|
use App\Http\Resources\V1\Task\TaskCollection;
|
||||||
use App\Http\Resources\V1\Task\TaskResource;
|
use App\Http\Resources\V1\Task\TaskResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -28,26 +27,6 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check scoped permission and verify user has access to the project
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, $permission);
|
|
||||||
|
|
||||||
$user = $this->user();
|
|
||||||
$hasAccess = Project::query()
|
|
||||||
->where('id', $project->id)
|
|
||||||
->visibleByEmployee($user)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $hasAccess) {
|
|
||||||
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks
|
* Get tasks
|
||||||
*
|
*
|
||||||
@@ -82,10 +61,7 @@ class TaskController extends Controller
|
|||||||
$query->whereNull('done_at');
|
$query->whereNull('done_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tasks = $query
|
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
|
||||||
|
|
||||||
return new TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
}
|
}
|
||||||
@@ -99,15 +75,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
/** @var Project $project */
|
$this->checkPermission($organization, 'tasks:create');
|
||||||
$project = Project::query()->findOrFail($request->input('project_id'));
|
|
||||||
|
|
||||||
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
|
||||||
$this->checkPermission($organization, 'tasks:create:all');
|
|
||||||
} else {
|
|
||||||
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
|
||||||
}
|
|
||||||
|
|
||||||
$task = new Task;
|
$task = new Task;
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
$task->project_id = $request->input('project_id');
|
$task->project_id = $request->input('project_id');
|
||||||
@@ -129,17 +97,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
// Check task belongs to organization
|
$this->checkPermission($organization, 'tasks:update', $task);
|
||||||
if ($task->organization_id !== $organization->id) {
|
|
||||||
throw new AuthorizationException('Task does not belong to organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
|
||||||
$this->checkPermission($organization, 'tasks:update:all');
|
|
||||||
} else {
|
|
||||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
|
||||||
}
|
|
||||||
|
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||||
$task->estimated_time = $request->getEstimatedTime();
|
$task->estimated_time = $request->getEstimatedTime();
|
||||||
@@ -161,16 +119,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
// Check task belongs to organization
|
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||||
if ($task->organization_id !== $organization->id) {
|
|
||||||
throw new AuthorizationException('Task does not belong to organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
|
||||||
$this->checkPermission($organization, 'tasks:delete:all');
|
|
||||||
} else {
|
|
||||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($task->timeEntries()->exists()) {
|
if ($task->timeEntries()->exists()) {
|
||||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Enums\TimeEntryType;
|
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
@@ -54,13 +53,12 @@ use Illuminate\Support\Facades\Blade;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
|
||||||
class TimeEntryController extends Controller
|
class TimeEntryController extends Controller
|
||||||
{
|
{
|
||||||
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
|
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
||||||
{
|
{
|
||||||
if (! $organization->prevent_overlapping_time_entries) {
|
if (! $organization->prevent_overlapping_time_entries) {
|
||||||
return;
|
return;
|
||||||
@@ -68,7 +66,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$query = TimeEntry::query()
|
$query = TimeEntry::query()
|
||||||
->where('organization_id', $organization->getKey())
|
->where('organization_id', $organization->getKey())
|
||||||
->where('member_id', $member->getKey())
|
->where('user_id', $member->user_id)
|
||||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||||
$q->where('id', '!=', $exclude->getKey());
|
$q->where('id', '!=', $exclude->getKey());
|
||||||
})
|
})
|
||||||
@@ -108,8 +106,8 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get time entries in organization
|
* Get time entries in organization
|
||||||
*
|
*
|
||||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||||
*
|
*
|
||||||
* @return TimeEntryCollection<TimeEntryResource>
|
* @return TimeEntryCollection<TimeEntryResource>
|
||||||
*
|
*
|
||||||
@@ -119,17 +117,16 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -160,7 +157,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($timeEntries->count() === 0) {
|
if ($timeEntries->count() === 0) {
|
||||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||||
'date' => $lastDate->toDateString(),
|
'date' => $lastDate->toDateString(),
|
||||||
'member_id' => $request->input('member_id'),
|
'user_id' => $request->input('user_id'),
|
||||||
'auth_user_id' => Auth::id(),
|
'auth_user_id' => Auth::id(),
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
]);
|
]);
|
||||||
@@ -196,8 +193,7 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->select($select)
|
->select($select)
|
||||||
->orderBy('time_entries.start', 'desc')
|
->orderBy('start', 'desc');
|
||||||
->orderBy('time_entries.id');
|
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
$filter->addStartFilter($request->input('start'));
|
$filter->addStartFilter($request->input('start'));
|
||||||
@@ -206,11 +202,10 @@ class TimeEntryController extends Controller
|
|||||||
$filter->addMemberIdFilter($member);
|
$filter->addMemberIdFilter($member);
|
||||||
$filter->addMemberIdsFilter($request->input('member_ids'));
|
$filter->addMemberIdsFilter($request->input('member_ids'));
|
||||||
$filter->addProjectIdsFilter($request->input('project_ids'));
|
$filter->addProjectIdsFilter($request->input('project_ids'));
|
||||||
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||||
$filter->addBillableFilter($request->input('billable'));
|
$filter->addBillableFilter($request->input('billable'));
|
||||||
$filter->addTypeFilter($request->input('type'));
|
|
||||||
|
|
||||||
return $filter->get();
|
return $filter->get();
|
||||||
}
|
}
|
||||||
@@ -224,10 +219,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -244,7 +238,7 @@ class TimeEntryController extends Controller
|
|||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -252,7 +246,7 @@ class TimeEntryController extends Controller
|
|||||||
'user',
|
'user',
|
||||||
'tagsRelation',
|
'tagsRelation',
|
||||||
]);
|
]);
|
||||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||||
$folderPath = 'exports';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
$localizationService = LocalizationService::forOrganization($organization);
|
$localizationService = LocalizationService::forOrganization($organization);
|
||||||
@@ -267,7 +261,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
@@ -336,9 +330,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5), [
|
->temporaryUrl($path, now()->addMinutes(5)),
|
||||||
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,10 +368,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -390,7 +381,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -426,10 +417,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -445,7 +435,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -479,7 +469,7 @@ class TimeEntryController extends Controller
|
|||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
$localizationService = LocalizationService::forOrganization($organization);
|
$localizationService = LocalizationService::forOrganization($organization);
|
||||||
|
|
||||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||||
$folderPath = 'exports';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
|
|
||||||
@@ -537,7 +527,7 @@ class TimeEntryController extends Controller
|
|||||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||||
} else {
|
} else {
|
||||||
Excel::store(
|
Excel::store(
|
||||||
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
|
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
|
||||||
$path,
|
$path,
|
||||||
config('filesystems.private'),
|
config('filesystems.private'),
|
||||||
$format->getExportPackageType(),
|
$format->getExportPackageType(),
|
||||||
@@ -549,9 +539,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5), [
|
->temporaryUrl($path, now()->addMinutes(5)),
|
||||||
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,11 +558,10 @@ class TimeEntryController extends Controller
|
|||||||
$filter->addMemberIdFilter($member);
|
$filter->addMemberIdFilter($member);
|
||||||
$filter->addMemberIdsFilter($request->input('member_ids'));
|
$filter->addMemberIdsFilter($request->input('member_ids'));
|
||||||
$filter->addProjectIdsFilter($request->input('project_ids'));
|
$filter->addProjectIdsFilter($request->input('project_ids'));
|
||||||
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||||
$filter->addBillableFilter($request->input('billable'));
|
$filter->addBillableFilter($request->input('billable'));
|
||||||
$filter->addTypeFilter($request->input('type'));
|
|
||||||
|
|
||||||
return $filter->get();
|
return $filter->get();
|
||||||
}
|
}
|
||||||
@@ -591,7 +578,7 @@ class TimeEntryController extends Controller
|
|||||||
{
|
{
|
||||||
/** @var Member $member */
|
/** @var Member $member */
|
||||||
$member = Member::query()->findOrFail($request->input('member_id'));
|
$member = Member::query()->findOrFail($request->input('member_id'));
|
||||||
if ($member->getKey() === $this->member($organization)->getKey()) {
|
if ($member->user_id === Auth::id()) {
|
||||||
$this->checkPermission($organization, 'time-entries:create:own');
|
$this->checkPermission($organization, 'time-entries:create:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:create:all');
|
$this->checkPermission($organization, 'time-entries:create:all');
|
||||||
@@ -638,13 +625,12 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $newMember */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||||
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
$this->checkPermission($organization, 'time-entries:update:own');
|
||||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||||
@@ -673,10 +659,6 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
if ($newMember !== null) {
|
|
||||||
$timeEntry->member()->associate($newMember);
|
|
||||||
$timeEntry->user()->associate($newMember->user);
|
|
||||||
}
|
|
||||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
@@ -706,7 +688,6 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
|
||||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||||
|
|
||||||
@@ -731,9 +712,6 @@ class TimeEntryController extends Controller
|
|||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Member|null $newMember */
|
|
||||||
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
|
||||||
|
|
||||||
$project = null;
|
$project = null;
|
||||||
$client = null;
|
$client = null;
|
||||||
$overwriteClient = false;
|
$overwriteClient = false;
|
||||||
@@ -760,33 +738,16 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
}
|
}
|
||||||
// Changing time entries to Break entries is only allowed when breaks are enabled in the org settings
|
|
||||||
$resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type;
|
|
||||||
if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) {
|
|
||||||
$error->push($id);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted)
|
|
||||||
if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) {
|
|
||||||
$error->push($id);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
if ($newMember !== null) {
|
|
||||||
$timeEntry->member()->associate($newMember);
|
|
||||||
$timeEntry->user_id = $newMember->user_id;
|
|
||||||
}
|
|
||||||
// If project is changed, but task is not, we remove the old task from the time entry
|
// If project is changed, but task is not, we remove the old task from the time entry
|
||||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||||
$timeEntry->task()->disassociate();
|
$timeEntry->task()->disassociate();
|
||||||
@@ -827,8 +788,7 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
if ($timeEntry->member->user_id === Auth::id()) {
|
||||||
if ($timeEntry->member_id === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||||
@@ -885,7 +845,7 @@ class TimeEntryController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
|
||||||
|
|
||||||
use App\Service\TimezoneService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class TimeZoneController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get all timezones
|
|
||||||
*
|
|
||||||
* @response object{key: string}[]
|
|
||||||
*
|
|
||||||
* @operationId getTimezones
|
|
||||||
*/
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$timezones = app(TimezoneService::class)->getTimezones();
|
|
||||||
|
|
||||||
$response = [];
|
|
||||||
|
|
||||||
foreach ($timezones as $timezone) {
|
|
||||||
$response[] = (object) [
|
|
||||||
'key' => $timezone,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,30 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
|
||||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
|
||||||
use App\Http\Requests\V1\User\UserDestroyRequest;
|
|
||||||
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
|
|
||||||
use App\Http\Requests\V1\User\UserUpdateRequest;
|
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
use App\Mail\VerifyUpdatedEmailMail;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\DeletionService;
|
|
||||||
use App\Service\UserService;
|
|
||||||
use App\Support\Base64File;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the current user
|
* Get the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of the organization.
|
* This endpoint is independent of organization.
|
||||||
*
|
*
|
||||||
* @operationId getMe
|
* @operationId getMe
|
||||||
*
|
*
|
||||||
@@ -39,173 +24,4 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return new UserResource($user);
|
return new UserResource($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the current organization of the current user
|
|
||||||
*
|
|
||||||
* Switches the organization that the user is currently working in. The user
|
|
||||||
* must be a member of the given organization. This endpoint is independent of
|
|
||||||
* the organization.
|
|
||||||
*
|
|
||||||
* @operationId updateMyCurrentOrganization
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function updateMyCurrentOrganization(UserUpdateCurrentOrganizationRequest $request, UserService $userService): UserResource
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
/** @var Organization|null $organization */
|
|
||||||
$organization = $user->organizations()
|
|
||||||
->whereKey($request->getOrganizationId())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($organization === null) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$userService->switchCurrentOrganization($user, $organization);
|
|
||||||
|
|
||||||
return new UserResource($user->refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the current user
|
|
||||||
*
|
|
||||||
* This endpoint is independent of the organization.
|
|
||||||
*
|
|
||||||
* @operationId updateUser
|
|
||||||
*/
|
|
||||||
public function update(User $user, UserUpdateRequest $request): UserResource
|
|
||||||
{
|
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->hasPhotoKey()) {
|
|
||||||
$photoDisk = (string) config('filesystems.public');
|
|
||||||
$previousPhotoPath = $user->profile_photo_path;
|
|
||||||
$newPhoto = $request->getPhoto();
|
|
||||||
|
|
||||||
if ($newPhoto === null) {
|
|
||||||
$user->profile_photo_path = null;
|
|
||||||
} else {
|
|
||||||
$decoded = Base64File::decode($newPhoto);
|
|
||||||
assert($decoded !== null);
|
|
||||||
$extension = Base64File::extension($decoded['mime_type']);
|
|
||||||
assert($extension !== null);
|
|
||||||
|
|
||||||
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
|
|
||||||
Storage::disk($photoDisk)->put($photoPath, $decoded['data'], 'public');
|
|
||||||
$user->profile_photo_path = $photoPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($previousPhotoPath !== null) {
|
|
||||||
Storage::disk($photoDisk)->delete($previousPhotoPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$emailToVerify = null;
|
|
||||||
$email = $request->getEmail();
|
|
||||||
if ($email !== null && $email !== Str::lower($user->email)) {
|
|
||||||
$emailToVerify = $email;
|
|
||||||
$user->pending_email = $email;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->getName() !== null) {
|
|
||||||
$user->name = $request->getName();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->getTimezone() !== null) {
|
|
||||||
$user->timezone = $request->getTimezone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->getWeekStart() !== null) {
|
|
||||||
$user->week_start = $request->getWeekStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
|
|
||||||
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
if ($emailToVerify !== null) {
|
|
||||||
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new UserResource($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the pending email for a user.
|
|
||||||
*
|
|
||||||
* This endpoint is independent of the organization.
|
|
||||||
*
|
|
||||||
* @operationId resetUserPendingEmail
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
|
||||||
*/
|
|
||||||
public function resetPendingEmail(User $user): JsonResponse
|
|
||||||
{
|
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->pending_email = null;
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend the pending email update verification email.
|
|
||||||
*
|
|
||||||
* This endpoint is independent of the organization.
|
|
||||||
*
|
|
||||||
* @operationId resendUserEmailVerification
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
|
||||||
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
|
|
||||||
*/
|
|
||||||
public function resendEmailVerification(User $user): JsonResponse
|
|
||||||
{
|
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->pending_email === null) {
|
|
||||||
throw new UserResendEmailVerificationNoPendingEmailApiException;
|
|
||||||
}
|
|
||||||
|
|
||||||
Mail::to($user->pending_email)
|
|
||||||
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the deletion of a user.
|
|
||||||
*
|
|
||||||
* This endpoint is independent of the organization.
|
|
||||||
*
|
|
||||||
* @operationId deleteUser
|
|
||||||
*
|
|
||||||
* @param User $user The user instance to be deleted.
|
|
||||||
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
|
||||||
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
|
||||||
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
|
||||||
*/
|
|
||||||
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
|
||||||
{
|
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$deletionService->deleteUser($user);
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get the memberships of the current user
|
* Get the memberships of the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of the organization.
|
* This endpoint is independent of organization.
|
||||||
*
|
*
|
||||||
* @operationId getMyMemberships
|
* @operationId getMyMemberships
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get the active time entry of the current user
|
* Get the active time entry of the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of the organization.
|
* This endpoint is independent of organization.
|
||||||
*
|
*
|
||||||
* @operationId getMyActiveTimeEntry
|
* @operationId getMyActiveTimeEntry
|
||||||
*/
|
*/
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user