Compare commits

...

124 Commits

Author SHA1 Message Date
Constantin Graf
6ff77f6027 Add permission config to npm-format-check GitHub action 2026-09-24 11:16:50 +02:00
Constantin Graf
a40327298c Updated UUID generation 2026-09-24 11:16:25 +02:00
Constantin Graf
fffb438963 Add new extension auditing 2026-09-24 11:16:07 +02:00
Constantin Graf
2dadf1ef02 Updated Laravel to v13 2026-09-24 10:45:18 +02:00
Constantin Graf
12cc3f27f8 Add check for premium to telemetry and update lookup 2026-09-24 10:45:18 +02:00
Constantin Graf
06a2048771 Add basic e2e tests for admin panel 2026-09-24 10:45:18 +02:00
Constantin Graf
a12ee1b029 Address review feedback on composer updates
- Default INERTIA_SSR_ENABLED to false to match .env.example, .env.ci and
  .env.production, so existing setups keep the previous behaviour.
- Stop tracking the published Filament assets under /public/fonts/filament and
  add them to .gitignore, as recommended by the Filament docs. They are
  regenerated by filament:assets via the filament:upgrade post-autoload-dump
  hook. /public/css and /public/js are already ignored.
- Drop the now dead theme.css filter from the Vite asset list in app.blade.php,
  since the custom Filament theme was removed along with viteTheme().
