mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-09 08:42:15 +01:00
Compare commits
114 Commits
feature/up
...
feature/vo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbf7b87bb | ||
|
|
114a32536d | ||
|
|
ff8a0f065b | ||
|
|
44fd0ffb91 | ||
|
|
c07c62bfab | ||
|
|
f599757761 | ||
|
|
e54fd2460c | ||
|
|
e997a2b39f | ||
|
|
f0de89b0e8 | ||
|
|
024283273a | ||
|
|
f01ab5fd9d | ||
|
|
4fb18f343f | ||
|
|
ba374c0371 | ||
|
|
e302421765 | ||
|
|
c90c1ed8fa | ||
|
|
75f5144b45 | ||
|
|
53ecc58509 | ||
|
|
ac68091d3e | ||
|
|
a97c02542b | ||
|
|
a56942abcf | ||
|
|
61a0a7db5a | ||
|
|
bc5af14a75 | ||
|
|
95bfb45978 | ||
|
|
2207f676ee | ||
|
|
981bcbe091 | ||
|
|
ccf3cec8c0 | ||
|
|
23ea1500b0 | ||
|
|
4bbde04e28 | ||
|
|
caa36d2875 | ||
|
|
58d7b33366 | ||
|
|
50f57f6997 | ||
|
|
ab9f6e6afb | ||
|
|
2a40bb7edb | ||
|
|
365f672cfc | ||
|
|
25f30fa820 | ||
|
|
37a8f43b23 | ||
|
|
c94aa8038d | ||
|
|
c9e84260b1 | ||
|
|
9a53f903d8 | ||
|
|
d50ce52b5b | ||
|
|
aea310e639 | ||
|
|
a94627321b | ||
|
|
806d138fc9 | ||
|
|
240dd809f4 | ||
|
|
dedb50ea73 | ||
|
|
6fe856fd31 | ||
|
|
20735c2fed | ||
|
|
b14e40c64b | ||
|
|
f2277ff708 | ||
|
|
6a197f7f34 | ||
|
|
24c94af952 | ||
|
|
111ce94150 | ||
|
|
49e69b85a6 | ||
|
|
90a5c2b87c | ||
|
|
6b01034bdd | ||
|
|
a4c400ef4e | ||
|
|
5d01b20dbf | ||
|
|
24875f9424 | ||
|
|
4975cf1dab | ||
|
|
b3fb04c4be | ||
|
|
0cc8d762a5 | ||
|
|
e3e45161a1 | ||
|
|
0c825a691e | ||
|
|
24775d2c47 | ||
|
|
c5c129b239 | ||
|
|
89a9341d91 | ||
|
|
bffd0773be | ||
|
|
56ea0ed90e | ||
|
|
24c19e7e7c | ||
|
|
734aab6353 | ||
|
|
5eb1a6a006 | ||
|
|
d0334bd730 | ||
|
|
fe4e903203 | ||
|
|
43e42ace54 | ||
|
|
84728f0914 | ||
|
|
8f02f98afc | ||
|
|
c1ecd0eff5 | ||
|
|
486b5d2152 | ||
|
|
5476b4e5a9 | ||
|
|
ceec4cbf62 | ||
|
|
372c7b02a0 | ||
|
|
cb8047028c | ||
|
|
e29be581fa | ||
|
|
02da133fb3 | ||
|
|
d00fe2baff | ||
|
|
a73fe59892 | ||
|
|
5d744d70c4 | ||
|
|
12d58ee42c | ||
|
|
f9be92894e | ||
|
|
a30d192ea2 | ||
|
|
d732064f31 | ||
|
|
cb5c2547f4 | ||
|
|
13a25524f3 | ||
|
|
112f6aa6a6 | ||
|
|
8eab0485c9 | ||
|
|
0aa0f0bd77 | ||
|
|
eb63c4ef03 | ||
|
|
54fffd07bc | ||
|
|
da235dfdc8 | ||
|
|
0debdddef9 | ||
|
|
62354cfe8b | ||
|
|
396e7b2b6b | ||
|
|
221889ff87 | ||
|
|
7ce3fa2740 | ||
|
|
df34014bfe | ||
|
|
faf3ee471c | ||
|
|
866e5d8594 | ||
|
|
72cd0b6f05 | ||
|
|
6d93e48b1d | ||
|
|
09af0f775f | ||
|
|
1cc000a584 | ||
|
|
1a754f6756 | ||
|
|
d69d25d059 | ||
|
|
0e15d9d9c2 |
54
.dockerignore
Normal file
54
.dockerignore
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.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
|
||||||
23
.github/VOUCHED.td
vendored
Normal file
23
.github/VOUCHED.td
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 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
|
||||||
|
candideu
|
||||||
|
KasparRosin
|
||||||
|
korridor
|
||||||
|
Onatcer
|
||||||
|
ShrootBuck
|
||||||
|
smileBeda
|
||||||
|
utlark
|
||||||
43
.github/workflows/build-onpremise.yml
vendored
43
.github/workflows/build-onpremise.yml
vendored
@@ -8,6 +8,8 @@ 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:
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -46,9 +48,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@v1"
|
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||||
with:
|
with:
|
||||||
prefix: "v"
|
pattern: "v*[0-9].*[0-9].*[0-9]"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -91,14 +93,23 @@ 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@v6
|
uses: actions/setup-node@v7
|
||||||
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@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-invoicing
|
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||||
|
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 }}
|
||||||
|
|
||||||
@@ -124,27 +135,27 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
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@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -163,7 +174,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -177,25 +188,25 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v8
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
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@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
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,6 +8,8 @@ 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:
|
||||||
@@ -22,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -33,9 +35,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@v1"
|
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||||
with:
|
with:
|
||||||
prefix: "v"
|
pattern: "v*[0-9].*[0-9].*[0-9]"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: version
|
id: version
|
||||||
@@ -68,14 +70,27 @@ jobs:
|
|||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-billing
|
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||||
|
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 }}
|
||||||
|
|
||||||
@@ -93,9 +108,10 @@ jobs:
|
|||||||
run: cd extensions/Billing && npm ci
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
- name: "Checkout services extension"
|
- name: "Checkout services extension"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-services
|
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||||
|
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 }}
|
||||||
|
|
||||||
@@ -111,9 +127,10 @@ jobs:
|
|||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-invoicing
|
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||||
|
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 }}
|
||||||
|
|
||||||
@@ -160,7 +177,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@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
@@ -168,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
||||||
tags: |
|
tags: |
|
||||||
@@ -179,13 +196,13 @@ jobs:
|
|||||||
type=sha,format=long
|
type=sha,format=long
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: "Build and push"
|
- name: "Build and push"
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
33
.github/workflows/build-public.yml
vendored
33
.github/workflows/build-public.yml
vendored
@@ -8,6 +8,7 @@ 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:
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -47,9 +48,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@v1"
|
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||||
with:
|
with:
|
||||||
prefix: "v"
|
pattern: "v*[0-9].*[0-9].*[0-9]"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -92,7 +93,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@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -109,34 +110,34 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
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@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -155,7 +156,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -169,31 +170,31 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v8
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
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@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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@v4
|
uses: actions/upload-artifact@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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@v6
|
uses: actions/setup-node@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
# 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@v6
|
- uses: actions/setup-node@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
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
Normal file
27
.github/workflows/npm-test-unit.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: NPM Test Unit
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
TZ: UTC
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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@v6
|
uses: actions/setup-node@v7
|
||||||
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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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@v6
|
- uses: actions/setup-node@v7
|
||||||
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@v5.5.1
|
uses: codecov/codecov-action@v7.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.6
|
uses: aglipanci/laravel-pint-action@2.6
|
||||||
|
|||||||
14
.github/workflows/playwright.yml
vendored
14
.github/workflows/playwright.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
MAILPIT_BASE_URL: 'http://localhost:8025'
|
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||||
|
|
||||||
- name: "Upload blob report"
|
- name: "Upload blob report"
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
@@ -99,10 +99,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: "Download blob reports"
|
- name: "Download blob reports"
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: all-blob-reports
|
path: all-blob-reports
|
||||||
pattern: blob-report-*
|
pattern: blob-report-*
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||||
|
|
||||||
- name: "Upload merged HTML report"
|
- name: "Upload merged HTML report"
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
|
|||||||
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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
Normal file
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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
Normal file
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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 }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,9 +26,10 @@ 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
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ 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.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Please open an issue or start a discussion and wait for approval before submitti
|
|||||||
|
|
||||||
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
**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.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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
|
||||||
@@ -55,7 +54,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
'terms' => ['accepted', 'required'],
|
||||||
'newsletter_consent' => [
|
'newsletter_consent' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Exceptions\MovedToApiException;
|
||||||
use App\Mail\VerifyUpdatedEmailMail;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
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
|
||||||
@@ -27,61 +20,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
if (isset($input['email']) && is_string($input['email'])) {
|
throw new MovedToApiException;
|
||||||
$input['email'] = Str::lower($input['email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
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']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = Str::lower((string) $input['email']);
|
|
||||||
|
|
||||||
if ($email !== Str::lower($user->email)) {
|
|
||||||
$user->forceFill([
|
|
||||||
'name' => $input['name'],
|
|
||||||
'pending_email' => $email,
|
|
||||||
'timezone' => $input['timezone'],
|
|
||||||
'week_start' => $input['week_start'],
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
|
|
||||||
} else {
|
|
||||||
$user->forceFill([
|
|
||||||
'name' => $input['name'],
|
|
||||||
'timezone' => $input['timezone'],
|
|
||||||
'week_start' => $input['week_start'],
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Exceptions\MovedToApiException;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
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
|
|
||||||
{
|
|
||||||
throw new MovedToApiException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Events\AfterCreateOrganization;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
|
||||||
use App\Service\OrganizationService;
|
|
||||||
use App\Service\UserService;
|
|
||||||
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
|
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
app(UserService::class)->switchCurrentOrganization($user, $organization);
|
|
||||||
|
|
||||||
AfterCreateOrganization::dispatch($organization);
|
|
||||||
|
|
||||||
return $organization;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Service\DeletionService;
|
|
||||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
|
||||||
|
|
||||||
class DeleteOrganization implements DeletesTeams
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Delete the given team.
|
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
|
||||||
public function delete(Organization $organization): void
|
|
||||||
{
|
|
||||||
/** @see ValidateOrganizationDeletion */
|
|
||||||
app(DeletionService::class)->deleteOrganization($organization);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Exceptions\Api\ApiException;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\DeletionService;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
|
||||||
|
|
||||||
class DeleteUser implements DeletesUsers
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Delete the given user.
|
|
||||||
*
|
|
||||||
* @throws ValidationException
|
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
|
||||||
public function delete(User $user): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
app(DeletionService::class)->deleteUser($user);
|
|
||||||
} catch (ApiException $exception) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'password' => $exception->getTranslatedMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Exceptions\MovedToApiException;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Exception;
|
|
||||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
|
||||||
|
|
||||||
class InviteOrganizationMember implements InvitesTeamMembers
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Invite a new team member to the given team.
|
|
||||||
*
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
|
||||||
{
|
|
||||||
throw new MovedToApiException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Exceptions\MovedToApiException;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Exception;
|
|
||||||
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
|
||||||
|
|
||||||
class RemoveOrganizationMember implements RemovesTeamMembers
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Remove the team member from the given team.
|
|
||||||
*
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
|
||||||
{
|
|
||||||
throw new MovedToApiException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Exceptions\MovedToApiException;
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class UpdateMemberRole
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Update the role for the given team member.
|
|
||||||
*
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
|
||||||
{
|
|
||||||
throw new MovedToApiException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Rules\CurrencyRule;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Laravel\Jetstream\Contracts\UpdatesTeamNames;
|
|
||||||
|
|
||||||
class UpdateOrganization implements UpdatesTeamNames
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Validate and update the given team's name.
|
|
||||||
*
|
|
||||||
* @param array<string, string> $input
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function update(User $user, Organization $organization, array $input): void
|
|
||||||
{
|
|
||||||
Gate::forUser($user)->authorize('update', $organization);
|
|
||||||
|
|
||||||
Validator::make($input, [
|
|
||||||
'name' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
],
|
|
||||||
'currency' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
new CurrencyRule,
|
|
||||||
],
|
|
||||||
])->validateWithBag('updateTeamName');
|
|
||||||
|
|
||||||
$organization->forceFill([
|
|
||||||
'name' => $input['name'],
|
|
||||||
'currency' => $input['currency'],
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
|
|
||||||
class ValidateOrganizationDeletion
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Validate that the team can be deleted by the given user.
|
|
||||||
*
|
|
||||||
* @param User $user Authenticated user
|
|
||||||
* @param Organization $organization Organization to be deleted
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
|
||||||
public function validate(User $user, Organization $organization): void
|
|
||||||
{
|
|
||||||
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,12 @@ 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';
|
||||||
|
|||||||
16
app/Enums/TagMatchType.php
Normal file
16
app/Enums/TagMatchType.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum TagMatchType: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case Contains = 'contains';
|
||||||
|
|
||||||
|
case NotContains = 'not_contains';
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces legacy TeamMemberAdded event.
|
||||||
|
*/
|
||||||
class MemberAdded
|
class MemberAdded
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces legacy AddingTeamMember event.
|
||||||
|
*/
|
||||||
class MemberAdding
|
class MemberAdding
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
|
|||||||
38
app/Events/OrganizationInvitationAdding.php
Normal file
38
app/Events/OrganizationInvitationAdding.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']));
|
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class UserResource extends Resource
|
|||||||
->ignore($record?->getKey()),
|
->ignore($record?->getKey()),
|
||||||
])
|
])
|
||||||
->rule([
|
->rule([
|
||||||
'email',
|
'email:rfc,strict',
|
||||||
])
|
])
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('is_placeholder')
|
Forms\Components\Toggle::make('is_placeholder')
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ 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);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class InvitationController extends Controller
|
|||||||
|
|
||||||
$invitations = $organization->organizationInvitations()
|
$invitations = $organization->organizationInvitations()
|
||||||
->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 InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
@@ -63,7 +64,7 @@ class InvitationController extends Controller
|
|||||||
$email = $request->getEmail();
|
$email = $request->getEmail();
|
||||||
$role = $request->getRole();
|
$role = $request->getRole();
|
||||||
|
|
||||||
$invitationService->inviteUser($organization, $email, $role);
|
$invitationService->inviteUser($organization, $email, $role, $this->user());
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class MemberController extends Controller
|
|||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
->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 MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
@@ -192,7 +193,7 @@ class MemberController extends Controller
|
|||||||
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user());
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\AfterCreateOrganization;
|
use App\Events\AfterCreateOrganization;
|
||||||
|
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
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;
|
||||||
@@ -50,6 +51,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();
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,7 @@ class OrganizationController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:delete');
|
$this->checkPermission($organization, 'organizations:delete');
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$projects = $projectsQuery
|
$projects = $projectsQuery
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
|
->orderBy('id')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->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;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class ProjectMemberController extends Controller
|
|||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
->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 ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class ReportController extends Controller
|
|||||||
$filter->addBillable($properties->billable);
|
$filter->addBillable($properties->billable);
|
||||||
$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());
|
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
|
||||||
$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();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class ReportController extends Controller
|
|||||||
|
|
||||||
$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'));
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ 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;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class TagController extends Controller
|
|||||||
$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);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
$tasks = $query
|
$tasks = $query
|
||||||
->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 TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$query = TimeEntry::query()
|
$query = TimeEntry::query()
|
||||||
->where('organization_id', $organization->getKey())
|
->where('organization_id', $organization->getKey())
|
||||||
->where('user_id', $member->user_id)
|
->where('member_id', $member->getKey())
|
||||||
->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());
|
||||||
})
|
})
|
||||||
@@ -107,8 +107,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 `user_id`.
|
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||||
*
|
*
|
||||||
* @return TimeEntryCollection<TimeEntryResource>
|
* @return TimeEntryCollection<TimeEntryResource>
|
||||||
*
|
*
|
||||||
@@ -118,16 +118,17 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
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, $member, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -158,7 +159,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(),
|
||||||
'user_id' => $request->input('user_id'),
|
'member_id' => $request->input('member_id'),
|
||||||
'auth_user_id' => Auth::id(),
|
'auth_user_id' => Auth::id(),
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
]);
|
]);
|
||||||
@@ -194,7 +195,8 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->select($select)
|
->select($select)
|
||||||
->orderBy('start', 'desc');
|
->orderBy('time_entries.start', 'desc')
|
||||||
|
->orderBy('time_entries.id');
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
$filter->addStartFilter($request->input('start'));
|
$filter->addStartFilter($request->input('start'));
|
||||||
@@ -203,7 +205,7 @@ 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'));
|
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
||||||
$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'));
|
||||||
@@ -220,9 +222,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
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');
|
||||||
@@ -239,7 +242,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, $member, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -262,7 +265,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, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
@@ -369,9 +372,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
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');
|
||||||
@@ -382,7 +386,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -418,9 +422,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
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');
|
||||||
@@ -436,7 +441,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -559,7 +564,7 @@ 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'));
|
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
||||||
$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'));
|
||||||
@@ -579,7 +584,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->user_id === Auth::id()) {
|
if ($member->getKey() === $this->member($organization)->getKey()) {
|
||||||
$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');
|
||||||
@@ -626,9 +631,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $newMember */
|
||||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
||||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
$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', $timeEntry);
|
||||||
@@ -660,6 +666,10 @@ 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();
|
||||||
@@ -689,6 +699,7 @@ 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');
|
||||||
|
|
||||||
@@ -713,6 +724,9 @@ 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;
|
||||||
@@ -739,7 +753,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -749,6 +763,10 @@ class TimeEntryController extends Controller
|
|||||||
$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();
|
||||||
@@ -789,7 +807,8 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||||
{
|
{
|
||||||
if ($timeEntry->member->user_id === Auth::id()) {
|
$member = $this->member($organization);
|
||||||
|
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);
|
||||||
@@ -846,7 +865,7 @@ class TimeEntryController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
33
app/Http/Controllers/Api/V1/TimeZoneController.php
Normal file
33
app/Http/Controllers/Api/V1/TimeZoneController.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Service\TimezoneService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class TimeZoneController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all timezones
|
||||||
|
*
|
||||||
|
* @response object{key: string}[]
|
||||||
|
*
|
||||||
|
* @operationId getTimezones
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$timezones = app(TimezoneService::class)->getTimezones();
|
||||||
|
|
||||||
|
$response = [];
|
||||||
|
|
||||||
|
foreach ($timezones as $timezone) {
|
||||||
|
$response[] = (object) [
|
||||||
|
'key' => $timezone,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,15 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
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\Requests\V1\User\UserUpdateRequest;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
use App\Mail\VerifyUpdatedEmailMail;
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\DeletionService;
|
use App\Service\DeletionService;
|
||||||
|
use App\Service\UserService;
|
||||||
use App\Support\Base64File;
|
use App\Support\Base64File;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -36,6 +40,35 @@ 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
|
* Update the current user
|
||||||
*
|
*
|
||||||
@@ -50,7 +83,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->hasPhotoKey()) {
|
if ($request->hasPhotoKey()) {
|
||||||
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
|
$photoDisk = (string) config('filesystems.public');
|
||||||
$previousPhotoPath = $user->profile_photo_path;
|
$previousPhotoPath = $user->profile_photo_path;
|
||||||
$newPhoto = $request->getPhoto();
|
$newPhoto = $request->getPhoto();
|
||||||
|
|
||||||
@@ -161,7 +194,7 @@ class UserController extends Controller
|
|||||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
* @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.
|
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||||
*/
|
*/
|
||||||
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
||||||
{
|
{
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
if ($user->getKey() !== $this->user()->getKey()) {
|
||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
|
|||||||
@@ -4,4 +4,21 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
abstract class Controller extends \App\Http\Controllers\Controller {}
|
use App\Models\Organization;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
|
||||||
|
abstract class Controller extends \App\Http\Controllers\Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected PermissionStore $permissionStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
protected function hasPermission(Organization $organization, string $permission): bool
|
||||||
|
{
|
||||||
|
return $this->permissionStore->has($organization, $permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
63
app/Http/Controllers/Web/OrganizationController.php
Normal file
63
app/Http/Controllers/Web/OrganizationController.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Brick\Money\Currency;
|
||||||
|
use Brick\Money\ISOCurrencyProvider;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class OrganizationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the team creation screen.
|
||||||
|
*/
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Teams/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the organizatio details screen.
|
||||||
|
*
|
||||||
|
* @param string $organizationId The organization ID
|
||||||
|
*/
|
||||||
|
public function show(string $organizationId): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$organization = Str::isUuid($organizationId) ? Organization::find($organizationId) : null;
|
||||||
|
if ($organization === null) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
if (! $this->hasPermission($organization, 'organizations:view')) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = $organization->owner;
|
||||||
|
|
||||||
|
return Inertia::render('Teams/Show', [
|
||||||
|
'team' => [
|
||||||
|
'id' => $organization->getKey(),
|
||||||
|
'name' => $organization->name,
|
||||||
|
'currency' => $organization->currency,
|
||||||
|
'owner' => [
|
||||||
|
'id' => $owner->getKey(),
|
||||||
|
'name' => $owner->name,
|
||||||
|
'profile_photo_url' => $owner->profile_photo_url,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'currencies' => array_map(function (Currency $currency): string {
|
||||||
|
return $currency->getName();
|
||||||
|
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
|
||||||
|
'permissions' => [
|
||||||
|
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
|
||||||
|
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Controllers/Web/OtherBrowserSessionsController.php
Normal file
54
app/Http/Controllers/Web/OtherBrowserSessionsController.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Actions\ConfirmPassword;
|
||||||
|
|
||||||
|
class OtherBrowserSessionsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Log the user out of their other browser sessions across all devices.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, StatefulGuard $guard): RedirectResponse
|
||||||
|
{
|
||||||
|
$password = (string) $request->string('password');
|
||||||
|
|
||||||
|
$confirmed = app(ConfirmPassword::class)($guard, $request->user(), $password);
|
||||||
|
|
||||||
|
if (! $confirmed) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => __('The password is incorrect.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guard->logoutOtherDevices($password);
|
||||||
|
|
||||||
|
$this->deleteOtherSessionRecords($request);
|
||||||
|
|
||||||
|
return back(303);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the other browser session records from storage.
|
||||||
|
*/
|
||||||
|
protected function deleteOtherSessionRecords(Request $request): void
|
||||||
|
{
|
||||||
|
if (config('session.driver') !== 'database') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::connection(config('session.connection'))
|
||||||
|
->table(config('session.table', 'sessions'))
|
||||||
|
->where('user_id', $request->user()->getAuthIdentifier())
|
||||||
|
->where('id', '!=', $request->session()->getId())
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Http/Controllers/Web/UserProfileController.php
Normal file
142
app/Http/Controllers/Web/UserProfileController.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
|
use App\Service\Dto\UserAgentDto;
|
||||||
|
use App\Service\TimezoneService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
class UserProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate the two-factor authentication state for the request.
|
||||||
|
*/
|
||||||
|
protected function validateTwoFactorAuthenticationState(Request $request): void
|
||||||
|
{
|
||||||
|
if (! Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentTime = time();
|
||||||
|
|
||||||
|
// Notate totally disabled state in session...
|
||||||
|
if ($this->twoFactorAuthenticationDisabled($request)) {
|
||||||
|
$request->session()->put('two_factor_empty_at', $currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If was previously totally disabled this session but is now confirming, notate time...
|
||||||
|
if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) {
|
||||||
|
$request->session()->put('two_factor_confirming_at', $currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the profile is reloaded and is not confirmed but was previously in confirming state, disable...
|
||||||
|
if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) {
|
||||||
|
app(DisableTwoFactorAuthentication::class)(Auth::user());
|
||||||
|
|
||||||
|
$request->session()->put('two_factor_empty_at', $currentTime);
|
||||||
|
$request->session()->remove('two_factor_confirming_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if two-factor authentication is totally disabled.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function twoFactorAuthenticationDisabled(Request $request)
|
||||||
|
{
|
||||||
|
return is_null($request->user()->two_factor_secret) &&
|
||||||
|
is_null($request->user()->two_factor_confirmed_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if two-factor authentication is just now being confirmed within the last request cycle.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request)
|
||||||
|
{
|
||||||
|
return ! is_null($request->user()->two_factor_secret) &&
|
||||||
|
is_null($request->user()->two_factor_confirmed_at) &&
|
||||||
|
$request->session()->has('two_factor_empty_at') &&
|
||||||
|
is_null($request->session()->get('two_factor_confirming_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if two-factor authentication was never totally confirmed once confirmation started.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime)
|
||||||
|
{
|
||||||
|
return ! array_key_exists('code', $request->session()->getOldInput()) &&
|
||||||
|
is_null($request->user()->two_factor_confirmed_at) &&
|
||||||
|
$request->session()->get('two_factor_confirming_at', 0) !== $currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the general profile settings screen.
|
||||||
|
*/
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->validateTwoFactorAuthenticationState($request);
|
||||||
|
|
||||||
|
return Inertia::render('Profile/Show', [
|
||||||
|
'timezones' => app(TimezoneService::class)->getSelectOptions(),
|
||||||
|
'weekdays' => Weekday::toSelectArray(),
|
||||||
|
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||||
|
'sessions' => $this->sessions($request),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current sessions.
|
||||||
|
*
|
||||||
|
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&\stdClass>
|
||||||
|
*/
|
||||||
|
public function sessions(Request $request): array
|
||||||
|
{
|
||||||
|
if (config('session.driver') !== 'database') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect(
|
||||||
|
DB::connection(config('session.connection'))->table(config('session.table', 'sessions'))
|
||||||
|
->where('user_id', $request->user()->getAuthIdentifier())
|
||||||
|
->orderBy('last_activity', 'desc')
|
||||||
|
->get()
|
||||||
|
)->map(function (object $session) use ($request): object {
|
||||||
|
$agent = $this->createAgent(is_string($session->user_agent) ? $session->user_agent : '');
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'agent' => [
|
||||||
|
'is_desktop' => $agent->isDesktop(),
|
||||||
|
'platform' => $agent->platform(),
|
||||||
|
'browser' => $agent->browser(),
|
||||||
|
],
|
||||||
|
'ip_address' => is_string($session->ip_address) ? $session->ip_address : '',
|
||||||
|
'is_current_device' => $session->id === $request->session()->getId(),
|
||||||
|
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new agent instance from the given session.
|
||||||
|
*/
|
||||||
|
protected function createAgent(string $userAgent): UserAgentDto
|
||||||
|
{
|
||||||
|
return tap(new UserAgentDto, fn ($agent) => $agent->setUserAgent($userAgent));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ class EnsureEmailIsVerified
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
|
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
|
||||||
{
|
{
|
||||||
if (! app()->isLocal()) {
|
if (! app()->isLocal() || config('app.local_email_verification')) {
|
||||||
if ($request->user() === null ||
|
if ($request->user() === null ||
|
||||||
(! $request->user()->hasVerifiedEmail())) {
|
(! $request->user()->hasVerifiedEmail())) {
|
||||||
return $request->expectsJson()
|
return $request->expectsJson()
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ use App\Models\User;
|
|||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Session;
|
use Illuminate\Support\Facades\Session;
|
||||||
use Illuminate\Support\MessageBag;
|
use Illuminate\Support\MessageBag;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class ShareInertiaData
|
class ShareInertiaData
|
||||||
@@ -27,27 +25,8 @@ class ShareInertiaData
|
|||||||
/** @var PermissionStore $permissions */
|
/** @var PermissionStore $permissions */
|
||||||
$permissions = app(PermissionStore::class);
|
$permissions = app(PermissionStore::class);
|
||||||
Inertia::share([
|
Inertia::share([
|
||||||
'jetstream' => function () use ($request) {
|
|
||||||
/** @var User|null $user */
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'canCreateTeams' => $user !== null &&
|
|
||||||
Jetstream::userHasTeamFeatures($user) &&
|
|
||||||
Gate::forUser($user)->check('create', Jetstream::newTeamModel()),
|
|
||||||
'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),
|
|
||||||
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
|
||||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
|
||||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
|
||||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
|
||||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
|
||||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
|
||||||
'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
|
||||||
'managesProfilePhotos' => Jetstream::managesProfilePhotos(),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
|
'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [],
|
||||||
'user' => function () use ($request): array {
|
'user' => function () use ($request): array {
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -56,6 +35,8 @@ class ShareInertiaData
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currentOrganization = $user->currentOrganization;
|
||||||
|
|
||||||
return array_merge([
|
return array_merge([
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
@@ -68,12 +49,12 @@ class ShareInertiaData
|
|||||||
'profile_photo_url' => $user->profile_photo_url,
|
'profile_photo_url' => $user->profile_photo_url,
|
||||||
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
|
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
|
||||||
&& ! is_null($user->two_factor_secret),
|
&& ! is_null($user->two_factor_secret),
|
||||||
'current_team' => $user->currentTeam !== null ? [
|
'current_team' => $currentOrganization !== null ? [
|
||||||
'id' => $user->currentTeam->id,
|
'id' => $currentOrganization->id,
|
||||||
'user_id' => $user->currentTeam->user_id,
|
'user_id' => $currentOrganization->user_id,
|
||||||
'name' => $user->currentTeam->name,
|
'name' => $currentOrganization->name,
|
||||||
'personal_team' => $user->currentTeam->personal_team,
|
'personal_team' => $currentOrganization->personal_team,
|
||||||
'currency' => $user->currentTeam->currency,
|
'currency' => $currentOrganization->currency,
|
||||||
] : null,
|
] : null,
|
||||||
], array_filter([
|
], array_filter([
|
||||||
'all_teams' => $user->organizations->map(function (Organization $organization): array {
|
'all_teams' => $user->organizations->map(function (Organization $organization): array {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email:rfc,strict',
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Organization;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class OrganizationDestroyRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, callable(Validator): void>
|
||||||
|
*/
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
if ($validator->errors()->has('password')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->user();
|
||||||
|
$password = $this->input('password');
|
||||||
|
|
||||||
|
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
|
||||||
|
$validator->errors()->add('password', __('The password is incorrect.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Rules\CurrencyRule;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +23,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,10 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
|
'currency' => [
|
||||||
|
'string',
|
||||||
|
new CurrencyRule,
|
||||||
|
],
|
||||||
'billable_rate' => array_merge(
|
'billable_rate' => array_merge(
|
||||||
[
|
[
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -68,6 +74,11 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
return $this->has('name') ? (string) $this->input('name') : null;
|
return $this->has('name') ? (string) $this->input('name') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getCurrency(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('currency') ? (string) $this->input('currency') : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function getNumberFormat(): ?NumberFormat
|
public function getNumberFormat(): ?NumberFormat
|
||||||
{
|
{
|
||||||
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;
|
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\Report;
|
namespace App\Http\Requests\V1\Report;
|
||||||
|
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
@@ -124,6 +125,11 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'properties.tag_match_type' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::enum(TagMatchType::class),
|
||||||
|
],
|
||||||
'properties.task_ids' => [
|
'properties.task_ids' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'array',
|
'array',
|
||||||
@@ -249,6 +255,15 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPropertyTagMatchType(): ?TagMatchType
|
||||||
|
{
|
||||||
|
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TagMatchType::from($this->input('properties.tag_match_type'));
|
||||||
|
}
|
||||||
|
|
||||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
@@ -139,6 +140,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'tag_match_type' => [
|
||||||
|
'string',
|
||||||
|
Rule::enum(TagMatchType::class),
|
||||||
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -246,6 +251,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTagMatchType(): ?TagMatchType
|
||||||
|
{
|
||||||
|
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TagMatchType::from($this->validated('tag_match_type'));
|
||||||
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
@@ -125,6 +126,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'tag_match_type' => [
|
||||||
|
'string',
|
||||||
|
Rule::enum(TagMatchType::class),
|
||||||
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -208,6 +213,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTagMatchType(): ?TagMatchType
|
||||||
|
{
|
||||||
|
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TagMatchType::from($this->validated('tag_match_type'));
|
||||||
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -110,6 +111,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'tag_match_type' => [
|
||||||
|
'string',
|
||||||
|
Rule::enum(TagMatchType::class),
|
||||||
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -215,6 +220,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTagMatchType(): ?TagMatchType
|
||||||
|
{
|
||||||
|
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TagMatchType::from($this->validated('tag_match_type'));
|
||||||
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
@@ -103,6 +104,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'tag_match_type' => [
|
||||||
|
'string',
|
||||||
|
Rule::enum(TagMatchType::class),
|
||||||
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -190,6 +195,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTagMatchType(): ?TagMatchType
|
||||||
|
{
|
||||||
|
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TagMatchType::from($this->validated('tag_match_type'));
|
||||||
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
48
app/Http/Requests/V1/User/UserDestroyRequest.php
Normal file
48
app/Http/Requests/V1/User/UserDestroyRequest.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\User;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UserDestroyRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, callable(Validator): void>
|
||||||
|
*/
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
if ($validator->errors()->has('password')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->user();
|
||||||
|
$password = $this->input('password');
|
||||||
|
|
||||||
|
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
|
||||||
|
$validator->errors()->add('password', __('The password is incorrect.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\User;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class UserUpdateCurrentOrganizationRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'organization_id' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'uuid',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganizationId(): string
|
||||||
|
{
|
||||||
|
return (string) $this->input('organization_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'email',
|
'email:rfc,strict',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class DetailedReportResource extends BaseResource
|
|||||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||||
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
||||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||||
|
/** @var string|null $tag_match_type Tag match type */
|
||||||
|
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
||||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||||
/** @var string|null $rounding_type Rounding type for time entries */
|
/** @var string|null $rounding_type Rounding type for time entries */
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the job if its models no longer exist.
|
||||||
|
*/
|
||||||
|
public bool $deleteWhenMissingModels = true;
|
||||||
|
|
||||||
public Project $project;
|
public Project $project;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the job if its models no longer exist.
|
||||||
|
*/
|
||||||
|
public bool $deleteWhenMissingModels = true;
|
||||||
|
|
||||||
public Task $task;
|
public Task $task;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ use App\Models\Concerns\HasUuids;
|
|||||||
use Database\Factories\MemberFactory;
|
use Database\Factories\MemberFactory;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Laravel\Jetstream\Membership as JetstreamMembership;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +31,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
*
|
*
|
||||||
* @method static MemberFactory factory()
|
* @method static MemberFactory factory()
|
||||||
*/
|
*/
|
||||||
class Member extends JetstreamMembership implements AuditableContract
|
class Member extends Pivot implements AuditableContract
|
||||||
{
|
{
|
||||||
use CustomAuditable;
|
use CustomAuditable;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Models\Concerns\HasUuids;
|
|||||||
use Database\Factories\OrganizationFactory;
|
use Database\Factories\OrganizationFactory;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
@@ -21,11 +22,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Jetstream\Events\TeamCreated;
|
|
||||||
use Laravel\Jetstream\Events\TeamDeleted;
|
|
||||||
use Laravel\Jetstream\Events\TeamUpdated;
|
|
||||||
use Laravel\Jetstream\Team;
|
|
||||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +49,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
*
|
*
|
||||||
* @method static OrganizationFactory factory()
|
* @method static OrganizationFactory factory()
|
||||||
*/
|
*/
|
||||||
class Organization extends JetstreamTeam implements AuditableContract
|
class Organization extends Model implements AuditableContract
|
||||||
{
|
{
|
||||||
use CustomAuditable;
|
use CustomAuditable;
|
||||||
|
|
||||||
@@ -91,17 +87,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'personal_team',
|
'personal_team',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* The event map for the model.
|
|
||||||
*
|
|
||||||
* @var array<string, class-string>
|
|
||||||
*/
|
|
||||||
protected $dispatchesEvents = [
|
|
||||||
'created' => TeamCreated::class,
|
|
||||||
'updated' => TeamUpdated::class,
|
|
||||||
'deleted' => TeamDeleted::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model's default values for attributes.
|
* The model's default values for attributes.
|
||||||
*
|
*
|
||||||
@@ -163,12 +148,13 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method prevents an unhandled exception when the ID is not a UUID.
|
* Find a model by its primary key or throw an exception.
|
||||||
* Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
|
|
||||||
*
|
*
|
||||||
* @param array<string> $columns
|
* @param array<int, string> $columns
|
||||||
|
*
|
||||||
|
* @throws ModelNotFoundException<Model>
|
||||||
*/
|
*/
|
||||||
public function findOrFail(string $id, array $columns = ['*']): Team
|
public static function findOrFail(string $id, array $columns = ['*']): Model
|
||||||
{
|
{
|
||||||
if (! Str::isUuid($id)) {
|
if (! Str::isUuid($id)) {
|
||||||
throw (new ModelNotFoundException)->setModel(
|
throw (new ModelNotFoundException)->setModel(
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use App\Models\Concerns\CustomAuditable;
|
|||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\OrganizationInvitationFactory;
|
use Database\Factories\OrganizationInvitationFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +25,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
*
|
*
|
||||||
* @method static OrganizationInvitationFactory factory()
|
* @method static OrganizationInvitationFactory factory()
|
||||||
*/
|
*/
|
||||||
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
|
class OrganizationInvitation extends Model implements AuditableContract
|
||||||
{
|
{
|
||||||
use CustomAuditable;
|
use CustomAuditable;
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
|
||||||
use Laravel\Jetstream\HasTeams;
|
|
||||||
use Laravel\Passport\AuthCode;
|
use Laravel\Passport\AuthCode;
|
||||||
use Laravel\Passport\Contracts\OAuthenticatable;
|
use Laravel\Passport\Contracts\OAuthenticatable;
|
||||||
use Laravel\Passport\HasApiTokens;
|
use Laravel\Passport\HasApiTokens;
|
||||||
@@ -46,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
* @property string|null $profile_photo_path
|
* @property string|null $profile_photo_path
|
||||||
* @property-read Organization|null $currentOrganization
|
* @property-read Organization|null $currentOrganization
|
||||||
* @property-read Organization|null $currentTeam
|
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
* @property-read Collection<int, Token> $tokens
|
* @property-read Collection<int, Token> $tokens
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
@@ -71,8 +68,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use HasProfilePhoto;
|
|
||||||
use HasTeams;
|
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
use Notifiable;
|
use Notifiable;
|
||||||
use TwoFactorAuthenticatable;
|
use TwoFactorAuthenticatable;
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class OrganizationPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can view any models.
|
|
||||||
*/
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can view the model.
|
|
||||||
*/
|
|
||||||
public function view(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->isMemberOfOrganization($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can create models.
|
|
||||||
*/
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can update the model.
|
|
||||||
*/
|
|
||||||
public function update(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can update team member permissions.
|
|
||||||
*/
|
|
||||||
public function updateTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: since this policy is only used for jetstream endpoints, we can return false here
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can remove team members.
|
|
||||||
*/
|
|
||||||
public function removeTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can delete the model.
|
|
||||||
*/
|
|
||||||
public function delete(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\Passport\AuthCode;
|
use App\Models\Passport\AuthCode;
|
||||||
use App\Models\Passport\Client;
|
use App\Models\Passport\Client;
|
||||||
use App\Models\Passport\RefreshToken;
|
use App\Models\Passport\RefreshToken;
|
||||||
use App\Models\Passport\Token;
|
use App\Models\Passport\Token;
|
||||||
use App\Policies\OrganizationPolicy;
|
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
@@ -22,7 +19,6 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
* @var array<class-string, class-string>
|
* @var array<class-string, class-string>
|
||||||
*/
|
*/
|
||||||
protected $policies = [
|
protected $policies = [
|
||||||
Organization::class => OrganizationPolicy::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,11 +52,5 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
// Passport::tokensExpireIn(now()->addDays(15));
|
// Passport::tokensExpireIn(now()->addDays(15));
|
||||||
// Passport::refreshTokensExpireIn(now()->addDays(30));
|
// Passport::refreshTokensExpireIn(now()->addDays(30));
|
||||||
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
|
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
|
||||||
|
|
||||||
// same as passport default above
|
|
||||||
Jetstream::defaultApiTokenPermissions(['read']);
|
|
||||||
|
|
||||||
// use passport scopes for jetstream token permissions
|
|
||||||
Jetstream::permissions(Passport::scopeIds());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ use Illuminate\Cache\RateLimiting\Limit;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
use Laravel\Fortify\Http\Responses\LoginResponse;
|
|
||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -50,6 +51,40 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Fortify::loginView(function () {
|
||||||
|
return Inertia::render('Auth/Login', [
|
||||||
|
'canResetPassword' => Route::has('password.request'),
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Fortify::requestPasswordResetLinkView(function () {
|
||||||
|
return Inertia::render('Auth/ForgotPassword', [
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Fortify::resetPasswordView(function (Request $request) {
|
||||||
|
return Inertia::render('Auth/ResetPassword', [
|
||||||
|
'email' => $request->input('email'),
|
||||||
|
'token' => $request->route('token'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Fortify::verifyEmailView(function () {
|
||||||
|
return Inertia::render('Auth/VerifyEmail', [
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Fortify::twoFactorChallengeView(function () {
|
||||||
|
return Inertia::render('Auth/TwoFactorChallenge');
|
||||||
|
});
|
||||||
|
|
||||||
|
Fortify::confirmPasswordView(function () {
|
||||||
|
return Inertia::render('Auth/ConfirmPassword');
|
||||||
|
});
|
||||||
|
|
||||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
@@ -74,7 +109,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->app->instance(LoginResponse::class, new CustomLoginResponse);
|
$this->app->instance(LoginResponseContract::class, new CustomLoginResponse);
|
||||||
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
|
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Actions\Jetstream\AddOrganizationMember;
|
|
||||||
use App\Actions\Jetstream\CreateOrganization;
|
|
||||||
use App\Actions\Jetstream\DeleteOrganization;
|
|
||||||
use App\Actions\Jetstream\DeleteUser;
|
|
||||||
use App\Actions\Jetstream\InviteOrganizationMember;
|
|
||||||
use App\Actions\Jetstream\RemoveOrganizationMember;
|
|
||||||
use App\Actions\Jetstream\UpdateMemberRole;
|
|
||||||
use App\Actions\Jetstream\UpdateOrganization;
|
|
||||||
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
|
||||||
use App\Enums\Weekday;
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\OrganizationInvitation;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use App\Service\TimezoneService;
|
|
||||||
use Brick\Money\Currency;
|
|
||||||
use Brick\Money\ISOCurrencyProvider;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
|
||||||
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
|
|
||||||
class JetstreamServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
$this->configurePermissions();
|
|
||||||
|
|
||||||
Jetstream::createTeamsUsing(CreateOrganization::class);
|
|
||||||
Jetstream::updateTeamNamesUsing(UpdateOrganization::class);
|
|
||||||
Jetstream::addTeamMembersUsing(AddOrganizationMember::class);
|
|
||||||
Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class);
|
|
||||||
Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class);
|
|
||||||
Jetstream::deleteTeamsUsing(DeleteOrganization::class);
|
|
||||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
|
||||||
Jetstream::useTeamModel(Organization::class);
|
|
||||||
Jetstream::useMembershipModel(Member::class);
|
|
||||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
|
||||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
|
||||||
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
|
||||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the roles and permissions that are available within the application.
|
|
||||||
*/
|
|
||||||
protected function configurePermissions(): void
|
|
||||||
{
|
|
||||||
Jetstream::defaultApiTokenPermissions([]);
|
|
||||||
|
|
||||||
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
|
|
||||||
Jetstream::role($role, $definition['name'], $definition['permissions'])
|
|
||||||
->description($definition['description']);
|
|
||||||
}
|
|
||||||
|
|
||||||
Jetstream::inertia()
|
|
||||||
->whenRendering(
|
|
||||||
'Profile/Show',
|
|
||||||
function (Request $request, array $data): array {
|
|
||||||
return array_merge($data, [
|
|
||||||
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
|
|
||||||
'weekdays' => Weekday::toSelectArray(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
->whenRendering(
|
|
||||||
'Teams/Show',
|
|
||||||
function (Request $request, array $data): array {
|
|
||||||
/** @var Organization $teamModel */
|
|
||||||
$teamModel = $data['team'];
|
|
||||||
$owner = $teamModel->owner;
|
|
||||||
|
|
||||||
return array_merge($data, [
|
|
||||||
'team' => [
|
|
||||||
'id' => $teamModel->getKey(),
|
|
||||||
'name' => $teamModel->name,
|
|
||||||
'currency' => $teamModel->currency,
|
|
||||||
'owner' => [
|
|
||||||
'id' => $owner->getKey(),
|
|
||||||
'name' => $owner->name,
|
|
||||||
'profile_photo_url' => $owner->profile_photo_url,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'currencies' => array_map(function (Currency $currency): string {
|
|
||||||
return $currency->getName();
|
|
||||||
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service\Dto;
|
namespace App\Service\Dto;
|
||||||
|
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
@@ -56,6 +57,8 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public ?Collection $tagIds = null;
|
public ?Collection $tagIds = null;
|
||||||
|
|
||||||
|
public ?TagMatchType $tagMatchType = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, string>|null
|
* @var Collection<int, string>|null
|
||||||
*/
|
*/
|
||||||
@@ -115,6 +118,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||||
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||||
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||||
|
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||||
@@ -144,6 +148,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
'clientIds' => $value->clientIds?->toArray(),
|
'clientIds' => $value->clientIds?->toArray(),
|
||||||
'projectIds' => $value->projectIds?->toArray(),
|
'projectIds' => $value->projectIds?->toArray(),
|
||||||
'tagIds' => $value->tagIds?->toArray(),
|
'tagIds' => $value->tagIds?->toArray(),
|
||||||
|
'tagMatchType' => $value->tagMatchType?->value,
|
||||||
'taskIds' => $value->taskIds?->toArray(),
|
'taskIds' => $value->taskIds?->toArray(),
|
||||||
'group' => $value->group->value,
|
'group' => $value->group->value,
|
||||||
'subGroup' => $value->subGroup->value,
|
'subGroup' => $value->subGroup->value,
|
||||||
@@ -216,6 +221,11 @@ class ReportPropertiesDto implements Castable
|
|||||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||||
|
{
|
||||||
|
$this->tagMatchType = $tagMatchType;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed>|null $taskIds
|
* @param array<mixed>|null $taskIds
|
||||||
*/
|
*/
|
||||||
|
|||||||
179
app/Service/Dto/UserAgentDto.php
Normal file
179
app/Service/Dto/UserAgentDto.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Dto;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Detection\MobileDetect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Originally created by Jens Segers: https://github.com/jenssegers/agent
|
||||||
|
*/
|
||||||
|
class UserAgentDto extends MobileDetect
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List of additional operating systems.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected static array $additionalOperatingSystems = [
|
||||||
|
'Windows' => 'Windows',
|
||||||
|
'Windows NT' => 'Windows NT',
|
||||||
|
'OS X' => 'Mac OS X',
|
||||||
|
'Debian' => 'Debian',
|
||||||
|
'Ubuntu' => 'Ubuntu',
|
||||||
|
'Macintosh' => 'PPC',
|
||||||
|
'OpenBSD' => 'OpenBSD',
|
||||||
|
'Linux' => 'Linux',
|
||||||
|
'ChromeOS' => 'CrOS',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of additional browsers.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected static array $additionalBrowsers = [
|
||||||
|
'Opera Mini' => 'Opera Mini',
|
||||||
|
'Opera' => 'Opera|OPR',
|
||||||
|
'Edge' => 'Edge|Edg',
|
||||||
|
'Coc Coc' => 'coc_coc_browser',
|
||||||
|
'UCBrowser' => 'UCBrowser',
|
||||||
|
'Vivaldi' => 'Vivaldi',
|
||||||
|
'Chrome' => 'Chrome',
|
||||||
|
'Firefox' => 'Firefox',
|
||||||
|
'Safari' => 'Safari',
|
||||||
|
'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+',
|
||||||
|
'Netscape' => 'Netscape',
|
||||||
|
'Mozilla' => 'Mozilla',
|
||||||
|
'WeChat' => 'MicroMessenger',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key value store for resolved strings.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected array $store = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the platform name from the User Agent.
|
||||||
|
*/
|
||||||
|
public function platform(): ?string
|
||||||
|
{
|
||||||
|
return $this->retrieveUsingCacheOrResolve('platform', function () {
|
||||||
|
return $this->findDetectionRulesAgainstUserAgent(
|
||||||
|
$this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the browser name from the User Agent.
|
||||||
|
*/
|
||||||
|
public function browser(): ?string
|
||||||
|
{
|
||||||
|
return $this->retrieveUsingCacheOrResolve('browser', function (): ?string {
|
||||||
|
return $this->findDetectionRulesAgainstUserAgent(
|
||||||
|
$this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the device is a desktop computer.
|
||||||
|
*/
|
||||||
|
public function isDesktop(): bool
|
||||||
|
{
|
||||||
|
return $this->retrieveUsingCacheOrResolve('desktop', function (): bool {
|
||||||
|
// Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront'
|
||||||
|
if (
|
||||||
|
$this->getUserAgent() === static::$cloudFrontUA
|
||||||
|
&& $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->isMobile() && ! $this->isTablet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a detection rule and return the matched key.
|
||||||
|
*
|
||||||
|
* @param array<string, string|list<string>> $rules
|
||||||
|
*/
|
||||||
|
protected function findDetectionRulesAgainstUserAgent(array $rules): ?string
|
||||||
|
{
|
||||||
|
$userAgent = $this->getUserAgent();
|
||||||
|
|
||||||
|
foreach ($rules as $key => $regex) {
|
||||||
|
if (is_array($regex)) {
|
||||||
|
$regex = implode('|', $regex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($regex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->match($regex, $userAgent)) {
|
||||||
|
if ($key !== '') {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = reset($this->matchesArray);
|
||||||
|
|
||||||
|
return is_string($match) ? $match : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve from the given key from the cache or resolve the value.
|
||||||
|
*
|
||||||
|
* @template TReturn of string|bool|null
|
||||||
|
*
|
||||||
|
* @param Closure():TReturn $callback
|
||||||
|
* @return TReturn
|
||||||
|
*/
|
||||||
|
protected function retrieveUsingCacheOrResolve(string $key, Closure $callback): string|bool|null
|
||||||
|
{
|
||||||
|
$cacheKey = $this->createCacheKey($key);
|
||||||
|
|
||||||
|
if (! is_null($cacheItem = $this->store[$cacheKey] ?? null)) {
|
||||||
|
return $cacheItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tap(call_user_func($callback), function ($result) use ($cacheKey): void {
|
||||||
|
$this->store[$cacheKey] = $result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge multiple rules into one array.
|
||||||
|
*
|
||||||
|
* @param array<string, string|list<string>> ...$all
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function mergeRules(array ...$all): array
|
||||||
|
{
|
||||||
|
$merged = [];
|
||||||
|
|
||||||
|
foreach ($all as $rules) {
|
||||||
|
foreach ($rules as $key => $value) {
|
||||||
|
$value = is_array($value) ? implode('|', $value) : $value;
|
||||||
|
|
||||||
|
if (empty($merged[$key])) {
|
||||||
|
$merged[$key] = $value;
|
||||||
|
} else {
|
||||||
|
$merged[$key] .= '|'.$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Import\Importers;
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
@@ -24,10 +25,12 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$billableRateKey = $this->getBillableRateKey($header);
|
$billableRateKey = $this->getBillableRateKey($header);
|
||||||
|
$tasksKey = $this->getTasksKey($header);
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if ($record['Client'] !== '') {
|
// Newer Clockify exports no longer contain a "Client" column.
|
||||||
|
if (($record['Client'] ?? '') !== '') {
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
'name' => $record['Client'],
|
'name' => $record['Client'],
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
@@ -43,12 +46,13 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'color' => $this->colorService->getRandomColor(),
|
'color' => $this->colorService->getRandomColor(),
|
||||||
'is_billable' => $record['Billability'] === 'Yes',
|
'is_billable' => $record['Billability'] === 'Yes',
|
||||||
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
||||||
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||||
|
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record['Task'] !== '') {
|
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
||||||
$tasks = explode(', ', $record['Task']);
|
$tasks = explode(', ', $record[$tasksKey]);
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task,
|
'name' => $task,
|
||||||
@@ -77,17 +81,36 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
'Project',
|
'Project',
|
||||||
'Client',
|
|
||||||
'Status',
|
'Status',
|
||||||
'Visibility',
|
'Visibility',
|
||||||
'Billability',
|
'Billability',
|
||||||
'Task',
|
|
||||||
];
|
];
|
||||||
foreach ($requiredFields as $requiredField) {
|
foreach ($requiredFields as $requiredField) {
|
||||||
if (! in_array($requiredField, $header, true)) {
|
if (! in_array($requiredField, $header, true)) {
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
|
||||||
|
if ($this->getTasksKey($header) === null) {
|
||||||
|
throw new ImportException('Invalid CSV header, missing field: Tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clockify names the tasks column differently depending on the export
|
||||||
|
* version: "Task" (older), "Tasks" (newer) or "Activities".
|
||||||
|
*
|
||||||
|
* @param array<string> $header
|
||||||
|
*/
|
||||||
|
private function getTasksKey(array $header): ?string
|
||||||
|
{
|
||||||
|
foreach (['Tasks', 'Task', 'Activities'] as $field) {
|
||||||
|
if (in_array($field, $header, true)) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$reader->setEscape('');
|
$reader->setEscape('');
|
||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
|
$taskKey = $this->getTaskKey($header);
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$userId = $this->userImportHelper->getKey([
|
$userId = $this->userImportHelper->getKey([
|
||||||
@@ -71,7 +72,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
]);
|
]);
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
$member = $this->memberImportHelper->getModelById($memberId);
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if ($record['Client'] !== '') {
|
if (($record['Client'] ?? '') !== '') {
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
'name' => $record['Client'],
|
'name' => $record['Client'],
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
@@ -96,9 +97,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['Task'] !== '') {
|
if ($taskKey !== null && $record[$taskKey] !== '') {
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['Task'],
|
'name' => $record[$taskKey],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
]);
|
]);
|
||||||
@@ -116,10 +117,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
if (isset($record['Billable'])) {
|
||||||
throw new ImportException('Invalid billable value');
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
|
throw new ImportException('Invalid billable value');
|
||||||
|
}
|
||||||
|
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
|
||||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
|
|
||||||
@@ -212,14 +215,11 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
'Project',
|
'Project',
|
||||||
'Client',
|
|
||||||
'Description',
|
'Description',
|
||||||
'Task',
|
|
||||||
'User',
|
'User',
|
||||||
'Group',
|
'Group',
|
||||||
'Email',
|
'Email',
|
||||||
'Tags',
|
'Tags',
|
||||||
'Billable',
|
|
||||||
'Start Date',
|
'Start Date',
|
||||||
'Start Time',
|
'Start Time',
|
||||||
'End Date',
|
'End Date',
|
||||||
@@ -230,6 +230,26 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
|
||||||
|
if ($this->getTaskKey($header) === null) {
|
||||||
|
throw new ImportException('Invalid CSV header, missing field: Task');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clockify names the task column "Task" or "Activity" depending on the export version.
|
||||||
|
*
|
||||||
|
* @param array<string> $header
|
||||||
|
*/
|
||||||
|
private function getTaskKey(array $header): ?string
|
||||||
|
{
|
||||||
|
foreach (['Task', 'Activity'] as $field) {
|
||||||
|
if (in_array($field, $header, true)) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\Override]
|
#[\Override]
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
}, validate: [
|
}, validate: [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email:rfc,strict',
|
||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($projects as $project) {
|
foreach ($projects as $project) {
|
||||||
|
$projectExternalId = $this->guardExternalIdentifier($project->id);
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if ($project->client_id !== null) {
|
if ($project->client_id !== null) {
|
||||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
||||||
@@ -146,16 +147,16 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
||||||
], (string) $project->id);
|
], (string) $project->id);
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {
|
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
|
||||||
throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP');
|
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
|
||||||
}
|
}
|
||||||
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json'));
|
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'));
|
||||||
if ($projectMembersFileContent === false) {
|
if ($projectMembersFileContent === false) {
|
||||||
throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened');
|
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened');
|
||||||
}
|
}
|
||||||
$projectMembers = json_decode($projectMembersFileContent);
|
$projectMembers = json_decode($projectMembersFileContent);
|
||||||
if ($projectMembers === null) {
|
if ($projectMembers === null) {
|
||||||
throw new ImportException('File "projects_users/'.$project->id.'.json" is empty');
|
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty');
|
||||||
}
|
}
|
||||||
foreach ($projectMembers as $projectMember) {
|
foreach ($projectMembers as $projectMember) {
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
||||||
@@ -170,6 +171,7 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$projectIds = $this->projectImportHelper->getExternalIds();
|
$projectIds = $this->projectImportHelper->getExternalIds();
|
||||||
foreach ($projectIds as $projectIdExternal) {
|
foreach ($projectIds as $projectIdExternal) {
|
||||||
|
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
|
||||||
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
|
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -209,6 +211,30 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure an externally-sourced identifier can be safely used inside a
|
||||||
|
* filesystem path. The identifiers originate from the untrusted uploaded
|
||||||
|
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
|
||||||
|
* parent directory of the resolved path, so an unfiltered "../" sequence
|
||||||
|
* would escape the import sandbox and create/probe arbitrary paths on the
|
||||||
|
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
|
||||||
|
* conservative allow-list rejects traversal without affecting real data.
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function guardExternalIdentifier(mixed $id): string
|
||||||
|
{
|
||||||
|
if (! is_string($id) && ! is_int($id)) {
|
||||||
|
throw new ImportException('Invalid identifier in import data');
|
||||||
|
}
|
||||||
|
$id = (string) $id;
|
||||||
|
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
|
||||||
|
throw new ImportException('Invalid identifier in import data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
#[Override]
|
#[Override]
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\OrganizationInvitationAdding;
|
||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
@@ -14,14 +15,13 @@ use App\Models\User;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
|
||||||
|
|
||||||
class InvitationService
|
class InvitationService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
@@ -34,7 +34,7 @@ class InvitationService
|
|||||||
throw new InvitationForTheEmailAlreadyExistsApiException;
|
throw new InvitationForTheEmailAlreadyExistsApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
InvitingTeamMember::dispatch($organization, $email, $role->value);
|
OrganizationInvitationAdding::dispatch($organization, $email, $role, $inviter);
|
||||||
|
|
||||||
$invitation = new OrganizationInvitation;
|
$invitation = new OrganizationInvitation;
|
||||||
$invitation->email = $email;
|
$invitation->email = $email;
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ use App\Models\User;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class MemberService
|
class MemberService
|
||||||
{
|
{
|
||||||
@@ -39,7 +37,6 @@ class MemberService
|
|||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
MemberAdding::dispatch($user, $organization, $role);
|
MemberAdding::dispatch($user, $organization, $role);
|
||||||
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -56,7 +53,6 @@ class MemberService
|
|||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
MemberAdded::dispatch($member, $organization, $user);
|
MemberAdded::dispatch($member, $organization, $user);
|
||||||
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
return $member;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\TagMatchType;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -62,7 +63,7 @@ class TimeEntryFilter
|
|||||||
if ($start === null) {
|
if ($start === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->where('start', '>', $start);
|
$this->builder->where('start', '>=', $start);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -192,15 +193,21 @@ class TimeEntryFilter
|
|||||||
/**
|
/**
|
||||||
* @param array<string>|null $tagIds
|
* @param array<string>|null $tagIds
|
||||||
*/
|
*/
|
||||||
public function addTagIdsFilter(?array $tagIds): self
|
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
|
||||||
{
|
{
|
||||||
if ($tagIds === null) {
|
if ($tagIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
$tagMatchType ??= TagMatchType::Contains;
|
||||||
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||||
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
|
||||||
|
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
|
||||||
|
if (count($tagIds) === 0 && ! $includeNone) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
$builder->orWhereJsonContains('tags', $tagId);
|
||||||
}
|
}
|
||||||
@@ -209,7 +216,18 @@ class TimeEntryFilter
|
|||||||
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if ($tagMatchType === TagMatchType::NotContains) {
|
||||||
|
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
|
||||||
|
$builder->whereNot($tagCondition);
|
||||||
|
if (! $includeNone) {
|
||||||
|
$builder->orWhereNull('tags');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->builder->where($tagCondition);
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class UserService
|
|||||||
$intervalFormat,
|
$intervalFormat,
|
||||||
$timeFormat,
|
$timeFormat,
|
||||||
);
|
);
|
||||||
|
$this->switchCurrentOrganization($user, $organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
"korridor/laravel-computed-attributes": "^3.1",
|
"korridor/laravel-computed-attributes": "^3.1",
|
||||||
"korridor/laravel-has-many-sync": "^3.1",
|
"korridor/laravel-has-many-sync": "^3.1",
|
||||||
"korridor/laravel-model-validation-rules": "^3.0",
|
"korridor/laravel-model-validation-rules": "^3.0",
|
||||||
|
"laravel/fortify": "^1.37",
|
||||||
"laravel/framework": "^12.19.3",
|
"laravel/framework": "^12.19.3",
|
||||||
"laravel/jetstream": "^5.0",
|
|
||||||
"laravel/octane": "^2.3",
|
"laravel/octane": "^2.3",
|
||||||
"laravel/passport": "^13.0.5",
|
"laravel/passport": "^13.0.5",
|
||||||
"laravel/tinker": "^2.8",
|
"laravel/tinker": "^2.8",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"league/iso3166": "^4.3",
|
"league/iso3166": "^4.3",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
|
"mobiledetect/mobiledetectlib": "^4.11",
|
||||||
"novadaemon/filament-pretty-json": "^2.2",
|
"novadaemon/filament-pretty-json": "^2.2",
|
||||||
"nwidart/laravel-modules": "^12.0.4",
|
"nwidart/laravel-modules": "^12.0.4",
|
||||||
"owen-it/laravel-auditing": "^14.0.0",
|
"owen-it/laravel-auditing": "^14.0.0",
|
||||||
@@ -131,7 +132,8 @@
|
|||||||
"pestphp/pest-plugin": true,
|
"pestphp/pest-plugin": true,
|
||||||
"php-http/discovery": true,
|
"php-http/discovery": true,
|
||||||
"wikimedia/composer-merge-plugin": true
|
"wikimedia/composer-merge-plugin": true
|
||||||
}
|
},
|
||||||
|
"process-timeout": 900
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true
|
"prefer-stable": true
|
||||||
|
|||||||
92
composer.lock
generated
92
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "4c728f01d2beb426b2d157143618fdae",
|
"content-hash": "897ca7bc13f827db641f7affa54a8523",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -4413,72 +4413,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-05-20T11:48:19+00:00"
|
"time": "2026-05-20T11:48:19+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "laravel/jetstream",
|
|
||||||
"version": "v5.5.3",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/laravel/jetstream.git",
|
|
||||||
"reference": "61cac5cde455311890f6981fb2da47acd298e4e2"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/laravel/jetstream/zipball/61cac5cde455311890f6981fb2da47acd298e4e2",
|
|
||||||
"reference": "61cac5cde455311890f6981fb2da47acd298e4e2",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
||||||
"laravel/fortify": "^1.20",
|
|
||||||
"mobiledetect/mobiledetectlib": "^4.8.08",
|
|
||||||
"php": "^8.2.0",
|
|
||||||
"symfony/console": "^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
|
||||||
"laravel/sanctum": "^4.0",
|
|
||||||
"livewire/livewire": "^3.3",
|
|
||||||
"mockery/mockery": "^1.0",
|
|
||||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
|
||||||
"phpstan/phpstan": "^1.10"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Laravel\\Jetstream\\JetstreamServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Laravel\\Jetstream\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Taylor Otwell",
|
|
||||||
"email": "taylor@laravel.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Tailwind scaffolding for the Laravel framework.",
|
|
||||||
"keywords": [
|
|
||||||
"auth",
|
|
||||||
"laravel",
|
|
||||||
"tailwind"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/laravel/jetstream/issues",
|
|
||||||
"source": "https://github.com/laravel/jetstream"
|
|
||||||
},
|
|
||||||
"time": "2026-05-19T01:30:03+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "laravel/octane",
|
"name": "laravel/octane",
|
||||||
"version": "v2.17.4",
|
"version": "v2.17.4",
|
||||||
@@ -6445,16 +6379,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mobiledetect/mobiledetectlib",
|
"name": "mobiledetect/mobiledetectlib",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/serbanghita/Mobile-Detect.git",
|
"url": "https://github.com/serbanghita/Mobile-Detect.git",
|
||||||
"reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c"
|
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c",
|
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/ab39168b7556f44c11c80be1222b44b239f5c2e4",
|
||||||
"reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c",
|
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6497,7 +6431,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/serbanghita/Mobile-Detect/issues",
|
"issues": "https://github.com/serbanghita/Mobile-Detect/issues",
|
||||||
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0"
|
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.11.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6505,7 +6439,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-04-23T13:05:57+00:00"
|
"time": "2026-05-24T12:32:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
@@ -13803,16 +13737,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "web-auth/webauthn-lib",
|
"name": "web-auth/webauthn-lib",
|
||||||
"version": "5.3.3",
|
"version": "5.3.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/web-auth/webauthn-lib.git",
|
"url": "https://github.com/web-auth/webauthn-lib.git",
|
||||||
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
|
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
|
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f",
|
||||||
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
|
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -13873,7 +13807,7 @@
|
|||||||
"webauthn"
|
"webauthn"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
|
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -13885,7 +13819,7 @@
|
|||||||
"type": "patreon"
|
"type": "patreon"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-17T19:04:30+00:00"
|
"time": "2026-05-31T15:00:08+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webmozart/assert",
|
"name": "webmozart/assert",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use App\Providers\AuthServiceProvider;
|
|||||||
use App\Providers\EventServiceProvider;
|
use App\Providers\EventServiceProvider;
|
||||||
use App\Providers\Filament\AdminPanelProvider;
|
use App\Providers\Filament\AdminPanelProvider;
|
||||||
use App\Providers\FortifyServiceProvider;
|
use App\Providers\FortifyServiceProvider;
|
||||||
use App\Providers\JetstreamServiceProvider;
|
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -82,6 +81,8 @@ return [
|
|||||||
|
|
||||||
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
||||||
|
|
||||||
|
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Timezone
|
| Application Timezone
|
||||||
@@ -203,7 +204,6 @@ return [
|
|||||||
AdminPanelProvider::class,
|
AdminPanelProvider::class,
|
||||||
RouteServiceProvider::class,
|
RouteServiceProvider::class,
|
||||||
FortifyServiceProvider::class,
|
FortifyServiceProvider::class,
|
||||||
JetstreamServiceProvider::class,
|
|
||||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||||
LaravelModulesServiceProvider::class,
|
LaravelModulesServiceProvider::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Laravel\Jetstream\Features;
|
|
||||||
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Stack
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This configuration value informs Jetstream which "stack" you will be
|
|
||||||
| using for your application. In general, this value is set for you
|
|
||||||
| during installation and will not need to be changed after that.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'stack' => 'inertia',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Route Middleware
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify which middleware Jetstream will assign to the routes
|
|
||||||
| that it registers with the application. When necessary, you may modify
|
|
||||||
| these middleware; however, this default value is usually sufficient.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'middleware' => ['web'],
|
|
||||||
|
|
||||||
'auth_session' => AuthenticateSession::class,
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Guard
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify the authentication guard Jetstream will use while
|
|
||||||
| authenticating users. This value should correspond with one of your
|
|
||||||
| guards that is already present in your "auth" configuration file.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'guard' => 'web',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Features
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Some of Jetstream's features are optional. You may disable the features
|
|
||||||
| by removing them from this array. You're free to only remove some of
|
|
||||||
| these features or you can even remove all of these if you need to.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'features' => [
|
|
||||||
Features::termsAndPrivacyPolicy(),
|
|
||||||
Features::profilePhotos(),
|
|
||||||
Features::teams(['invitations' => true]),
|
|
||||||
Features::accountDeletion(),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Profile Photo Disk
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This configuration value determines the default disk that will be used
|
|
||||||
| when storing profile photos for your application's users. Typically
|
|
||||||
| this will be the "public" disk but you may adjust this if needed.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'profile_photo_disk' => env('PROFILE_PHOTO_DISK', env('PUBLIC_FILESYSTEM_DISK', 'public')),
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -94,7 +94,7 @@ class UserFactory extends Factory
|
|||||||
$profilePhoto = $this->faker->image(null, 500, 500);
|
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||||
/** @see FileHelpers::hashName */
|
/** @see FileHelpers::hashName */
|
||||||
$path = 'profile-photos/'.Str::random(40).'.png';
|
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||||
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
|
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
|
||||||
|
|
||||||
return $this->state(function (array $attributes) use ($path): array {
|
return $this->state(function (array $attributes) use ($path): array {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.1-jammy
|
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ ENV WITH_HORIZON=false \
|
|||||||
WITH_SCHEDULER=false \
|
WITH_SCHEDULER=false \
|
||||||
WITH_REVERB=false
|
WITH_REVERB=false
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
||||||
|
|
||||||
|
RUN test -z "$(find . -name .git -print -quit)"
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user