mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
337 Commits
feature/up
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7bc076cf | ||
|
|
f6b6b0a467 | ||
|
|
95645ddd91 | ||
|
|
1de985b577 | ||
|
|
d54296e66a | ||
|
|
95ddbf9ead | ||
|
|
70646a0dd4 | ||
|
|
5b12c09747 | ||
|
|
24023353f2 | ||
|
|
720d20c10e | ||
|
|
82ea9af8b5 | ||
|
|
169d522da0 | ||
|
|
45c7377802 | ||
|
|
8e57275cef | ||
|
|
efc6b55628 | ||
|
|
c12789376d | ||
|
|
4795812b60 | ||
|
|
0e00979ab8 | ||
|
|
f1426fcb5e | ||
|
|
23f512d4a4 | ||
|
|
637475e669 | ||
|
|
3ec2abb309 | ||
|
|
3e36b1cc01 | ||
|
|
7831bc697e | ||
|
|
602a8daa1f | ||
|
|
38b448a729 | ||
|
|
e6f071f87f | ||
|
|
693a1fa7e0 | ||
|
|
77f14b696e | ||
|
|
593372bae5 | ||
|
|
db9ca51fc4 | ||
|
|
28aba7b27a | ||
|
|
453d5ed066 | ||
|
|
ed3ba0135b | ||
|
|
fb7d945c50 | ||
|
|
9958e1c09c | ||
|
|
f00d48f1c3 | ||
|
|
7edbfd5d76 | ||
|
|
1edb940557 | ||
|
|
c9330e6cb8 | ||
|
|
0e9f652d84 | ||
|
|
d29f5706ce | ||
|
|
b2849ec04a | ||
|
|
bd69cf478c | ||
|
|
5197135d00 | ||
|
|
b09b8649b8 | ||
|
|
99fafdb8d6 | ||
|
|
b02e49c7e0 | ||
|
|
8609635d74 | ||
|
|
5a07f798b3 | ||
|
|
3af69830ee | ||
|
|
a050153bcd | ||
|
|
9d9731c7ce | ||
|
|
dfe3206614 | ||
|
|
97fd882878 | ||
|
|
111e12df12 | ||
|
|
de13c07855 | ||
|
|
29a2e994cd | ||
|
|
f6d886b218 | ||
|
|
80d98b30a1 | ||
|
|
32f2f1431b | ||
|
|
8f6d584ee9 | ||
|
|
1905cbf40c | ||
|
|
c8f668238e | ||
|
|
bf11bacdee | ||
|
|
f3c6a0b8ae | ||
|
|
619c602571 | ||
|
|
600daf44d9 | ||
|
|
a1d6c92806 | ||
|
|
ce1bd6a435 | ||
|
|
79d85227c2 | ||
|
|
885abf3333 | ||
|
|
c0c8fee6be | ||
|
|
cbcd1e51f6 | ||
|
|
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 | ||
|
|
7d9ecd9526 | ||
|
|
3a17f80f99 | ||
|
|
e29ea2ea42 | ||
|
|
fb6e4639ce | ||
|
|
69bc41988a | ||
|
|
f7663b1c8b | ||
|
|
793bd11dcf | ||
|
|
77a62afd69 | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c | ||
|
|
797fddf638 | ||
|
|
d07294ae7c | ||
|
|
1f49940805 | ||
|
|
6be6a48e0d | ||
|
|
b94a04dca0 | ||
|
|
bd3b8f265f | ||
|
|
c19a0f9acc | ||
|
|
5c6d84dc38 | ||
|
|
5c67709746 | ||
|
|
a2b0828c54 | ||
|
|
b94872b07b | ||
|
|
12bbbf64e9 | ||
|
|
c07ac4b0e4 | ||
|
|
a58566d002 | ||
|
|
57ed6036e6 | ||
|
|
ef7569b63b | ||
|
|
19c789b78e | ||
|
|
49548037b3 | ||
|
|
97df779d1e | ||
|
|
a1d5563fc4 | ||
|
|
c94ca804f8 | ||
|
|
189682cfaf | ||
|
|
8d16503541 | ||
|
|
e43ce477b8 | ||
|
|
5646aedb25 | ||
|
|
2b46e568e0 | ||
|
|
89a4a1962a | ||
|
|
c581ad8854 | ||
|
|
bce6cb9395 | ||
|
|
1cdae98ed9 | ||
|
|
02f6436fd0 | ||
|
|
452acca942 | ||
|
|
192c8c3b88 | ||
|
|
6218ffceb5 | ||
|
|
ba32be0543 | ||
|
|
bd817db06f | ||
|
|
97f4bce676 | ||
|
|
6962b668fb | ||
|
|
be8091296c | ||
|
|
84c4750c9b | ||
|
|
f582adab0d | ||
|
|
c60cff04ce | ||
|
|
cae41e4b4f | ||
|
|
8973be9dab | ||
|
|
2a0b8d31e6 | ||
|
|
d2f3fe411a | ||
|
|
f880f9f730 | ||
|
|
556bbedeca | ||
|
|
eed638d0aa | ||
|
|
864f41bda6 | ||
|
|
26524c5f40 | ||
|
|
cf98fabe0a | ||
|
|
88c0c334e9 | ||
|
|
0fc325363d | ||
|
|
1afc16573a | ||
|
|
147514a606 | ||
|
|
435522b502 | ||
|
|
f1d001e03e | ||
|
|
7f145cf1c2 | ||
|
|
b579ed1075 | ||
|
|
ed2b7476ae | ||
|
|
8107c6a208 | ||
|
|
6dc517e07d | ||
|
|
2c60d04ba4 | ||
|
|
2c222f3f67 | ||
|
|
c5c1a7af13 | ||
|
|
22cf7cf74d | ||
|
|
cfbfbd4b6a | ||
|
|
6629482a0e | ||
|
|
38457cae4d | ||
|
|
0e63ecb520 | ||
|
|
6f207a4926 | ||
|
|
052424a581 | ||
|
|
b258717211 | ||
|
|
685cc29282 | ||
|
|
c78c681ec4 | ||
|
|
2d9f33387e | ||
|
|
b68d68a2a2 | ||
|
|
a9e03f3b29 | ||
|
|
474b294a18 | ||
|
|
334a98016f | ||
|
|
8be55359ce | ||
|
|
e45662c715 | ||
|
|
f3217baed1 | ||
|
|
562ee234a8 | ||
|
|
15e61e9789 | ||
|
|
125f6f062f | ||
|
|
f75a19bccd | ||
|
|
c17d87b710 | ||
|
|
a154293348 | ||
|
|
9832c688fe | ||
|
|
6804eb098d | ||
|
|
531443f0df | ||
|
|
bd2d57dfd1 | ||
|
|
73c92fad47 | ||
|
|
537a023ab9 | ||
|
|
28fc324c6a | ||
|
|
9379c191be | ||
|
|
ff06d4d2f3 | ||
|
|
7efb7e6071 | ||
|
|
b2af9c6bf1 | ||
|
|
73b4d66386 | ||
|
|
cb7baef0ba | ||
|
|
dd75a80df7 | ||
|
|
bc562bf76f | ||
|
|
756b423295 | ||
|
|
3707f2469c | ||
|
|
c6c1434430 | ||
|
|
70b78e41c3 | ||
|
|
8c16302f17 | ||
|
|
bfc369794e | ||
|
|
3c2ea0e645 | ||
|
|
b0d28f2f6d | ||
|
|
6555bca5f1 | ||
|
|
81d9561656 | ||
|
|
0a6bde8bc6 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d |
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
|
||||
2
.env.ci
2
.env.ci
@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
GOTENBERG_URL=http://localhost:3000
|
||||
|
||||
# Octane
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
||||
@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
|
||||
APP_ENABLE_REGISTRATION=on
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
@@ -77,6 +78,9 @@ TELESCOPE_ENABLED=false
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
# Octane
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
||||
# Local setup
|
||||
NGINX_HOST_NAME=solidtime.test
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
|
||||
27
.github/VOUCHED.td
vendored
Normal file
27
.github/VOUCHED.td
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Vouched contributors for solidtime.
|
||||
#
|
||||
# One handle per line, without the leading @, sorted alphabetically.
|
||||
# Prefix a handle with - to denounce them, optionally followed by a reason.
|
||||
# Format reference: https://github.com/mitchellh/vouch
|
||||
#
|
||||
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
|
||||
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
|
||||
# discussion and the vouch workflows will update this file.
|
||||
#
|
||||
# Collaborators with write access and bots are always allowed and do not need
|
||||
# an entry here.
|
||||
#
|
||||
# Seeded 2026-07-25 from the authors of every merged pull request.
|
||||
|
||||
agross
|
||||
akolenda
|
||||
bufferhead-code
|
||||
candideu
|
||||
kasparrosin
|
||||
korridor
|
||||
nikbucher tasks table sorting
|
||||
onatcer
|
||||
shrootbuck
|
||||
smilebeda
|
||||
thespyder
|
||||
utlark
|
||||
43
.github/workflows/build-onpremise.yml
vendored
43
.github/workflows/build-onpremise.yml
vendored
@@ -8,6 +8,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-onpremise.yml'
|
||||
- '.dockerignore'
|
||||
- 'extensions/manifest.json'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -35,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
@@ -46,9 +48,9 @@ jobs:
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||
with:
|
||||
prefix: "v"
|
||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
||||
|
||||
- name: "Get version"
|
||||
id: release-version
|
||||
@@ -91,14 +93,23 @@ jobs:
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
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"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: solidtime-io/extension-invoicing
|
||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
@@ -124,27 +135,27 @@ jobs:
|
||||
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_REPO }}
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: "Build and push by digest"
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
@@ -163,7 +174,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: "Upload digest"
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -177,25 +188,25 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: "Docker meta"
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_REPO }}
|
||||
|
||||
49
.github/workflows/build-private.yml
vendored
49
.github/workflows/build-private.yml
vendored
@@ -8,6 +8,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-private.yml'
|
||||
- '.dockerignore'
|
||||
- 'extensions/manifest.json'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
@@ -22,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
@@ -33,9 +35,9 @@ jobs:
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||
with:
|
||||
prefix: "v"
|
||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
||||
|
||||
- name: "Get version"
|
||||
id: version
|
||||
@@ -68,21 +70,34 @@ jobs:
|
||||
run: cat .env
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
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"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: solidtime-io/extension-billing
|
||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
||||
path: extensions/Billing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||
|
||||
- name: "Install dependencies in billing extension"
|
||||
uses: php-actions/composer@v6
|
||||
env:
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
with:
|
||||
working_dir: "extensions/Billing"
|
||||
command: install
|
||||
@@ -93,9 +108,10 @@ jobs:
|
||||
run: cd extensions/Billing && npm ci
|
||||
|
||||
- name: "Checkout services extension"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: solidtime-io/extension-services
|
||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
||||
path: extensions/Services
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||
|
||||
@@ -111,9 +127,10 @@ jobs:
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: solidtime-io/extension-invoicing
|
||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
@@ -160,7 +177,7 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: rg.fr-par.scw.cloud/solidtime
|
||||
username: nologin
|
||||
@@ -168,7 +185,7 @@ jobs:
|
||||
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
||||
tags: |
|
||||
@@ -179,13 +196,13 @@ jobs:
|
||||
type=sha,format=long
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: "Build and push"
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
|
||||
33
.github/workflows/build-public.yml
vendored
33
.github/workflows/build-public.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
- '.dockerignore'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
@@ -47,9 +48,9 @@ jobs:
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||
with:
|
||||
prefix: "v"
|
||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
||||
|
||||
- name: "Get version"
|
||||
id: release-version
|
||||
@@ -92,7 +93,7 @@ jobs:
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -109,34 +110,34 @@ jobs:
|
||||
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_REPO }}
|
||||
${{ env.GHCR_REPO }}
|
||||
|
||||
- name: "Login to Docker Hub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: "Build and push by digest"
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
@@ -155,7 +156,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: "Upload digest"
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -169,31 +170,31 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to Docker Hub"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GHCR"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: "Docker meta"
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ 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:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
run: php artisan scramble:export --path=build/api-docs.json
|
||||
|
||||
- name: "Upload API docs to GitHub"
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: 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:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP (for Ziggy)"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
27
.github/workflows/npm-test-unit.yml
vendored
Normal file
27
.github/workflows/npm-test-unit.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: NPM Test Unit
|
||||
|
||||
on: [push]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
TZ: UTC
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@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:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP (for Ziggy)"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
name: PHPUnit Tests - Extensions
|
||||
on: push
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
phpunit-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
image: postgres:${{ matrix.postgres_version }}
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
POSTGRES_DB: 'laravel'
|
||||
POSTGRES_USER: 'root'
|
||||
POSTGRES_PASSWORD: 'root'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
ports:
|
||||
- 3000:3000
|
||||
options: >-
|
||||
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: pcov
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
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"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
||||
path: extensions/Billing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in billing extension"
|
||||
working-directory: extensions/Billing
|
||||
env:
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in billing extension"
|
||||
run: cd extensions/Billing && npm ci
|
||||
|
||||
- name: "Checkout services extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
||||
path: extensions/Services
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in services extension"
|
||||
working-directory: extensions/Services
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in services extension"
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in invoicing extension"
|
||||
working-directory: extensions/Invoicing
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Activate billing extension"
|
||||
run: php artisan module:enable Billing
|
||||
|
||||
- name: "Activate services extension"
|
||||
run: php artisan module:enable Services
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: "Prepare Laravel Application"
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan passport:keys
|
||||
|
||||
- name: "Run PHPUnit"
|
||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
uses: codecov/codecov-action@v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Check code style"
|
||||
uses: aglipanci/laravel-pint-action@2.5
|
||||
uses: aglipanci/laravel-pint-action@2.6
|
||||
with:
|
||||
configPath: "pint.json"
|
||||
|
||||
18
.github/workflows/playwright.yml
vendored
18
.github/workflows/playwright.yml
vendored
@@ -9,8 +9,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
|
||||
services:
|
||||
mailpit:
|
||||
@@ -35,10 +35,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||
|
||||
- name: "Upload blob report"
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
@@ -99,10 +99,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: "Download blob reports"
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
|
||||
- name: "Upload merged HTML report"
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: 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 }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,9 +26,10 @@ yarn-error.log
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/coverage
|
||||
/extensions
|
||||
/extensions/*
|
||||
!/extensions/.gitkeep
|
||||
!/extensions/extensions_autoload.php
|
||||
!/extensions/manifest.json
|
||||
/auth.json
|
||||
/modules_statuses.json
|
||||
/k8s
|
||||
@@ -42,3 +43,4 @@ yarn-error.log
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
/AGENTS.md
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# solidtime - The modern Open-Source Time Tracker
|
||||
# solidtime - The modern Open-Source TimeTracker
|
||||
|
||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||
@@ -37,6 +37,10 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
||||
|
||||
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
**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.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@@ -3,3 +3,18 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
Reports we typically won't issue an advisory for:
|
||||
|
||||
* Theoretical findings without a working PoC
|
||||
* Raw scanner output without manual validation
|
||||
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
|
||||
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
|
||||
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
|
||||
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
|
||||
* Org owners or admins acting destructively within their own organization
|
||||
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
|
||||
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\RegistrationMode;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\User;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\TimezoneService;
|
||||
use App\Service\UserService;
|
||||
@@ -16,7 +18,6 @@ use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Log;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
@@ -32,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
if (! config('app.enable_registration')) {
|
||||
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
|
||||
if ($registrationMode === RegistrationMode::Off) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('Registration is disabled.')],
|
||||
]);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
$validated = Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -55,12 +57,26 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
'terms' => ['accepted', 'required'],
|
||||
'newsletter_consent' => [
|
||||
'boolean',
|
||||
],
|
||||
])->validate();
|
||||
|
||||
if ($registrationMode === RegistrationMode::InviteOnly) {
|
||||
$invitationService = app(InvitationService::class);
|
||||
$email = (string) $validated['email'];
|
||||
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
|
||||
$message = $invitationService->hasPendingInvitationForEmail($email)
|
||||
? __('Please accept the organization invitation sent to your email address before registering.')
|
||||
: __('Registration is only available to invited users.');
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [$message],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$timezone = null;
|
||||
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
|
||||
@@ -4,13 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
@@ -24,56 +20,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
'nullable',
|
||||
'mimes:jpg,jpeg,png',
|
||||
'max:1024',
|
||||
],
|
||||
'timezone' => [
|
||||
'required',
|
||||
'timezone:all',
|
||||
],
|
||||
'week_start' => [
|
||||
'required',
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if (isset($input['photo'])) {
|
||||
$user->updateProfilePhoto($input['photo']);
|
||||
}
|
||||
|
||||
if ($input['email'] !== $user->email) {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
'timezone' => $input['timezone'],
|
||||
'week_start' => $input['week_start'],
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'timezone' => $input['timezone'],
|
||||
'week_start' => $input['week_start'],
|
||||
])->save();
|
||||
}
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
{
|
||||
/**
|
||||
* Add a new team member to the given team.
|
||||
*/
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
$newOrganizationMember = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the add member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules())->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for adding a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user is not already on the team.
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($team, $email): void {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\CreatesTeams;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateOrganization implements CreatesTeams
|
||||
{
|
||||
/**
|
||||
* Validate and create a new team for the given user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(User $user, array $input): Organization
|
||||
{
|
||||
Gate::forUser($user)->authorize('create', Jetstream::newTeamModel());
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$input['name'],
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
||||
|
||||
class DeleteOrganization implements DeletesTeams
|
||||
{
|
||||
/**
|
||||
* Delete the given team.
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
/** @see ValidateOrganizationDeletion */
|
||||
app(DeletionService::class)->deleteOrganization($organization);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||
|
||||
class DeleteUser implements DeletesUsers
|
||||
{
|
||||
/**
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
try {
|
||||
app(DeletionService::class)->deleteUser($user);
|
||||
} catch (ApiException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => $exception->getTranslatedMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class InviteOrganizationMember implements InvitesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Invite a new team member to the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
||||
|
||||
class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Remove the team member from the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
|
||||
class UpdateMemberRole
|
||||
{
|
||||
/**
|
||||
* Update the role for the given team member.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Rules\CurrencyRule;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\UpdatesTeamNames;
|
||||
|
||||
class UpdateOrganization implements UpdatesTeamNames
|
||||
{
|
||||
/**
|
||||
* Validate and update the given team's name.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, Organization $organization, array $input): void
|
||||
{
|
||||
Gate::forUser($user)->authorize('update', $organization);
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule,
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
$organization->forceFill([
|
||||
'name' => $input['name'],
|
||||
'currency' => $input['currency'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
class ValidateOrganizationDeletion
|
||||
{
|
||||
/**
|
||||
* Validate that the team can be deleted by the given user.
|
||||
*
|
||||
* @param User $user Authenticated user
|
||||
* @param Organization $organization Organization to be deleted
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Auth/ActiveUserProvider.php
Normal file
37
app/Auth/ActiveUserProvider.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use Illuminate\Auth\EloquentUserProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* User provider that only resolves non-placeholder users.
|
||||
*
|
||||
* Placeholder users are created by imports and when members are removed from an
|
||||
* organization. They can share an email address with a real user, so resolving a user by
|
||||
* email can return a placeholder instead of the real account. The login flow filters them
|
||||
* out explicitly, but the password broker and the guard credential checks (for example the
|
||||
* password confirmation) resolve users through the configured user provider.
|
||||
*
|
||||
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
|
||||
* built-in one for every provider in config/auth.php.
|
||||
*/
|
||||
class ActiveUserProvider extends EloquentUserProvider
|
||||
{
|
||||
/**
|
||||
* @param Model|null $model
|
||||
* @return Builder<Model>
|
||||
*/
|
||||
#[\Override]
|
||||
protected function newModelQuery($model = null): Builder
|
||||
{
|
||||
$query = parent::newModelQuery($model);
|
||||
$query->getQuery()->where('is_placeholder', '=', false);
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ class UserCreateCommand extends Command
|
||||
);
|
||||
});
|
||||
/** @var Organization|null $organization */
|
||||
$organization = $user->ownedTeams->first();
|
||||
$organization = $user->ownedOrganizations->first();
|
||||
if ($organization === null) {
|
||||
throw new LogicException('User does not have an organization');
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
$query->where('is_placeholder', '=', false)
|
||||
->where('send_time_entry_still_running_email', '=', true);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||
|
||||
29
app/Enums/RegistrationMode.php
Normal file
29
app/Enums/RegistrationMode.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RegistrationMode: string
|
||||
{
|
||||
case On = 'on';
|
||||
case InviteOnly = 'invite-only';
|
||||
case Off = 'off';
|
||||
|
||||
public static function fromConfig(mixed $value): self
|
||||
{
|
||||
if ($value === true) {
|
||||
return self::On;
|
||||
}
|
||||
|
||||
if ($value === false || $value === null) {
|
||||
return self::Off;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $value))) {
|
||||
'1', 'on', 'true' => self::On,
|
||||
'invite-only' => self::InviteOnly,
|
||||
default => self::Off,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum Role: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Owner = 'owner';
|
||||
case Admin = 'admin';
|
||||
case Manager = 'manager';
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -21,6 +21,7 @@ enum TimeEntryAggregationType: string
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
case Tag = 'tag';
|
||||
case Type = 'type';
|
||||
|
||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||
{
|
||||
|
||||
15
app/Enums/TimeEntryType.php
Normal file
15
app/Enums/TimeEntryType.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeEntryType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Work = 'work';
|
||||
case Break = 'break';
|
||||
}
|
||||
31
app/Events/MemberAdded.php
Normal file
31
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Replaces legacy TeamMemberAdded event.
|
||||
*/
|
||||
class MemberAdded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public User $user;
|
||||
|
||||
public function __construct(Member $member, Organization $organization, User $user)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
$this->user = $user;
|
||||
}
|
||||
}
|
||||
31
app/Events/MemberAdding.php
Normal file
31
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Replaces legacy AddingTeamMember event.
|
||||
*/
|
||||
class MemberAdding
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public User $user;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Role $role;
|
||||
|
||||
public function __construct(User $user, Organization $organization, Role $role)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->organization = $organization;
|
||||
$this->role = $role;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
|
||||
*/
|
||||
public function report(): bool
|
||||
{
|
||||
// TODO: temporary activated
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
||||
{
|
||||
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class CanNotRemoveOwnerFromOrganization extends ApiException
|
||||
{
|
||||
public const string KEY = 'can_not_remove_owner_from_organization';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||
{
|
||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
||||
{
|
||||
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
|
||||
|
||||
public const string KEY = 'entity_still_in_use';
|
||||
|
||||
/**
|
||||
* Report the exception.
|
||||
*
|
||||
* @return bool true means the exception handler will not report it again
|
||||
*/
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translated message for the exception.
|
||||
*/
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class InactiveUserCanNotBeUsedApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'inactive_user_can_not_be_used';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class OnlyOwnerCanChangeOwnership extends ApiException
|
||||
{
|
||||
public const string KEY = 'only_owner_can_change_ownership';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||
{
|
||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_needs_at_least_one_owner';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class OverlappingTimeEntryApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'overlapping_time_entry';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||
{
|
||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'time_entry_can_not_be_restarted';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
|
||||
class TimeEntryStillRunningApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'time_entry_still_running';
|
||||
|
||||
/**
|
||||
* Report the exception.
|
||||
*
|
||||
* @return bool true means the exception handler will not report it again
|
||||
*/
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_is_already_member_of_organization';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_is_already_member_of_project';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
||||
class UserNotPlaceholderApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_not_placeholder';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
@@ -30,6 +34,33 @@ class Handler extends ExceptionHandler
|
||||
$this->reportable(function (Throwable $e): void {
|
||||
//
|
||||
});
|
||||
|
||||
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
|
||||
&& $e->getErrorType() === 'access_denied'
|
||||
&& $e->getHttpStatusCode() === 401);
|
||||
|
||||
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
|
||||
// otherwise renders as a bare "Bad request." 400. Show a message that
|
||||
// says how to fix it instead. The framework has already converted the
|
||||
// SuspiciousOperationException into a BadRequestHttpException by the time
|
||||
// renderables run, so we match that and inspect the original.
|
||||
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
|
||||
$previous = $e->getPrevious();
|
||||
|
||||
if (! $previous instanceof SuspiciousOperationException
|
||||
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
|
||||
return null; // any other bad request keeps the default response
|
||||
}
|
||||
|
||||
$message = 'This hostname is not configured for this instance. '
|
||||
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $message], 400);
|
||||
}
|
||||
|
||||
return response()->view('errors.untrusted-host', ['message' => $message], 400);
|
||||
});
|
||||
}
|
||||
|
||||
public function render($request, Throwable $e): Response|RedirectResponse
|
||||
|
||||
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
|
||||
TextInput::make('queue')->disabled(),
|
||||
|
||||
// make text a little bit smaller because often a complete Stack Trace is shown:
|
||||
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||
Textarea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
||||
])->columns(4);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class OrganizationInvitationResource extends Resource
|
||||
->required(),
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
Select::make('organization_id')
|
||||
->label('Organization')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
|
||||
@@ -55,7 +55,7 @@ class OrganizationResource extends Resource
|
||||
->label('Is personal?')
|
||||
->hiddenOn(['create'])
|
||||
->required(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
Select::make('user_id')
|
||||
->label('Owner')
|
||||
->relationship(name: 'owner', titleAttribute: 'email')
|
||||
->searchable(['name', 'email'])
|
||||
@@ -76,7 +76,7 @@ class OrganizationResource extends Resource
|
||||
Select::make('time_format')
|
||||
->options(TimeFormat::toSelectArray())
|
||||
->required(),
|
||||
Forms\Components\Select::make('currency')
|
||||
Select::make('currency')
|
||||
->label('Currency')
|
||||
->options(function (): array {
|
||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||
@@ -114,22 +114,22 @@ class OrganizationResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('personal_team')
|
||||
->boolean()
|
||||
->label('Is personal?')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('owner.email')
|
||||
TextColumn::make('owner.email')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency'),
|
||||
TextColumn::make('currency'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@@ -223,7 +223,7 @@ class OrganizationResource extends Resource
|
||||
|
||||
return $select;
|
||||
}),
|
||||
Forms\Components\Select::make('timezone')
|
||||
Select::make('timezone')
|
||||
->label('Timezone')
|
||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||
->searchable()
|
||||
|
||||
@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class InvitationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'teamInvitations';
|
||||
protected static string $relationship = 'organizationInvitations';
|
||||
|
||||
protected static ?string $title = 'Invitations';
|
||||
|
||||
@@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager
|
||||
$ownerRecord = $this->getOwnerRecord();
|
||||
|
||||
return app(InvitationService::class)
|
||||
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
|
||||
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
|
||||
@@ -49,13 +49,13 @@ class UsersRelationManager extends RelationManager
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
TextColumn::make('name'),
|
||||
TextColumn::make('role'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money($organization->currency, divideBy: 100),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()
|
||||
AttachAction::make()
|
||||
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||
->form(fn (AttachAction $action): array => [
|
||||
$action->getRecordSelect(),
|
||||
|
||||
@@ -63,11 +63,11 @@ class ReportResource extends Resource
|
||||
return $record->getRawOriginal('properties');
|
||||
})
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
@@ -78,10 +78,10 @@ class ReportResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
TextColumn::make('description')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
ToggleColumn::make('is_public')
|
||||
@@ -90,10 +90,10 @@ class ReportResource extends Resource
|
||||
TextColumn::make('organization.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
@@ -93,11 +93,11 @@ class TimeEntryResource extends Resource
|
||||
($record->end?->toDateTimeString('minute') ?? '...').')';
|
||||
})
|
||||
->label('Time'),
|
||||
Tables\Columns\TextColumn::make('organization.name')
|
||||
TextColumn::make('organization.name')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
TextColumn::make('created_at')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
TextColumn::make('updated_at')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\TimezoneService;
|
||||
use App\Service\UserService;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Exception;
|
||||
use Filament\Forms;
|
||||
@@ -47,17 +48,17 @@ class UserResource extends Resource
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('id')
|
||||
TextInput::make('id')
|
||||
->label('ID')
|
||||
->disabled()
|
||||
->visibleOn(['update', 'show'])
|
||||
->readOnly()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('name')
|
||||
TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('email')
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->required()
|
||||
->rules($record?->is_placeholder ? [] : [
|
||||
@@ -65,7 +66,7 @@ class UserResource extends Resource
|
||||
->ignore($record?->getKey()),
|
||||
])
|
||||
->rule([
|
||||
'email',
|
||||
'email:rfc,strict',
|
||||
])
|
||||
->maxLength(255),
|
||||
Forms\Components\Toggle::make('is_placeholder')
|
||||
@@ -179,7 +180,7 @@ class UserResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Impersonate::make()->before(function (User $record): void {
|
||||
if ($record->currentTeam === null) {
|
||||
if ($record->currentOrganization === null) {
|
||||
$organization = $record->organizations()->where('personal_team', '=', true)->first();
|
||||
if ($organization === null) {
|
||||
$organization = $record->organizations()->first();
|
||||
@@ -187,8 +188,7 @@ class UserResource extends Resource
|
||||
if ($organization === null) {
|
||||
throw new Exception('User has no organization');
|
||||
}
|
||||
$record->currentTeam()->associate($organization);
|
||||
$record->save();
|
||||
app(UserService::class)->switchCurrentOrganization($record, $organization);
|
||||
}
|
||||
}),
|
||||
Tables\Actions\EditAction::make(),
|
||||
|
||||
@@ -16,7 +16,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static ?string $title = 'Owned Organizations';
|
||||
|
||||
protected static string $relationship = 'ownedTeams';
|
||||
protected static string $relationship = 'ownedOrganizations';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
||||
/**
|
||||
* List all api token of the currently authenticated user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getApiTokens
|
||||
*
|
||||
@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonContains('grant_types', 'personal_access');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return new ApiTokenCollection($tokens);
|
||||
|
||||
@@ -43,7 +43,8 @@ class ClientController extends Controller
|
||||
|
||||
$clientsQuery = Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc');
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id');
|
||||
|
||||
if (! $canViewAllClients) {
|
||||
$clientsQuery->visibleByEmployee($user);
|
||||
|
||||
@@ -28,7 +28,9 @@ class ExportController extends Controller
|
||||
|
||||
$filepath = $exportService->export($organization);
|
||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
|
||||
@@ -40,7 +40,9 @@ class InvitationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:view');
|
||||
|
||||
$invitations = $organization->teamInvitations()
|
||||
$invitations = $organization->organizationInvitations()
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return InvitationCollection::make($invitations);
|
||||
@@ -62,7 +64,7 @@ class InvitationController extends Controller
|
||||
$email = $request->getEmail();
|
||||
$role = $request->getRole();
|
||||
|
||||
$invitationService->inviteUser($organization, $email, $role);
|
||||
$invitationService->inviteUser($organization, $email, $role, $this->user());
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ class MemberController extends Controller
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return MemberCollection::make($members);
|
||||
@@ -191,7 +193,7 @@ class MemberController extends Controller
|
||||
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
}
|
||||
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user());
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,19 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
@@ -43,6 +51,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getName() !== null) {
|
||||
$organization->name = $request->getName();
|
||||
}
|
||||
if ($request->getCurrency() !== null) {
|
||||
$organization->currency = $request->getCurrency();
|
||||
}
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||
}
|
||||
@@ -67,6 +78,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
||||
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
||||
}
|
||||
if ($request->getBreaksEnabled() !== null) {
|
||||
$organization->breaks_enabled = $request->getBreaksEnabled();
|
||||
}
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
@@ -80,4 +94,46 @@ class OrganizationController extends Controller
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization
|
||||
*
|
||||
* @operationId createOrganization
|
||||
*/
|
||||
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||
{
|
||||
$user = $this->user();
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||
|
||||
$currency = $ipLookupResponse?->currency;
|
||||
|
||||
$organization = $organizationService->createOrganization(
|
||||
$request->getName(),
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
app(UserService::class)->switchCurrentOrganization($user, $organization);
|
||||
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
*
|
||||
* @operationId deleteOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:delete');
|
||||
|
||||
$deletionService->deleteOrganization($organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,10 @@ class ProjectController extends Controller
|
||||
$projectsQuery->whereNull('archived_at');
|
||||
}
|
||||
|
||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
$projects = $projectsQuery
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
@@ -76,7 +79,7 @@ class ProjectController extends Controller
|
||||
*/
|
||||
public function show(Organization $organization, Project $project): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view', $project);
|
||||
$this->checkPermission($organization, 'projects:view:all', $project);
|
||||
|
||||
// Note: There is currently no need to check if a user is a member of the project,
|
||||
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||
@@ -41,12 +42,14 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
*/
|
||||
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
||||
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:view', $project);
|
||||
|
||||
$projectMembers = ProjectMember::query()
|
||||
->whereBelongsTo($project, 'project')
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ProjectMemberCollection($projectMembers);
|
||||
|
||||
@@ -57,9 +57,10 @@ class ReportController extends Controller
|
||||
$filter->addEnd($properties->end);
|
||||
$filter->addActive($properties->active);
|
||||
$filter->addBillable($properties->billable);
|
||||
$filter->addType($properties->timeEntryType);
|
||||
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
||||
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
||||
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
|
||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||
$timeEntriesQuery = $filter->get();
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\Report\ReportIndexRequest;
|
||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||
@@ -40,12 +41,13 @@ class ReportController extends Controller
|
||||
*
|
||||
* @operationId getReports
|
||||
*/
|
||||
public function index(Organization $organization): ReportCollection
|
||||
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:view');
|
||||
|
||||
$reports = Report::query()
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
@@ -95,6 +97,7 @@ class ReportController extends Controller
|
||||
$properties->setClientIds($request->input('properties.client_ids', null));
|
||||
$properties->setProjectIds($request->input('properties.project_ids', null));
|
||||
$properties->setTagIds($request->input('properties.tag_ids', null));
|
||||
$properties->setTagMatchType($request->getPropertyTagMatchType());
|
||||
$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;
|
||||
$timezone = $user->timezone;
|
||||
@@ -109,6 +112,7 @@ class ReportController extends Controller
|
||||
$properties->timezone = $timezone;
|
||||
$properties->roundingType = $request->getPropertyRoundingType();
|
||||
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||
$properties->timeEntryType = $request->getPropertyTimeEntryType();
|
||||
$report->properties = $properties;
|
||||
if ($isPublic) {
|
||||
$report->share_secret = $reportService->generateSecret();
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Tag\TagIndexRequest;
|
||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||
use App\Http\Resources\V1\Tag\TagCollection;
|
||||
@@ -34,13 +35,14 @@ class TagController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Organization $organization): TagCollection
|
||||
public function index(Organization $organization, TagIndexRequest $request): TagCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'tags:view');
|
||||
|
||||
$tags = Tag::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new TagCollection($tags);
|
||||
|
||||
@@ -82,7 +82,10 @@ class TaskController extends Controller
|
||||
$query->whereNull('done_at');
|
||||
}
|
||||
|
||||
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
||||
$tasks = $query
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new TaskCollection($tasks);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
@@ -53,12 +54,13 @@ use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
|
||||
class TimeEntryController extends Controller
|
||||
{
|
||||
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
||||
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
|
||||
{
|
||||
if (! $organization->prevent_overlapping_time_entries) {
|
||||
return;
|
||||
@@ -66,7 +68,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->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 {
|
||||
$q->where('id', '!=', $exclude->getKey());
|
||||
})
|
||||
@@ -106,8 +108,8 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Get time entries in organization
|
||||
*
|
||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||
* 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 member ID in the member_id filter.
|
||||
*
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
*
|
||||
@@ -117,16 +119,17 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$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');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||
|
||||
$totalCount = $timeEntriesQuery->count();
|
||||
|
||||
@@ -157,7 +160,7 @@ class TimeEntryController extends Controller
|
||||
if ($timeEntries->count() === 0) {
|
||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||
'date' => $lastDate->toDateString(),
|
||||
'user_id' => $request->input('user_id'),
|
||||
'member_id' => $request->input('member_id'),
|
||||
'auth_user_id' => Auth::id(),
|
||||
'limit' => $limit,
|
||||
]);
|
||||
@@ -193,7 +196,8 @@ class TimeEntryController extends Controller
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->select($select)
|
||||
->orderBy('start', 'desc');
|
||||
->orderBy('time_entries.start', 'desc')
|
||||
->orderBy('time_entries.id');
|
||||
|
||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||
$filter->addStartFilter($request->input('start'));
|
||||
@@ -202,10 +206,11 @@ class TimeEntryController extends Controller
|
||||
$filter->addMemberIdFilter($member);
|
||||
$filter->addMemberIdsFilter($request->input('member_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->addClientIdsFilter($request->input('client_ids'));
|
||||
$filter->addBillableFilter($request->input('billable'));
|
||||
$filter->addTypeFilter($request->input('type'));
|
||||
|
||||
return $filter->get();
|
||||
}
|
||||
@@ -219,9 +224,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$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');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -238,7 +244,7 @@ class TimeEntryController extends Controller
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery->with([
|
||||
'task',
|
||||
'client',
|
||||
@@ -246,7 +252,7 @@ class TimeEntryController extends Controller
|
||||
'user',
|
||||
'tagsRelation',
|
||||
]);
|
||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
||||
$folderPath = 'exports';
|
||||
$path = $folderPath.'/'.$filename;
|
||||
$localizationService = LocalizationService::forOrganization($organization);
|
||||
@@ -261,7 +267,7 @@ class TimeEntryController extends Controller
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
null,
|
||||
@@ -330,7 +336,9 @@ class TimeEntryController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'download_url' => Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($path, now()->addMinutes(5)),
|
||||
->temporaryUrl($path, now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -368,9 +376,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$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');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -381,7 +390,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -417,9 +426,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$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');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -435,7 +445,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -469,7 +479,7 @@ class TimeEntryController extends Controller
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
$localizationService = LocalizationService::forOrganization($organization);
|
||||
|
||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
||||
$folderPath = 'exports';
|
||||
$path = $folderPath.'/'.$filename;
|
||||
|
||||
@@ -527,7 +537,7 @@ class TimeEntryController extends Controller
|
||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||
} else {
|
||||
Excel::store(
|
||||
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
|
||||
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
|
||||
$path,
|
||||
config('filesystems.private'),
|
||||
$format->getExportPackageType(),
|
||||
@@ -539,7 +549,9 @@ class TimeEntryController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'download_url' => Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($path, now()->addMinutes(5)),
|
||||
->temporaryUrl($path, now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -558,10 +570,11 @@ class TimeEntryController extends Controller
|
||||
$filter->addMemberIdFilter($member);
|
||||
$filter->addMemberIdsFilter($request->input('member_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->addClientIdsFilter($request->input('client_ids'));
|
||||
$filter->addBillableFilter($request->input('billable'));
|
||||
$filter->addTypeFilter($request->input('type'));
|
||||
|
||||
return $filter->get();
|
||||
}
|
||||
@@ -578,7 +591,7 @@ class TimeEntryController extends Controller
|
||||
{
|
||||
/** @var Member $member */
|
||||
$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');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:create:all');
|
||||
@@ -625,12 +638,13 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||
$this->checkPermission($organization, 'time-entries:update:own');
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $newMember */
|
||||
$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);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:update:all');
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
}
|
||||
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||
@@ -659,6 +673,10 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user()->associate($newMember->user);
|
||||
}
|
||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
@@ -688,6 +706,7 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||
{
|
||||
$member = $this->member($organization);
|
||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||
|
||||
@@ -712,6 +731,9 @@ class TimeEntryController extends Controller
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
/** @var Member|null $newMember */
|
||||
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
||||
|
||||
$project = null;
|
||||
$client = null;
|
||||
$overwriteClient = false;
|
||||
@@ -738,16 +760,33 @@ class TimeEntryController extends Controller
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
||||
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
// Changing time entries to Break entries is only allowed when breaks are enabled in the org settings
|
||||
$resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type;
|
||||
if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
}
|
||||
// Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted)
|
||||
if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
}
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$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 ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||
$timeEntry->task()->disassociate();
|
||||
@@ -788,7 +827,8 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
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);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||
@@ -845,7 +885,7 @@ class TimeEntryController extends Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
||||
$error->push($id);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,30 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||
use App\Http\Requests\V1\User\UserDestroyRequest;
|
||||
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
|
||||
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use App\Mail\VerifyUpdatedEmailMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\UserService;
|
||||
use App\Support\Base64File;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMe
|
||||
*
|
||||
@@ -24,4 +39,173 @@ class UserController extends Controller
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current organization of the current user
|
||||
*
|
||||
* Switches the organization that the user is currently working in. The user
|
||||
* must be a member of the given organization. This endpoint is independent of
|
||||
* the organization.
|
||||
*
|
||||
* @operationId updateMyCurrentOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function updateMyCurrentOrganization(UserUpdateCurrentOrganizationRequest $request, UserService $userService): UserResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
/** @var Organization|null $organization */
|
||||
$organization = $user->organizations()
|
||||
->whereKey($request->getOrganizationId())
|
||||
->first();
|
||||
|
||||
if ($organization === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$userService->switchCurrentOrganization($user, $organization);
|
||||
|
||||
return new UserResource($user->refresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId updateUser
|
||||
*/
|
||||
public function update(User $user, UserUpdateRequest $request): UserResource
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($request->hasPhotoKey()) {
|
||||
$photoDisk = (string) config('filesystems.public');
|
||||
$previousPhotoPath = $user->profile_photo_path;
|
||||
$newPhoto = $request->getPhoto();
|
||||
|
||||
if ($newPhoto === null) {
|
||||
$user->profile_photo_path = null;
|
||||
} else {
|
||||
$decoded = Base64File::decode($newPhoto);
|
||||
assert($decoded !== null);
|
||||
$extension = Base64File::extension($decoded['mime_type']);
|
||||
assert($extension !== null);
|
||||
|
||||
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
|
||||
Storage::disk($photoDisk)->put($photoPath, $decoded['data'], 'public');
|
||||
$user->profile_photo_path = $photoPath;
|
||||
}
|
||||
|
||||
if ($previousPhotoPath !== null) {
|
||||
Storage::disk($photoDisk)->delete($previousPhotoPath);
|
||||
}
|
||||
}
|
||||
|
||||
$emailToVerify = null;
|
||||
$email = $request->getEmail();
|
||||
if ($email !== null && $email !== Str::lower($user->email)) {
|
||||
$emailToVerify = $email;
|
||||
$user->pending_email = $email;
|
||||
}
|
||||
|
||||
if ($request->getName() !== null) {
|
||||
$user->name = $request->getName();
|
||||
}
|
||||
|
||||
if ($request->getTimezone() !== null) {
|
||||
$user->timezone = $request->getTimezone();
|
||||
}
|
||||
|
||||
if ($request->getWeekStart() !== null) {
|
||||
$user->week_start = $request->getWeekStart();
|
||||
}
|
||||
|
||||
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
|
||||
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
if ($emailToVerify !== null) {
|
||||
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
|
||||
}
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the pending email for a user.
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId resetUserPendingEmail
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
||||
*/
|
||||
public function resetPendingEmail(User $user): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$user->pending_email = null;
|
||||
$user->save();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend the pending email update verification email.
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId resendUserEmailVerification
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
||||
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
|
||||
*/
|
||||
public function resendEmailVerification(User $user): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($user->pending_email === null) {
|
||||
throw new UserResendEmailVerificationNoPendingEmailApiException;
|
||||
}
|
||||
|
||||
Mail::to($user->pending_email)
|
||||
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of a user.
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId deleteUser
|
||||
*
|
||||
* @param User $user The user instance to be deleted.
|
||||
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
||||
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||
*/
|
||||
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$deletionService->deleteUser($user);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
|
||||
/**
|
||||
* Get the memberships of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMyMemberships
|
||||
*
|
||||
|
||||
@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
|
||||
/**
|
||||
* Get the active time entry of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMyActiveTimeEntry
|
||||
*/
|
||||
|
||||
@@ -59,7 +59,7 @@ class Controller extends BaseController
|
||||
protected function currentOrganization(): Organization
|
||||
{
|
||||
$user = $this->user();
|
||||
$organization = $user->currentTeam;
|
||||
$organization = $user->currentOrganization;
|
||||
if ($organization === null) {
|
||||
$organization = $user->organizations()->first();
|
||||
}
|
||||
|
||||
@@ -4,4 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
abstract class Controller extends \App\Http\Controllers\Controller {}
|
||||
use App\Models\Organization;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
abstract class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
{
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user