2026-09-24 10:45:18 +02:00
Constantin Graf
54dc0dbabc Add psysh to gitignore 2026-09-24 10:45:18 +02:00
Constantin Graf
08f9edbc96 Add additional tests for filament 2026-09-24 10:45:18 +02:00
Constantin Graf
0551c633a3 Add github action to prevent non-tag refs in manifest.json 2026-09-24 10:45:18 +02:00
Constantin Graf
37579b1e5c Updated compose dependencies 2026-09-24 10:45:18 +02:00
Constantin Graf
b33cf00788 Remove Group from ClockifyTimeEntriesImporter 2026-09-22 18:08:49 +02:00
Gregor Vostrak
c23f09fb2d add date range navigation, unify picker components 2026-09-22 15:53:50 +02:00
Gregor Vostrak
a0262addb7 fix e2e submit focus test behaviour 2026-09-22 13:43:42 +02:00
Gregor Vostrak
b4911ebddd move project reset button into mass update modal and fix
TimeTrackerProjectTaskDropdown trigger focus
2026-09-22 13:43:42 +02:00
Gregor Vostrak
3ade20887b fix cmd+enter submit shortcut on modals #1238 2026-09-22 13:43:42 +02:00
dependabot[bot]
4943a38ffe Bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 12:02:12 +02:00
dependabot[bot]
12e355ff26 Bump codecov/codecov-action from 7.0.0 to 7.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 7.0.0 to 7.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v7.0.0...v7.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 12:01:59 +02:00
Constantin Graf
01b60c0f6a Add validation for task name length 2026-09-22 12:01:48 +02:00
Constantin Graf
927da7dce9 Fixed type check 2026-09-22 12:01:48 +02:00
Constantin Graf
c89fa87b0f Fix clockfiy importer, allow project imports without Billability 2026-09-22 12:01:48 +02:00
Constantin Graf
02cf600f43 Add placeholder paddle api key 2026-09-21 22:07:14 +02:00
Constantin Graf
98a460725f Add GitHub action for phpunit tests with extensions 2026-09-21 22:07:14 +02:00
Constantin Graf
95645ddd91 Fixed composer auth username 2026-09-21 18:28:02 +02:00
Constantin Graf
1de985b577 Update docker image 2026-09-21 16:08:13 +02:00
Gregor Vostrak
d54296e66a fix live timer restarting while duration input is paused 2026-09-16 15:34:16 +02:00
Gregor Vostrak
95ddbf9ead fix placeholder users being resolved by authentication flows 2026-09-16 15:25:08 +02:00
Constantin Graf
70646a0dd4 Add additional validation for import, Enhanced ZIP extraction in importer 2026-09-16 15:02:54 +02:00
Gregor Vostrak
5b12c09747 bump invoicing extension to v0.0.6 for pagination ui package change 2026-09-07 17:13:20 +02:00
Gregor Vostrak
24023353f2 bump ui package version 2026-09-07 16:40:55 +02:00
Gregor Vostrak
720d20c10e move pagination component to ui package 2026-09-07 16:40:55 +02:00
Gregor Vostrak
82ea9af8b5 fix radix dialog focus restore behaviour 2026-09-04 14:55:09 +02:00
Gregor Vostrak
169d522da0 fix activity graph border color 2026-09-03 17:15:19 +02:00
Gregor Vostrak
45c7377802 bump invoicing extension to v0.0.5 2026-08-31 16:38:25 +02:00
Gregor Vostrak
8e57275cef bump invoicing extension version 2026-08-31 16:03:26 +02:00
Gregor Vostrak
efc6b55628 add helper functions for tanstack form and cent conversions 2026-08-31 16:03:26 +02:00
Gregor Vostrak
c12789376d fix type error and adapt formatting to new prettier version changes 2026-08-31 16:03:26 +02:00
Gregor Vostrak
4795812b60 align alter dialog positioning with other modals 2026-08-31 16:03:26 +02:00
Gregor Vostrak
0e00979ab8 make sure all invoices routes show active state in the navigation 2026-08-31 16:03:26 +02:00
Gregor Vostrak
f1426fcb5e add combobox to ui package 2026-08-31 16:03:26 +02:00
Constantin Graf
23f512d4a4 Add permissions and types for invoice recipients 2026-08-31 16:03:26 +02:00
Constantin Graf
637475e669 Add invite-only registration mode 2026-08-31 13:36:25 +02:00
Constantin Graf
3ec2abb309 Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
2026-08-31 13:36:25 +02:00
Gregor Vostrak
3e36b1cc01 replace TimezoneModalMismatch unit test with e2e test 2026-08-31 12:48:22 +02:00
Andrew Herron
7831bc697e Fix timezone mismatch modal posting to the removed Jetstream route
Use useUpdateUserMutation, matching UpdateProfileInformationForm.
2026-08-31 12:48:22 +02:00
Constantin Graf
602a8daa1f Suppress expected OAuth access denial reports 2026-08-31 12:42:00 +02:00
Constantin Graf
38b448a729 Suppress reporting for expected API exceptions 2026-08-31 12:42:00 +02:00
Constantin Graf
e6f071f87f Fixed formatting 2026-08-31 12:26:02 +02:00
Constantin Graf
693a1fa7e0 Test still-running email preference 2026-08-31 12:26:02 +02:00
Constantin Graf
77f14b696e Move email preference to notifications section 2026-08-31 12:26:02 +02:00
Constantin Graf
593372bae5 Add still-running email preference 2026-08-31 12:26:02 +02:00
dependabot[bot]
db9ca51fc4 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 12:14:38 +02:00
Andrew Herron
28aba7b27a Stopped downloads opening a new tab now that it's guaranteed to be an attachment. 2026-08-28 17:30:27 +02:00
Andrew Herron
453d5ed066 Set content-disposition 'attachment' for both local and S3 downloads. Added a bunch of tests. Fixes #1148. 2026-08-28 17:30:27 +02:00
Gregor Vostrak
ed3ba0135b fix undefined table-state helper in projects search e2e test 2026-08-28 13:10:36 +02:00
Gregor Vostrak
fb7d945c50 polish projects search ui, fix empty states 2026-08-27 18:50:34 +02:00
AKolenda
9958e1c09c add search input to projects overview page
Filters the project table by name client-side, reusing the existing
filteredProjects computed and the dropdown search input styling.
Search is deliberately not persisted in the table state so a reload
never leaves the table silently filtered.
2026-08-27 18:50:34 +02:00
github-actions[bot]
f00d48f1c3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1217#issuecomment-5442102245
2026-08-27 16:28:04 +00:00
Constantin Graf
7edbfd5d76 Updated extension billing to 0.0.6 2026-08-27 15:22:31 +02:00
Gregor Vostrak
1edb940557 fix empty-value sorting edge cases and simplify sort components 2026-08-26 18:17:34 +02:00
Gregor Vostrak
c9330e6cb8 fix semantic task table role 2026-08-26 18:17:34 +02:00
Gregor Vostrak
0e9f652d84 refactor table sorting to composables and shared SortableTableHeaderCell 2026-08-26 18:17:34 +02:00
Niklaus Bucher
d29f5706ce Add sorting to tasks table 2026-08-26 18:17:34 +02:00
Constantin Graf
b2849ec04a Fixed code formatting 2026-08-25 13:35:32 +02:00
Constantin Graf
bd69cf478c Bump billing extension to v0.0.5 2026-08-25 13:35:32 +02:00
Constantin Graf
5197135d00 Updated service extension 2026-08-25 13:35:32 +02:00
Constantin Graf
b09b8649b8 Fixed typo in import 2026-08-25 12:32:30 +02:00
Constantin Graf
99fafdb8d6 Add database indexes 2026-08-25 12:32:30 +02:00
Gregor Vostrak
b02e49c7e0 improve modal scrolling behaviour on mobile 2026-08-24 21:46:46 +02:00
Gregor Vostrak
8609635d74 fix modal overflow on smaller viewports with dynamic viewport height 2026-08-22 17:43:33 +02:00
github-actions[bot]
5a07f798b3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/discussions/1188#discussioncomment-DC_kwDOLFnGEc4BFEiq
2026-08-21 13:22:28 +00:00
Gregor Vostrak
3af69830ee move api rate limit to app config, add fallback for empty env key 2026-08-20 17:40:33 +02:00
Darwin Correa
a050153bcd feat: make API rate limits configurable via env vars (#1204)
* feat: make API rate limits configurable via env vars
2026-08-20 17:22:24 +02:00
Gregor Vostrak
9d9731c7ce add formatting support for months grouping 2026-08-20 17:11:35 +02:00
Andrew Herron
dfe3206614 Add "Week" grouping option to reporting 2026-08-20 17:11:35 +02:00
Andrew Herron
97fd882878 Format date group labels in all aggregate exports 2026-08-20 17:11:35 +02:00
Andrew Herron
111e12df12 Add "Date" grouping option to reporting 2026-08-20 17:11:35 +02:00
Constantin Graf
de13c07855 Fixed return type in TrustHosts 2026-08-06 17:49:52 +02:00
Constantin Graf
29a2e994cd Fixed phpstan error in TrustHosts middleware 2026-08-06 17:06:34 +02:00
Constantin Graf
f6d886b218 Refactored TrustHostsTest; Added return types to TrustHosts 2026-08-06 17:01:46 +02:00
Gregor Vostrak
80d98b30a1 add custom error handling for host mismatch; ensure TrustHosts runs
before TrustProxies
2026-08-06 17:01:46 +02:00
Gregor Vostrak
32f2f1431b add TrustHosts middleware with exemption for healthchecks 2026-08-06 17:01:46 +02:00
Constantin Graf
8f6d584ee9 Fixed invoice tax rate 2026-07-30 21:16:06 +02:00
github-actions[bot]
1905cbf40c Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1148#issuecomment-5123895106
2026-07-29 22:12:52 +00:00
github-actions[bot]
c8f668238e Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1179#issuecomment-5123615992
2026-07-29 21:36:39 +00:00
Gregor Vostrak
bf11bacdee add vouch system requirement for PRs > 50 line changes 2026-07-29 23:22:32 +02:00
Gregor Vostrak
f3c6a0b8ae fix invitations not being respected during signup when email case differs 2026-07-28 17:07:53 +02:00
Gregor Vostrak
619c602571 cleanup and deduplicate breaks frontend tests 2026-07-28 16:52:36 +02:00
Gregor Vostrak
600daf44d9 add description/project labels to break placement modal for existing
time entries
2026-07-28 16:52:36 +02:00
Gregor Vostrak
a1d6c92806 insert breaks into work entries instead of carving them out; rollback on
failure system
2026-07-28 16:52:36 +02:00
Gregor Vostrak
ce1bd6a435 unify display values for work and break time totals 2026-07-28 16:52:36 +02:00
Gregor Vostrak
79d85227c2 bump ui to 0.0.22 and api to 0.0.7 2026-07-28 16:52:36 +02:00
Gregor Vostrak
885abf3333 promote breaks feature in sidebar banner 2026-07-28 16:52:36 +02:00
Gregor Vostrak
c0c8fee6be default time entry type filter to work for legacy reports 2026-07-28 16:52:36 +02:00
Gregor Vostrak
cbcd1e51f6 add break time entries and simplified time tracker ui 2026-07-28 16:52:36 +02:00
Constantin Graf
114a32536d Fixed update of member_id in time_entries.update and time_entries.updateMultiple
Removed usage of legacy user_id in TimeEntryController
2026-07-23 12:02:46 +02:00
Constantin Graf
ff8a0f065b Updated extension billing 2026-07-23 11:42:32 +02:00
Constantin Graf
44fd0ffb91 Fix .dockerignore 2026-07-23 11:40:19 +02:00
Constantin Graf
c07c62bfab Add extensions manifest 2026-07-21 16:39:27 +02:00
Constantin Graf
f599757761 Add .dockerignore file 2026-07-21 16:39:27 +02:00
dependabot[bot]
e54fd2460c Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:33 +02:00
dependabot[bot]
e997a2b39f Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:15 +02:00
Constantin Graf
f0de89b0e8 Prevent failed jobs for no longer existing projects and tasks 2026-07-11 16:00:34 +02:00
Constantin Graf
024283273a Updated email validation to stricter config 2026-07-11 16:00:34 +02:00
Candide
f01ab5fd9d Fix awkward wording for time tracker alert email
I just received this email today, and found the "you do that in solidtime" wording to be a bit awkward.
2026-07-11 15:57:33 +02:00
Gregor Vostrak
4fb18f343f allow missing client columns in clockify importer, fixes #1149 2026-07-09 16:16:25 +02:00
Gregor Vostrak
ba374c0371 add client name to projecttaskselect dropdown trigger 2026-07-09 13:53:08 +02:00
Gregor Vostrak
e302421765 make sure dropdown/combobox lists stay visible during close animation to
avoid layout shifts
2026-07-09 13:01:23 +02:00
Gregor Vostrak
c90c1ed8fa add stable secondary sorting based on id to the tests to avoid flakyness 2026-07-09 13:01:23 +02:00
Gregor Vostrak
75f5144b45 use pinned selection instead of fix items on dropdown open so async
loaded collections update the dropdown properly
2026-07-09 13:01:23 +02:00
Gregor Vostrak
53ecc58509 add validationa for project names in toggl importer 2026-07-09 13:01:23 +02:00
Gregor Vostrak
ac68091d3e add secondary name sorting as tie breaker in client and project tables 2026-07-09 13:01:23 +02:00
Gregor Vostrak
a97c02542b add unique id tiebreaker to paginated index endpoints to make pagination
stable (#1138)
2026-07-09 13:01:23 +02:00
Gregor Vostrak
a56942abcf Add virtualizer to ProjectDropdown, ClientDropdown and Reporting
Comboboxes; Remove redundant focus loop on Project/ClientDropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
61a0a7db5a remove the measure row logic and rely on static values only for the
virtualizer and remove duplicated focus trap to avoid infinite loop in
project task dropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
bc5af14a75 keep tasks visible when search term matches project or client name 2026-07-09 13:01:23 +02:00
Gregor Vostrak
95bfb45978 add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
2026-07-09 13:01:23 +02:00
Gregor Vostrak
2207f676ee add pagination to client and project table 2026-07-09 13:01:23 +02:00
Gregor Vostrak
981bcbe091 add tests for tag match type (backend + e2e) 2026-06-26 14:53:37 +02:00
Gregor Vostrak
ccf3cec8c0 migrate tag match type buttons to reka-ui radio group for accessibility 2026-06-26 14:53:37 +02:00
Gregor Vostrak
23ea1500b0 Use an enum for tag match type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
4bbde04e28 Rename tag_filter parameter to tag_match_type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
caa36d2875 Fix not-contains tag filter dropping entries where tags is null 2026-06-26 14:53:37 +02:00
Beda Schmid
58d7b33366 Add filter to include/exclude tags
[Added]
- Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
2026-06-26 14:53:37 +02:00
490 changed files with 22489 additions and 5645 deletions

54
.dockerignore Normal file
View 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

View File

@@ -6,6 +6,7 @@ APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
# Logging # Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack
@@ -64,3 +65,6 @@ GOTENBERG_URL=http://localhost:3000
# Octane # Octane
OCTANE_SERVER=frankenphp OCTANE_SERVER=frankenphp
# Inertia
INERTIA_SSR_ENABLED=false

View File

@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
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 SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500 PAGINATION_PER_PAGE_DEFAULT=500
@@ -87,3 +88,6 @@ FORWARD_DB_PORT=54329
VITE_HOST_NAME=vite.solidtime.test VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage #SAIL_XDEBUG_MODE=develop,debug,coverage
# This app does not ship a Node SSR bundle, so Inertia SSR must stay disabled.
INERTIA_SSR_ENABLED=false

View File

@@ -13,3 +13,5 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
INERTIA_SSR_ENABLED=false

27
.github/VOUCHED.td vendored Normal file
View 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

View File

@@ -8,6 +8,8 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-onpremise.yml' - '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
@@ -35,7 +37,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -91,14 +93,25 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-invoicing repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
@@ -108,9 +121,26 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci
@@ -130,7 +160,7 @@ jobs:
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -184,7 +214,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -8,6 +8,8 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -22,7 +24,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,21 +70,36 @@ jobs:
run: cat .env run: cat .env
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-billing repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install dependencies in billing extension" - name: "Install dependencies in billing extension"
uses: php-actions/composer@v6 uses: php-actions/composer@v6
env: 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: with:
working_dir: "extensions/Billing" working_dir: "extensions/Billing"
command: install command: install
@@ -93,9 +110,10 @@ jobs:
run: cd extensions/Billing && npm ci run: cd extensions/Billing && npm ci
- name: "Checkout services extension" - name: "Checkout services extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-services repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -111,9 +129,10 @@ jobs:
run: cd extensions/Services && npm ci run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-invoicing repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
@@ -128,6 +147,25 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Auditing"
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Setup PHP with PECL extension" - name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@@ -151,6 +189,9 @@ jobs:
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci
@@ -160,7 +201,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -8,6 +8,7 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
@@ -36,7 +37,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +93,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
@@ -116,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -176,13 +177,13 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -0,0 +1,31 @@
name: Extension Manifest Check
on:
pull_request:
paths:
- extensions/manifest.json
- .github/workflows/extension-manifest-check.yml
permissions:
contents: read
jobs:
check-refs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
# Extension refs must be pinned to a release tag (vX.Y.Z) or a full commit
# SHA. Branch names (main, feature/foo, ...) are only acceptable while
# developing and must be replaced before the pull request is merged.
- name: "Check extension refs are pinned"
run: |
failed=0
while IFS=$'\t' read -r name ref; do
if [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$ ]] || [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
echo "✅ $name: $ref"
else
echo "::error file=extensions/manifest.json::Extension \"$name\" uses ref \"$ref\", which is not a release tag or commit SHA. Pin it before merging."
failed=1
fi
done < <(jq -r 'to_entries[] | [.key, .value.ref] | @tsv' extensions/manifest.json)
exit $failed

View File

@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -1,6 +1,8 @@
name: NPM Format Check name: NPM Format Check
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
format-check: format-check:
@@ -9,10 +11,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- name: Install root project dependencies - name: Install root project dependencies
run: npm ci run: npm ci
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -13,10 +13,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -10,7 +10,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

136
.github/workflows/phpunit-extensions.yml vendored Normal file
View 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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install" - name: "Run composer install"
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.0.0 uses: codecov/codecov-action@v7.1.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6 uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,10 +35,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

75
.github/workflows/vouch-check-pr.yml vendored Normal file
View 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 }}

View 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 }}

View 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 }}

5
.gitignore vendored
View File

@@ -6,6 +6,7 @@ dist
/public/storage /public/storage
/public/css /public/css
/public/js /public/js
/public/fonts/filament
/public/vendor /public/vendor
/lang/vendor /lang/vendor
/storage/*.key /storage/*.key
@@ -26,9 +27,10 @@ yarn-error.log
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/coverage /coverage
/extensions /extensions/*
!/extensions/.gitkeep !/extensions/.gitkeep
!/extensions/extensions_autoload.php !/extensions/extensions_autoload.php
!/extensions/manifest.json
/auth.json /auth.json
/modules_statuses.json /modules_statuses.json
/k8s /k8s
@@ -42,4 +44,5 @@ yarn-error.log
/data /data
/config/caddy /config/caddy
/config/composer /config/composer
/config/psysh
/AGENTS.md /AGENTS.md

View File

@@ -12,6 +12,22 @@ In order to keep the issues of the repository clean we decided to only use them
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons. To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Vouched contributors
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
Your pull request is not affected if any of the following applies:
- You have write access to this repository.
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
### Contributor License Agreement ### Contributor License Agreement
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks. You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.

View File

@@ -39,6 +39,8 @@ Please open an issue or start a discussion and wait for approval before submitti
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.** **If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides. We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\RegistrationMode;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\User; use App\Models\User;
use App\Service\InvitationService;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService; use App\Service\UserService;
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User 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([ throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')], 'email' => [__('Registration is disabled.')],
]); ]);
} }
Validator::make($input, [ $validated = Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
], ],
])->validate(); ])->validate();
if ($registrationMode === RegistrationMode::InviteOnly) {
$invitationService = app(InvitationService::class);
$email = (string) $validated['email'];
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
$message = $invitationService->hasPendingInvitationForEmail($email)
? __('Please accept the organization invitation sent to your email address before registering.')
: __('Registration is only available to invited users.');
throw ValidationException::withMessages([
'email' => [$message],
]);
}
}
$timezone = null; $timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) { if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) { if (app(TimezoneService::class)->isValid($input['timezone'])) {

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Override;
/**
* 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;
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use stdClass;
class SelfHostDatabaseConsistency extends Command class SelfHostDatabaseConsistency extends Command
{ {
@@ -101,7 +102,7 @@ class SelfHostDatabaseConsistency extends Command
} }
/** /**
* @param Collection<int, \stdClass> $problems * @param Collection<int, stdClass> $problems
*/ */
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{ {

View File

@@ -7,7 +7,7 @@ namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter; use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use phpseclib3\Crypt\RSA; use phpseclib4\Crypt\RSA;
class SelfHostGenerateKeysCommand extends Command class SelfHostGenerateKeysCommand extends Command
{ {

View File

@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
]) ])
->whereHas('user', function (Builder $query): void { ->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
$query->where('is_placeholder', '=', false); $query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
}) })
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View 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,
};
}
}

View 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';
}

View File

@@ -21,6 +21,7 @@ enum TimeEntryAggregationType: string
case Billable = 'billable'; case Billable = 'billable';
case Description = 'description'; case Description = 'description';
case Tag = 'tag'; case Tag = 'tag';
case Type = 'type';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{ {

View 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';
}

View File

@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
*/ */
public function report(): bool public function report(): bool
{ {
// TODO: temporary activated
return false; return false;
} }
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{ {
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members'; public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException class CanNotRemoveOwnerFromOrganization extends ApiException
{ {
public const string KEY = 'can_not_remove_owner_from_organization'; public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_of_placeholder_is_not_allowed'; public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_to_placeholder_is_not_allowed'; public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Exceptions\Api; namespace App\Exceptions\Api;
use Override;
class EntityStillInUseApiException extends ApiException class EntityStillInUseApiException extends ApiException
{ {
private string $modelToDelete; private string $modelToDelete;
@@ -19,10 +21,20 @@ class EntityStillInUseApiException extends ApiException
public const string KEY = 'entity_still_in_use'; public const string KEY = 'entity_still_in_use';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
/** /**
* Get the translated message for the exception. * Get the translated message for the exception.
*/ */
#[\Override] #[Override]
public function getTranslatedMessage(): string public function getTranslatedMessage(): string
{ {
return __('exceptions.api.'.$this->getKey(), [ return __('exceptions.api.'.$this->getKey(), [

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{ {
public const string KEY = 'feature_is_not_available_in_free_plan'; public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException class InactiveUserCanNotBeUsedApiException extends ApiException
{ {
public const string KEY = 'inactive_user_can_not_be_used'; public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{ {
public const string KEY = 'invitation_for_the_email_already_exists'; public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException class OnlyOwnerCanChangeOwnership extends ApiException
{ {
public const string KEY = 'only_owner_can_change_ownership'; public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{ {
public const string KEY = 'only_placeholders_can_be_merged_into_another_member'; public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{ {
public const string KEY = 'organization_has_no_subscription_but_multiple_members'; public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException class OrganizationNeedsAtLeastOneOwner extends ApiException
{ {
public const string KEY = 'organization_needs_at_least_one_owner'; public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException class OverlappingTimeEntryApiException extends ApiException
{ {
public const string KEY = 'overlapping_time_entry'; public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{ {
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception'; public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException class TimeEntryCanNotBeRestartedApiException extends ApiException
{ {
public const string KEY = 'time_entry_can_not_be_restarted'; public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException class TimeEntryStillRunningApiException extends ApiException
{ {
public const string KEY = 'time_entry_still_running'; public const string KEY = 'time_entry_still_running';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_organization'; public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException class UserIsAlreadyMemberOfProjectApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_project'; public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException class UserNotPlaceholderApiException extends ApiException
{ {
public const string KEY = 'user_not_placeholder'; public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{ {
public const string KEY = 'user_resend_email_verification_no_pending_email'; public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
} }

View File

@@ -6,7 +6,11 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable; use Throwable;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
@@ -30,6 +34,33 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void { $this->reportable(function (Throwable $e): void {
// //
}); });
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
&& $e->getErrorType() === 'access_denied'
&& $e->getHttpStatusCode() === 401);
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the
// SuspiciousOperationException into a BadRequestHttpException by the time
// renderables run, so we match that and inspect the original.
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
$previous = $e->getPrevious();
if (! $previous instanceof SuspiciousOperationException
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
return null; // any other bad request keeps the default response
}
$message = 'This hostname is not configured for this instance. '
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
if ($request->expectsJson()) {
return response()->json(['message' => $message], 400);
}
return response()->view('errors.untrusted-host', ['message' => $message], 400);
});
} }
public function render($request, Throwable $e): Response|RedirectResponse public function render($request, Throwable $e): Response|RedirectResponse

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationGroup = 'System';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('user_type')
->maxLength(255),
Forms\Components\TextInput::make('user_id'),
Forms\Components\TextInput::make('event')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_type')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')
->maxLength(1023),
Forms\Components\TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('event'),
Tables\Columns\TextColumn::make('auditable_type'),
Tables\Columns\TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->sortable()
->dateTime(),
Tables\Columns\TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAudits::route('/'),
'create' => Pages\CreateAudit::route('/create'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

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

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\ClientResource\Pages; use App\Filament\Resources\ClientResource\Pages\CreateClient;
use App\Filament\Resources\ClientResource\Pages\EditClient;
use App\Filament\Resources\ClientResource\Pages\ListClients;
use App\Models\Client; use App\Models\Client;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -18,16 +23,16 @@ class ClientResource extends Resource
{ {
protected static ?string $model = Client::class; protected static ?string $model = Client::class;
protected static ?string $navigationIcon = 'heroicon-o-briefcase'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-briefcase';
protected static ?string $navigationGroup = 'Timetracking'; protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 4; protected static ?int $navigationSort = 4;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required(), ->required(),
@@ -43,17 +48,17 @@ class ClientResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->label('Name') ->label('Name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('organization.name') TextColumn::make('organization.name')
->sortable() ->sortable()
->label('Organization'), ->label('Organization'),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->label('Created at') ->label('Created at')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->label('Updated at') ->label('Updated at')
->sortable(), ->sortable(),
]) ])
@@ -68,12 +73,12 @@ class ClientResource extends Resource
->relationship('organization', 'id') ->relationship('organization', 'id')
->searchable(), ->searchable(),
]) ])
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make(),
]), ]),
]); ]);
} }
@@ -88,9 +93,9 @@ class ClientResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListClients::route('/'), 'index' => ListClients::route('/'),
'create' => Pages\CreateClient::route('/create'), 'create' => CreateClient::route('/create'),
'edit' => Pages\EditClient::route('/{record}/edit'), 'edit' => EditClient::route('/{record}/edit'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages; namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource; use App\Filament\Resources\ClientResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditClient extends EditRecord class EditClient extends EditRecord
@@ -15,7 +15,7 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages; namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource; use App\Filament\Resources\ClientResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListClients extends ListRecords class ListClients extends ListRecords
@@ -15,7 +15,7 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -7,16 +7,16 @@ namespace App\Filament\Resources;
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs; use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs; use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
use App\Models\FailedJob; use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\Action; use Filament\Schemas\Schema;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -30,19 +30,19 @@ class FailedJobResource extends Resource
{ {
protected static ?string $model = FailedJob::class; protected static ?string $model = FailedJob::class;
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-circle';
protected static ?string $navigationGroup = 'System'; protected static string|\UnitEnum|null $navigationGroup = 'System';
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
{ {
return (string) FailedJob::query()->count(); return (string) FailedJob::query()->count();
} }
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('uuid')->disabled()->columnSpan(4), TextInput::make('uuid')->disabled()->columnSpan(4),
TextInput::make('failed_at')->disabled(), TextInput::make('failed_at')->disabled(),
TextInput::make('id')->disabled(), TextInput::make('id')->disabled(),
@@ -74,7 +74,7 @@ class FailedJobResource extends Resource
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true), TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([]) ->filters([])
->bulkActions([ ->toolbarActions([
BulkAction::make('retry') BulkAction::make('retry')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->label('Retry selected') ->label('Retry selected')
@@ -91,7 +91,7 @@ class FailedJobResource extends Resource
}), }),
DeleteBulkAction::make(), DeleteBulkAction::make(),
]) ])
->actions([ ->recordActions([
DeleteAction::make(), DeleteAction::make(),
ViewAction::make(), ViewAction::make(),
Action::make('retry') Action::make('retry')

View File

@@ -5,14 +5,21 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\Role; use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages; use App\Filament\Resources\OrganizationInvitationResource\Pages\EditOrganizationInvitation;
use App\Filament\Resources\OrganizationInvitationResource\Pages\ListOrganizationInvitations;
use App\Filament\Resources\OrganizationInvitationResource\Pages\ViewOrganizationInvitation;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService; use App\Service\OrganizationInvitationService;
use Filament\Forms; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -22,18 +29,18 @@ class OrganizationInvitationResource extends Resource
protected static ?string $label = 'Invitations'; protected static ?string $label = 'Invitations';
protected static ?string $navigationIcon = 'heroicon-o-user-plus'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationGroup = 'Users'; protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 9; protected static ?int $navigationSort = 9;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->columns(1) ->columns(1)
->schema([ ->components([
Forms\Components\TextInput::make('email') TextInput::make('email')
->label('Email') ->label('Email')
->disabledOn(['edit']) ->disabledOn(['edit'])
->required(), ->required(),
@@ -45,11 +52,11 @@ class OrganizationInvitationResource extends Resource
->searchable(['name']) ->searchable(['name'])
->disabledOn(['edit']) ->disabledOn(['edit'])
->required(), ->required(),
Forms\Components\DateTimePicker::make('created_at') DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
@@ -60,17 +67,17 @@ class OrganizationInvitationResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('organization.name') TextColumn::make('organization.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('email') TextColumn::make('email')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('role'), TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->label('Created At') ->label('Created At')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->label('Updated At') ->label('Updated At')
->dateTime() ->dateTime()
->sortable() ->sortable()
@@ -80,13 +87,13 @@ class OrganizationInvitationResource extends Resource
->filters([ ->filters([
// //
]) ])
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
Tables\Actions\DeleteAction::make(), DeleteAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend') BulkAction::make('resend')
->label('Resend') ->label('Resend')
->action(function (Collection $records): void { ->action(function (Collection $records): void {
foreach ($records as $organizationInvite) { foreach ($records as $organizationInvite) {
@@ -106,9 +113,9 @@ class OrganizationInvitationResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListOrganizationInvitations::route('/'), 'index' => ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'), 'edit' => EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'), 'view' => ViewOrganizationInvitation::route('/{record}'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages; namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource; use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord class EditOrganizationInvitation extends EditRecord
@@ -15,7 +15,7 @@ class EditOrganizationInvitation extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -9,7 +9,10 @@ use App\Enums\DateFormat;
use App\Enums\IntervalFormat; use App\Enums\IntervalFormat;
use App\Enums\NumberFormat; use App\Enums\NumberFormat;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource\Pages\CreateOrganization;
use App\Filament\Resources\OrganizationResource\Pages\EditOrganization;
use App\Filament\Resources\OrganizationResource\Pages\ListOrganizations;
use App\Filament\Resources\OrganizationResource\Pages\ViewOrganization;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization; use App\Models\Organization;
@@ -20,38 +23,45 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto; use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService; use App\Service\Import\ImportService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Filament\Forms; use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToReadFile;
class OrganizationResource extends Resource class OrganizationResource extends Resource
{ {
protected static ?string $model = Organization::class; protected static ?string $model = Organization::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office-2'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static ?string $navigationGroup = 'Users'; protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 7; protected static ?int $navigationSort = 7;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->columns(1) ->columns(1)
->schema([ ->components([
Forms\Components\TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('personal_team') Toggle::make('personal_team')
->label('Is personal?') ->label('Is personal?')
->hiddenOn(['create']) ->hiddenOn(['create'])
->required(), ->required(),
@@ -79,7 +89,7 @@ class OrganizationResource extends Resource
Select::make('currency') Select::make('currency')
->label('Currency') ->label('Currency')
->options(function (): array { ->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = []; $select = [];
foreach ($currencies as $currency) { foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
@@ -89,7 +99,7 @@ class OrganizationResource extends Resource
}) })
->required() ->required()
->searchable(), ->searchable(),
Forms\Components\TextInput::make('billable_rate') TextInput::make('billable_rate')
->label('Billable rate (in Cents)') ->label('Billable rate (in Cents)')
->nullable() ->nullable()
->rules([ ->rules([
@@ -99,11 +109,11 @@ class OrganizationResource extends Resource
'max:2147483647', 'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\DateTimePicker::make('created_at') DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
@@ -117,7 +127,7 @@ class OrganizationResource extends Resource
TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('personal_team') IconColumn::make('personal_team')
->boolean() ->boolean()
->label('Is personal?') ->label('Is personal?')
->sortable(), ->sortable(),
@@ -138,9 +148,9 @@ class OrganizationResource extends Resource
->filters([ ->filters([
// //
]) ])
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
Tables\Actions\DeleteAction::make() DeleteAction::make()
->using(function (Organization $record): void { ->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record); app(DeletionService::class)->deleteOrganization($record);
}), }),
@@ -158,7 +168,7 @@ class OrganizationResource extends Resource
return response()->streamDownload(function () use ($file): void { return response()->streamDownload(function () use ($file): void {
echo Storage::disk(config('filesystems.private'))->get($file); echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip'); }, 'export.zip');
} catch (\Exception $exception) { } catch (Exception $exception) {
report($exception); report($exception);
Notification::make() Notification::make()
->title('Export failed') ->title('Export failed')
@@ -172,9 +182,13 @@ class OrganizationResource extends Resource
->icon('heroicon-o-inbox-arrow-down') ->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void { ->action(function (Organization $record, array $data): void {
try { try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
} catch (UnableToReadFile) {
$file = null;
}
if ($file === null) { if ($file === null) {
throw new \Exception('File not found'); throw new ImportException('File not found');
} }
/** @var string $timezone */ /** @var string $timezone */
$timezone = $data['timezone']; $timezone = $data['timezone'];
@@ -209,8 +223,8 @@ class OrganizationResource extends Resource
} }
}) })
->tooltip(fn (Organization $record): string => 'Import into '.$record->name) ->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->form([ ->schema([
Forms\Components\FileUpload::make('file') FileUpload::make('file')
->label('File') ->label('File')
->required(), ->required(),
Select::make('type') Select::make('type')
@@ -230,7 +244,7 @@ class OrganizationResource extends Resource
->required(), ->required(),
]), ]),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
@@ -245,10 +259,10 @@ class OrganizationResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListOrganizations::route('/'), 'index' => ListOrganizations::route('/'),
'create' => Pages\CreateOrganization::route('/create'), 'create' => CreateOrganization::route('/create'),
'edit' => Pages\EditOrganization::route('/{record}/edit'), 'edit' => EditOrganization::route('/{record}/edit'),
'view' => Pages\ViewOrganization::route('/{record}'), 'view' => ViewOrganization::route('/{record}'),
]; ];
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages; namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Filament\Resources\OrganizationResource\Actions\DeleteOrganization;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord class EditOrganization extends EditRecord
@@ -14,7 +15,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
OrganizationResource\Actions\DeleteOrganization::make(), DeleteOrganization::make(),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages; namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListOrganizations extends ListRecords class ListOrganizations extends ListRecords
@@ -15,7 +15,7 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -9,12 +9,17 @@ use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\InvitationService; use App\Service\InvitationService;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DetachBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -25,10 +30,10 @@ class InvitationsRelationManager extends RelationManager
protected static ?string $title = 'Invitations'; protected static ?string $title = 'Invitations';
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('email') TextInput::make('email')
->label('Email') ->label('Email')
->disabledOn(['edit']) ->disabledOn(['edit'])
@@ -53,33 +58,33 @@ class InvitationsRelationManager extends RelationManager
->modelLabel('Invitation') ->modelLabel('Invitation')
->pluralModelLabel('Invitations') ->pluralModelLabel('Invitations')
->columns([ ->columns([
Tables\Columns\TextColumn::make('email'), TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'), TextColumn::make('role'),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus') ->icon('heroicon-s-plus')
->using(function (array $data, string $model): Model { ->using(function (array $data, string $model): Model {
/** @var Organization $ownerRecord */ /** @var Organization $ownerRecord */
$ownerRecord = $this->getOwnerRecord(); $ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class) return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user()); ->inviteUser($ownerRecord, $data['email'], ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), auth()->user());
}), }),
]) ])
->actions([ ->recordActions([
Action::make('view') Action::make('view')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [ ->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make(), EditAction::make(),
Tables\Actions\DeleteAction::make(), DeleteAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(), DetachBulkAction::make(),
]), ]),
]); ]);
} }

View File

@@ -12,14 +12,15 @@ use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\MemberService; use App\Service\MemberService;
use Filament\Actions\Action;
use Filament\Actions\AttachAction;
use Filament\Actions\DetachAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -28,10 +29,10 @@ class UsersRelationManager extends RelationManager
{ {
protected static string $relationship = 'users'; protected static string $relationship = 'users';
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
Select::make('role') Select::make('role')
->options(Role::class), ->options(Role::class),
TextInput::make('billable_rate') TextInput::make('billable_rate')
@@ -75,17 +76,17 @@ class UsersRelationManager extends RelationManager
->using(function (User $record, array $data): void { ->using(function (User $record, array $data): void {
/** @var Organization $organization */ /** @var Organization $organization */
$organization = $this->getOwnerRecord(); $organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true); app(MemberService::class)->addMember($record, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
}), }),
]) ])
->actions([ ->recordActions([
Action::make('view') Action::make('view')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->url(fn (User $record): string => UserResource::getUrl('view', [ ->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make() EditAction::make()
->using(function (User $record, array $data): User { ->using(function (User $record, array $data): User {
/** @var Organization $organization */ /** @var Organization $organization */
$organization = $this->getOwnerRecord(); $organization = $this->getOwnerRecord();
@@ -97,9 +98,10 @@ class UsersRelationManager extends RelationManager
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member); app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
} }
if ($data['role'] !== $member->role) { $newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
try { try {
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true); app(MemberService::class)->changeRole($member, $organization, $newRole, true);
} catch (ApiException $exception) { } catch (ApiException $exception) {
Notification::make() Notification::make()
->danger() ->danger()
@@ -113,7 +115,7 @@ class UsersRelationManager extends RelationManager
return $record; return $record;
}), }),
Tables\Actions\DetachAction::make() DetachAction::make()
->using(function (User $record): void { ->using(function (User $record): void {
/** @var Organization $organization */ /** @var Organization $organization */
$organization = $this->getOwnerRecord(); $organization = $this->getOwnerRecord();
@@ -133,7 +135,7 @@ class UsersRelationManager extends RelationManager
} }
}), }),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
} }

View File

@@ -4,12 +4,19 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\ProjectMemberResource\Pages; use App\Filament\Resources\ProjectMemberResource\Pages\CreateProjectMember;
use App\Filament\Resources\ProjectMemberResource\Pages\EditProjectMember;
use App\Filament\Resources\ProjectMemberResource\Pages\ListProjectMembers;
use App\Filament\Resources\ProjectMemberResource\Pages\ViewProjectMembers;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use Filament\Forms; use Filament\Actions\BulkActionGroup;
use Filament\Forms\Form; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
class ProjectMemberResource extends Resource class ProjectMemberResource extends Resource
@@ -18,11 +25,11 @@ class ProjectMemberResource extends Resource
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
Forms\Components\TextInput::make('billable_rate') TextInput::make('billable_rate')
->label('Billable rate (in Cents)') ->label('Billable rate (in Cents)')
->nullable() ->nullable()
->rules([ ->rules([
@@ -32,10 +39,10 @@ class ProjectMemberResource extends Resource
'max:2147483647', 'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('user_id') Select::make('user_id')
->relationship('user', 'name') ->relationship('user', 'name')
->required(), ->required(),
Forms\Components\Select::make('member_id') Select::make('member_id')
->relationship('member', 'id') ->relationship('member', 'id')
->required(), ->required(),
]); ]);
@@ -45,17 +52,17 @@ class ProjectMemberResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('id') TextColumn::make('id')
->label('ID'), ->label('ID'),
Tables\Columns\TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->numeric() ->numeric()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('project.name'), TextColumn::make('project.name'),
Tables\Columns\TextColumn::make('user.name'), TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
@@ -63,12 +70,12 @@ class ProjectMemberResource extends Resource
->filters([ ->filters([
// //
]) ])
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make(),
]), ]),
]); ]);
} }
@@ -83,10 +90,10 @@ class ProjectMemberResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListProjectMembers::route('/'), 'index' => ListProjectMembers::route('/'),
'create' => Pages\CreateProjectMember::route('/create'), 'create' => CreateProjectMember::route('/create'),
'edit' => Pages\EditProjectMember::route('/{record}/edit'), 'edit' => EditProjectMember::route('/{record}/edit'),
'view' => Pages\ViewProjectMembers::route('/{record}'), 'view' => ViewProjectMembers::route('/{record}'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages; namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource; use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditProjectMember extends EditRecord class EditProjectMember extends EditRecord
@@ -15,7 +15,7 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages; namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource; use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListProjectMembers extends ListRecords class ListProjectMembers extends ListRecords
@@ -15,7 +15,7 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -4,14 +4,19 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages; use App\Filament\Resources\ProjectResource\Pages\CreateProject;
use App\Filament\Resources\ProjectResource\Pages\EditProject;
use App\Filament\Resources\ProjectResource\Pages\ListProjects;
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager; use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
use App\Models\Project; use App\Models\Project;
use Filament\Forms; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Form; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\ColorColumn; use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@@ -21,24 +26,24 @@ class ProjectResource extends Resource
{ {
protected static ?string $model = Project::class; protected static ?string $model = Project::class;
protected static ?string $navigationIcon = 'heroicon-o-folder'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
protected static ?string $navigationGroup = 'Timetracking'; protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 2; protected static ?int $navigationSort = 2;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
Forms\Components\TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
ColorPicker::make('color') ColorPicker::make('color')
->label('Color') ->label('Color')
->required(), ->required(),
Forms\Components\TextInput::make('billable_rate') TextInput::make('billable_rate')
->label('Billable rate (in Cents)') ->label('Billable rate (in Cents)')
->nullable() ->nullable()
->rules([ ->rules([
@@ -48,7 +53,7 @@ class ProjectResource extends Resource
'max:2147483647', 'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('organization_id') Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name') ->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
->required(), ->required(),
@@ -81,12 +86,12 @@ class ProjectResource extends Resource
->searchable(), ->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make(),
]), ]),
]); ]);
} }
@@ -101,9 +106,9 @@ class ProjectResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListProjects::route('/'), 'index' => ListProjects::route('/'),
'create' => Pages\CreateProject::route('/create'), 'create' => CreateProject::route('/create'),
'edit' => Pages\EditProject::route('/{record}/edit'), 'edit' => EditProject::route('/{record}/edit'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages; namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource; use App\Filament\Resources\ProjectResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditProject extends EditRecord class EditProject extends EditRecord
@@ -15,7 +15,7 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages; namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource; use App\Filament\Resources\ProjectResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListProjects extends ListRecords class ListProjects extends ListRecords
@@ -15,7 +15,7 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -6,10 +6,10 @@ namespace App\Filament\Resources\ProjectResource\RelationManagers;
use App\Filament\Resources\ProjectMemberResource; use App\Filament\Resources\ProjectMemberResource;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use Filament\Forms\Form; use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
class ProjectMembersRelationManager extends RelationManager class ProjectMembersRelationManager extends RelationManager
@@ -18,10 +18,10 @@ class ProjectMembersRelationManager extends RelationManager
protected static string $relationship = 'members'; protected static string $relationship = 'members';
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
]); ]);
} }
@@ -30,8 +30,8 @@ class ProjectMembersRelationManager extends RelationManager
return $table return $table
->recordTitleAttribute('name') ->recordTitleAttribute('name')
->columns([ ->columns([
Tables\Columns\TextColumn::make('user.name'), TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->numeric() ->numeric()
->sortable(), ->sortable(),
]) ])
@@ -40,7 +40,7 @@ class ProjectMembersRelationManager extends RelationManager
]) ])
->headerActions([ ->headerActions([
]) ])
->actions([ ->recordActions([
Action::make('view') Action::make('view')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
@@ -54,7 +54,7 @@ class ProjectMembersRelationManager extends RelationManager
])) ]))
->openUrlInNewTab(), ->openUrlInNewTab(),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
} }

View File

@@ -4,16 +4,21 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages; use App\Filament\Resources\ReportResource\Pages\EditReport;
use App\Filament\Resources\ReportResource\Pages\ListReports;
use App\Filament\Resources\ReportResource\Pages\ViewReport;
use App\Models\Report; use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto; use App\Service\Dto\ReportPropertiesDto;
use Filament\Forms; use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@@ -24,22 +29,22 @@ class ReportResource extends Resource
{ {
protected static ?string $model = Report::class; protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationGroup = 'Timetracking'; protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7; protected static ?int $navigationSort = 7;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->columns(1) ->columns(1)
->schema([ ->components([
Forms\Components\TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('description') TextInput::make('description')
->label('Description') ->label('Description')
->nullable() ->nullable()
->maxLength(255), ->maxLength(255),
@@ -49,13 +54,13 @@ class ReportResource extends Resource
DateTimePicker::make('public_until') DateTimePicker::make('public_until')
->label('Public until') ->label('Public until')
->nullable(), ->nullable(),
Forms\Components\Select::make('organization_id') Select::make('organization_id')
->label('Organization') ->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name') ->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
->disabled() ->disabled()
->required(), ->required(),
Forms\Components\TextInput::make('share_secret') TextInput::make('share_secret')
->label('Share Secret') ->label('Share Secret')
->nullable(), ->nullable(),
PrettyJsonField::make('properties') PrettyJsonField::make('properties')
@@ -109,18 +114,18 @@ class ReportResource extends Resource
->relationship('organization', 'id') ->relationship('organization', 'id')
->searchable(), ->searchable(),
]) ])
->actions([ ->recordActions([
Action::make('public-view') Action::make('public-view')
->label('Public') ->label('Public')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->hidden(fn (Report $record): bool => $record->getShareableLink() === null) ->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
->url(fn (Report $record): string => $record->getShareableLink(), true), ->url(fn (Report $record): string => $record->getShareableLink(), true),
Tables\Actions\ViewAction::make(), ViewAction::make(),
Tables\Actions\EditAction::make(), EditAction::make(),
Tables\Actions\DeleteAction::make(), DeleteAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
@@ -133,9 +138,9 @@ class ReportResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListReports::route('/'), 'index' => ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'), 'edit' => EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'), 'view' => ViewReport::route('/{record}'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages; namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource; use App\Filament\Resources\ReportResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord class EditReport extends EditRecord
@@ -15,7 +15,7 @@ class EditReport extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TagResource\Pages; use App\Filament\Resources\TagResource\Pages\CreateTag;
use App\Filament\Resources\TagResource\Pages\EditTag;
use App\Filament\Resources\TagResource\Pages\ListTags;
use App\Models\Tag; use App\Models\Tag;
use Filament\Forms; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -18,20 +23,20 @@ class TagResource extends Resource
{ {
protected static ?string $model = Tag::class; protected static ?string $model = Tag::class;
protected static ?string $navigationIcon = 'heroicon-o-tag'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationGroup = 'Timetracking'; protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 5; protected static ?int $navigationSort = 5;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required(), ->required(),
Forms\Components\Select::make('organization_id') Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name') ->relationship(name: 'organization', titleAttribute: 'name')
->label('Organization') ->label('Organization')
->searchable(['name']) ->searchable(['name'])
@@ -43,17 +48,17 @@ class TagResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->label('Name') ->label('Name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('organization.name') TextColumn::make('organization.name')
->sortable() ->sortable()
->label('Organization'), ->label('Organization'),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->label('Created at') ->label('Created at')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->label('Updated at') ->label('Updated at')
->sortable(), ->sortable(),
]) ])
@@ -68,12 +73,12 @@ class TagResource extends Resource
->relationship('organization', 'id') ->relationship('organization', 'id')
->searchable(), ->searchable(),
]) ])
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make(),
]), ]),
]); ]);
} }
@@ -88,9 +93,9 @@ class TagResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListTags::route('/'), 'index' => ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'), 'create' => CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'), 'edit' => EditTag::route('/{record}/edit'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages; namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource; use App\Filament\Resources\TagResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditTag extends EditRecord class EditTag extends EditRecord
@@ -15,7 +15,7 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages; namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource; use App\Filament\Resources\TagResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListTags extends ListRecords class ListTags extends ListRecords
@@ -15,7 +15,7 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages; use App\Filament\Resources\TaskResource\Pages\CreateTask;
use App\Filament\Resources\TaskResource\Pages\EditTask;
use App\Filament\Resources\TaskResource\Pages\ListTasks;
use App\Models\Task; use App\Models\Task;
use Filament\Forms; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -18,17 +23,17 @@ class TaskResource extends Resource
{ {
protected static ?string $model = Task::class; protected static ?string $model = Task::class;
protected static ?string $navigationIcon = 'heroicon-o-list-bullet'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static ?string $navigationGroup = 'Timetracking'; protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 3; protected static ?int $navigationSort = 3;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
Forms\Components\TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
@@ -47,16 +52,16 @@ class TaskResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('project.name') TextColumn::make('project.name')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('organization.name') TextColumn::make('organization.name')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([
@@ -70,12 +75,12 @@ class TaskResource extends Resource
->searchable(), ->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make(),
]), ]),
]); ]);
} }
@@ -90,9 +95,9 @@ class TaskResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListTasks::route('/'), 'index' => ListTasks::route('/'),
'create' => Pages\CreateTask::route('/create'), 'create' => CreateTask::route('/create'),
'edit' => Pages\EditTask::route('/{record}/edit'), 'edit' => EditTask::route('/{record}/edit'),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages; namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource; use App\Filament\Resources\TaskResource;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditTask extends EditRecord class EditTask extends EditRecord
@@ -15,7 +15,7 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages; namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource; use App\Filament\Resources\TaskResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListTasks extends ListRecords class ListTasks extends ListRecords
@@ -15,7 +15,7 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -4,16 +4,20 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages; use App\Filament\Resources\TimeEntryResource\Pages\CreateTimeEntry;
use App\Filament\Resources\TimeEntryResource\Pages\EditTimeEntry;
use App\Filament\Resources\TimeEntryResource\Pages\ListTimeEntries;
use App\Models\Member; use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -23,16 +27,16 @@ class TimeEntryResource extends Resource
{ {
protected static ?string $model = TimeEntry::class; protected static ?string $model = TimeEntry::class;
protected static ?string $navigationIcon = 'heroicon-o-clock'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationGroup = 'Timetracking'; protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 1; protected static ?int $navigationSort = 1;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('id') TextInput::make('id')
->label('ID') ->label('ID')
->readOnly() ->readOnly()
@@ -111,12 +115,12 @@ class TimeEntryResource extends Resource
->searchable(), ->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->recordActions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make(),
]), ]),
]); ]);
} }
@@ -131,9 +135,9 @@ class TimeEntryResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListTimeEntries::route('/'), 'index' => ListTimeEntries::route('/'),
'create' => Pages\CreateTimeEntry::route('/create'), 'create' => CreateTimeEntry::route('/create'),
'edit' => Pages\EditTimeEntry::route('/{record}/edit'), 'edit' => EditTimeEntry::route('/{record}/edit'),
]; ];
} }
} }

View File

@@ -6,7 +6,7 @@ namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\TimeEntryResource;
use App\Models\Member; use App\Models\Member;
use Filament\Actions; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditTimeEntry extends EditRecord class EditTimeEntry extends EditRecord
@@ -16,7 +16,7 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages; namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\TimeEntryResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListTimeEntries extends ListRecords class ListTimeEntries extends ListRecords
@@ -15,7 +15,7 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages; use App\Filament\Resources\TokenResource\Pages\ListTokens;
use App\Filament\Resources\TokenResource\Pages\ViewToken;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Filament\Forms; use Filament\Actions\ViewAction;
use Filament\Forms\Form; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -18,48 +24,48 @@ class TokenResource extends Resource
{ {
protected static ?string $model = Token::class; protected static ?string $model = Token::class;
protected static ?string $navigationIcon = 'heroicon-o-key'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'Auth'; protected static string|\UnitEnum|null $navigationGroup = 'Auth';
protected static ?int $navigationSort = 6; protected static ?int $navigationSort = 6;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form return $schema
->columns(1) ->columns(1)
->schema([ ->components([
Forms\Components\TextInput::make('id') TextInput::make('id')
->label('ID') ->label('ID')
->disabled() ->disabled()
->visibleOn(['update', 'show']) ->visibleOn(['update', 'show'])
->readOnly() ->readOnly()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('name') TextInput::make('name')
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('owner_id') Select::make('owner_id')
->label('User') ->label('User')
->relationship(name: 'user', titleAttribute: 'name') ->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
->disabled() ->disabled()
->required(), ->required(),
Forms\Components\Select::make('client_id') Select::make('client_id')
->label('Client') ->label('Client')
->relationship(name: 'client', titleAttribute: 'name') ->relationship(name: 'client', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
->required(), ->required(),
Forms\Components\Toggle::make('revoked') Toggle::make('revoked')
->label('Revoked') ->label('Revoked')
->required(), ->required(),
Forms\Components\DateTimePicker::make('expires_at') DateTimePicker::make('expires_at')
->label('Expires At') ->label('Expires At')
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('created_at') DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->disabled(), ->disabled(),
]); ]);
@@ -69,32 +75,32 @@ class TokenResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('user.name') TextColumn::make('user.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('client.name') TextColumn::make('client.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('personal_access_client') IconColumn::make('personal_access_client')
->state(function (Token $token): bool { ->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true); return in_array('personal_access', $token->client->grant_types ?? [], true);
}) })
->boolean() ->boolean()
->label('API token?'), ->label('API token?'),
Tables\Columns\IconColumn::make('revoked') IconColumn::make('revoked')
->boolean() ->boolean()
->label('Revoked?') ->label('Revoked?')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('expires_at') TextColumn::make('expires_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
@@ -120,10 +126,10 @@ class TokenResource extends Resource
TernaryFilter::make('revoked') TernaryFilter::make('revoked')
->label('Revoked?'), ->label('Revoked?'),
]) ])
->actions([ ->recordActions([
Tables\Actions\ViewAction::make(), ViewAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
@@ -136,8 +142,8 @@ class TokenResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListTokens::route('/'), 'index' => ListTokens::route('/'),
'view' => Pages\ViewToken::route('/{record}'), 'view' => ViewToken::route('/{record}'),
]; ];
} }
} }

View File

@@ -6,21 +6,30 @@ namespace App\Filament\Resources;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Exceptions\Api\ApiException; use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\Pages\CreateUser;
use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Filament\Resources\UserResource\Pages\ViewUser;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService; use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Exception; use Exception;
use Filament\Forms; use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -28,26 +37,26 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Tables\Actions\Impersonate; use STS\FilamentImpersonate\Actions\Impersonate;
class UserResource extends Resource class UserResource extends Resource
{ {
protected static ?string $model = User::class; protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-user'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user';
protected static ?string $navigationGroup = 'Users'; protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 6; protected static ?int $navigationSort = 6;
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
/** @var User|null $record */ /** @var User|null $record */
$record = $form->getRecord(); $record = $schema->getRecord();
return $form return $schema
->columns(1) ->columns(1)
->schema([ ->components([
TextInput::make('id') TextInput::make('id')
->label('ID') ->label('ID')
->disabled() ->disabled()
@@ -66,26 +75,26 @@ class UserResource extends Resource
->ignore($record?->getKey()), ->ignore($record?->getKey()),
]) ])
->rule([ ->rule([
'email', 'email:rfc,strict',
]) ])
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('is_placeholder') Toggle::make('is_placeholder')
->label('Is Placeholder?') ->label('Is Placeholder?')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabledOn(['edit']), ->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at') DateTimePicker::make('email_verified_at')
->label('Email Verified At') ->label('Email Verified At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->nullable(), ->nullable(),
Forms\Components\Toggle::make('is_email_verified') Toggle::make('is_email_verified')
->label('Email Verified?') ->label('Email Verified?')
->visibleOn(['create']), ->visibleOn(['create']),
Forms\Components\Select::make('timezone') Select::make('timezone')
->label('Timezone') ->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable() ->searchable()
->required(), ->required(),
Forms\Components\Select::make('week_start') Select::make('week_start')
->label('Week Start') ->label('Week Start')
->options(Weekday::class) ->options(Weekday::class)
->required(), ->required(),
@@ -103,10 +112,10 @@ class UserResource extends Resource
->visibleOn(['create']) ->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create') ->required(fn (string $context): bool => $context === 'create')
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('currency') Select::make('currency')
->label('Currency (Personal Organization)') ->label('Currency (Personal Organization)')
->options(function (): array { ->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = []; $select = [];
foreach ($currencies as $currency) { foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
@@ -117,11 +126,11 @@ class UserResource extends Resource
->required() ->required()
->visibleOn(['create']) ->visibleOn(['create'])
->searchable(), ->searchable(),
Forms\Components\DateTimePicker::make('created_at') DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create']) ->hiddenOn(['create'])
->disabled(), ->disabled(),
@@ -132,25 +141,25 @@ class UserResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('email') TextColumn::make('email')
->icon('heroicon-m-envelope') ->icon('heroicon-m-envelope')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('is_real_user') IconColumn::make('is_real_user')
->getStateUsing(fn (User $record): bool => ! $record->is_placeholder) ->getStateUsing(fn (User $record): bool => ! $record->is_placeholder)
->label('Real user?') ->label('Real user?')
->boolean(), ->boolean(),
Tables\Columns\IconColumn::make('email_verified') IconColumn::make('email_verified')
->getStateUsing(fn (User $record): bool => $record->email_verified_at !== null) ->getStateUsing(fn (User $record): bool => $record->email_verified_at !== null)
->label('Email verified?') ->label('Email verified?')
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
@@ -178,7 +187,7 @@ class UserResource extends Resource
->attribute('email_verified_at') ->attribute('email_verified_at')
->nullable(), ->nullable(),
]) ])
->actions([ ->recordActions([
Impersonate::make()->before(function (User $record): void { Impersonate::make()->before(function (User $record): void {
if ($record->currentOrganization === null) { if ($record->currentOrganization === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first(); $organization = $record->organizations()->where('personal_team', '=', true)->first();
@@ -191,8 +200,8 @@ class UserResource extends Resource
app(UserService::class)->switchCurrentOrganization($record, $organization); app(UserService::class)->switchCurrentOrganization($record, $organization);
} }
}), }),
Tables\Actions\EditAction::make(), EditAction::make(),
Tables\Actions\DeleteAction::make() DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user())) ->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void { ->using(function (User $record): void {
try { try {
@@ -207,8 +216,8 @@ class UserResource extends Resource
} }
}), }),
]) ])
->bulkActions([ ->toolbarActions([
Tables\Actions\BulkAction::make('Resend verification email') BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane') ->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void { ->action(function (Collection $records): void {
foreach ($records as $user) { foreach ($records as $user) {
@@ -230,10 +239,10 @@ class UserResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListUsers::route('/'), 'index' => ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'), 'create' => CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'), 'edit' => EditUser::route('/{record}/edit'),
'view' => Pages\ViewUser::route('/{record}'), 'view' => ViewUser::route('/{record}'),
]; ];
} }
} }

View File

@@ -22,7 +22,7 @@ class CreateUser extends CreateRecord
$data['email'], $data['email'],
$data['password_create'], $data['password_create'],
$data['timezone'], $data['timezone'],
Weekday::from($data['week_start']), $data['week_start'] instanceof Weekday ? $data['week_start'] : Weekday::from($data['week_start']),
$data['currency'], $data['currency'],
verifyEmail: (bool) $data['is_email_verified'] verifyEmail: (bool) $data['is_email_verified']
); );

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Filament\Resources\UserResource\Actions\DeleteUser;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate; use STS\FilamentImpersonate\Actions\Impersonate;
class EditUser extends EditRecord class EditUser extends EditRecord
{ {
@@ -16,7 +17,7 @@ class EditUser extends EditRecord
{ {
return [ return [
Impersonate::make()->record($this->getRecord()), Impersonate::make()->record($this->getRecord()),
UserResource\Actions\DeleteUser::make(), DeleteUser::make(),
]; ];
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords class ListUsers extends ListRecords
@@ -15,7 +15,7 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->icon('heroicon-s-plus'), ->icon('heroicon-s-plus'),
]; ];
} }

View File

@@ -7,7 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate; use STS\FilamentImpersonate\Actions\Impersonate;
class ViewUser extends ViewRecord class ViewUser extends ViewRecord
{ {

View File

@@ -11,12 +11,13 @@ use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\MemberService; use App\Service\MemberService;
use Filament\Actions\Action;
use Filament\Actions\DetachAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -24,10 +25,10 @@ class OrganizationsRelationManager extends RelationManager
{ {
protected static string $relationship = 'organizations'; protected static string $relationship = 'organizations';
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
Select::make('role') Select::make('role')
->options(Role::class), ->options(Role::class),
]); ]);
@@ -46,21 +47,22 @@ class OrganizationsRelationManager extends RelationManager
]) ])
->headerActions([ ->headerActions([
]) ])
->actions([ ->recordActions([
Action::make('view') Action::make('view')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make() EditAction::make()
->using(function (Organization $record, array $data): Organization { ->using(function (Organization $record, array $data): Organization {
/** @var Member $member */ /** @var Member $member */
$member = $record->getRelation('membership'); $member = $record->getRelation('membership');
if ($data['role'] !== $member->role) { $newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
try { try {
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true); app(MemberService::class)->changeRole($member, $record, $newRole, true);
} catch (ApiException $exception) { } catch (ApiException $exception) {
Notification::make() Notification::make()
->danger() ->danger()
@@ -74,7 +76,7 @@ class OrganizationsRelationManager extends RelationManager
return $record; return $record;
}), }),
Tables\Actions\DetachAction::make() DetachAction::make()
->using(function (Organization $record): void { ->using(function (Organization $record): void {
/** @var User $user */ /** @var User $user */
$user = $this->getOwnerRecord(); $user = $this->getOwnerRecord();
@@ -94,7 +96,7 @@ class OrganizationsRelationManager extends RelationManager
} }
}), }),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
} }

View File

@@ -6,10 +6,10 @@ namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Organization; use App\Models\Organization;
use Filament\Forms\Form; use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
class OwnedOrganizationsRelationManager extends RelationManager class OwnedOrganizationsRelationManager extends RelationManager
@@ -18,10 +18,10 @@ class OwnedOrganizationsRelationManager extends RelationManager
protected static string $relationship = 'ownedOrganizations'; protected static string $relationship = 'ownedOrganizations';
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
]); ]);
} }
@@ -30,14 +30,14 @@ class OwnedOrganizationsRelationManager extends RelationManager
return $table return $table
->recordTitleAttribute('name') ->recordTitleAttribute('name')
->columns([ ->columns([
Tables\Columns\TextColumn::make('name'), TextColumn::make('name'),
]) ])
->filters([ ->filters([
// //
]) ])
->headerActions([ ->headerActions([
]) ])
->actions([ ->recordActions([
Action::make('view') Action::make('view')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
@@ -51,7 +51,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
])) ]))
->openUrlInNewTab(), ->openUrlInNewTab(),
]) ])
->bulkActions([ ->toolbarActions([
]); ]);
} }
} }

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