mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-14 08:43:16 +01:00
Compare commits
5 Commits
feature/ex
...
feature/ru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5bcf98b84 | ||
|
|
0bfeab156c | ||
|
|
332edcb047 | ||
|
|
c791057f25 | ||
|
|
4a4acb2214 |
7
.github/workflows/phpunit-extensions.yml
vendored
7
.github/workflows/phpunit-extensions.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
phpunit-extensions:
|
phpunit-extensions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
postgres_version: [ 15, 16, 17 ]
|
postgres_version: [ 15, 16, 17 ]
|
||||||
@@ -132,8 +132,5 @@ jobs:
|
|||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
php artisan passport:keys
|
php artisan passport:keys
|
||||||
|
|
||||||
- name: "Run PHPUnit (extensions)"
|
- name: "Run PHPUnit"
|
||||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||||
|
|
||||||
- name: "Run PHPUnit (core)"
|
|
||||||
run: php artisan test --testsuite=Unit,Feature --stop-on-failure
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner).
|
|
||||||
# The scanner builds this image with the repository root as the build context and then audits it without network
|
|
||||||
# access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend,
|
|
||||||
# a local PostgreSQL and a local Gotenberg for PDF rendering) is installed here.
|
|
||||||
#
|
|
||||||
# The base image is the FrankenPHP image the production image (docker/prod/Dockerfile) is built on: in production the
|
|
||||||
# app runs as long-lived Laravel Octane workers in FrankenPHP worker mode, not as one PHP process per request.
|
|
||||||
#
|
|
||||||
# Inside the finished image:
|
|
||||||
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
|
|
||||||
# .oss-scanner/start-gotenberg.sh start the local Gotenberg server (required for PDF exports)
|
|
||||||
# php artisan test run the PHPUnit suite (needs PostgreSQL and Gotenberg running)
|
|
||||||
# .oss-scanner/start-octane.sh run the app like production on http://127.0.0.1:8000 (starts everything above)
|
|
||||||
|
|
||||||
ARG FRANKENPHP_VERSION=1.11
|
|
||||||
ARG PHP_VERSION=8.3
|
|
||||||
|
|
||||||
FROM node:20-trixie-slim AS node
|
|
||||||
|
|
||||||
FROM gotenberg/gotenberg:8 AS gotenberg
|
|
||||||
|
|
||||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
|
||||||
COMPOSER_NO_INTERACTION=1 \
|
|
||||||
OCTANE_SERVER=frankenphp \
|
|
||||||
TZ=UTC
|
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
|
||||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
|
||||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
|
||||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
|
||||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
|
||||||
|
|
||||||
# PostgreSQL 17 (Debian trixie default) is one of the versions solidtime is tested against in CI.
|
|
||||||
# chromium, qpdf and exiftool are what Gotenberg needs for HTML to PDF rendering.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
|
|
||||||
chromium fonts-liberation qpdf libimage-exiftool-perl \
|
|
||||||
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap apcu \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
|
|
||||||
|
|
||||||
# Gotenberg 8 (the PDF renderer, same image as in CI). solidtime only uses its Chromium HTML to PDF route, so LibreOffice
|
|
||||||
# and pdftk are not installed: Gotenberg only checks at startup that their binaries exist, start-gotenberg.sh disables
|
|
||||||
# the LibreOffice routes and the Chromium route does not use pdftk.
|
|
||||||
COPY --from=gotenberg /usr/bin/gotenberg /usr/local/bin/gotenberg
|
|
||||||
COPY --from=gotenberg /usr/bin/pdfcpu /usr/local/bin/pdfcpu
|
|
||||||
COPY --from=gotenberg /opt/gotenberg /opt/gotenberg
|
|
||||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium \
|
|
||||||
CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data \
|
|
||||||
GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions \
|
|
||||||
QPDF_BIN_PATH=/usr/bin/qpdf \
|
|
||||||
EXIFTOOL_BIN_PATH=/usr/bin/exiftool \
|
|
||||||
PDFCPU_BIN_PATH=/usr/local/bin/pdfcpu \
|
|
||||||
LIBREOFFICE_BIN_PATH=/bin/false \
|
|
||||||
UNOCONVERTER_BIN_PATH=/bin/false \
|
|
||||||
PDFTK_BIN_PATH=/bin/false
|
|
||||||
|
|
||||||
# Database matching .env.ci: user root / password root, database laravel.
|
|
||||||
RUN pg_ctlcluster 17 main start \
|
|
||||||
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
|
|
||||||
&& runuser -u postgres -- createdb -O root laravel \
|
|
||||||
&& pg_ctlcluster 17 main stop
|
|
||||||
|
|
||||||
# scanner contract: the checkout lives inside the image, at /src
|
|
||||||
COPY . /src
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
RUN composer install --prefer-dist \
|
|
||||||
&& npm ci \
|
|
||||||
&& npm run build
|
|
||||||
|
|
||||||
RUN cp .env.ci .env \
|
|
||||||
&& php artisan key:generate \
|
|
||||||
&& php artisan passport:keys --force \
|
|
||||||
&& php artisan octane:install --server=frankenphp --no-interaction \
|
|
||||||
&& chmod +x .oss-scanner/*.sh
|
|
||||||
|
|
||||||
# Run the test suite so the image is known to work, but do not fail the build on test failures.
|
|
||||||
RUN .oss-scanner/start-postgres.sh \
|
|
||||||
&& .oss-scanner/start-gotenberg.sh \
|
|
||||||
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
|
|
||||||
&& kill "$(cat /tmp/gotenberg.pid)" \
|
|
||||||
&& pg_ctlcluster 17 main stop
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Used instead of the root .dockerignore when building .oss-scanner/Dockerfile. The root one is tailored to the
|
|
||||||
# production image and excludes tests, phpunit.xml etc., which the scanner needs.
|
|
||||||
node_modules
|
|
||||||
extensions/*/node_modules
|
|
||||||
vendor
|
|
||||||
.env
|
|
||||||
public/build
|
|
||||||
public/hot
|
|
||||||
storage/*.key
|
|
||||||
storage/logs/*
|
|
||||||
coverage
|
|
||||||
test-results
|
|
||||||
playwright-report
|
|
||||||
.phpunit.cache
|
|
||||||
.phpunit.result.cache
|
|
||||||
auth.json
|
|
||||||
.DS_Store
|
|
||||||
.idea
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Start the local Gotenberg server (PDF rendering) on 127.0.0.1:3000, matching GOTENBERG_URL in .env.ci.
|
|
||||||
# Only the Chromium routes are needed by solidtime, see .oss-scanner/Dockerfile. Stop: kill "$(cat /tmp/gotenberg.pid)"
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if ! curl -fs http://127.0.0.1:3000/health >/dev/null; then
|
|
||||||
nohup gotenberg \
|
|
||||||
--api-port=3000 \
|
|
||||||
--libreoffice-disable-routes \
|
|
||||||
--libreoffice-auto-start=false \
|
|
||||||
--log-level=warn \
|
|
||||||
>/tmp/gotenberg.log 2>&1 &
|
|
||||||
echo $! >/tmp/gotenberg.pid
|
|
||||||
until curl -fs http://127.0.0.1:3000/health >/dev/null; do sleep 0.5; done
|
|
||||||
fi
|
|
||||||
echo "Gotenberg is running on http://127.0.0.1:3000 (log: /tmp/gotenberg.log)"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Run the app the way production does: Laravel Octane on FrankenPHP in worker mode, with the production Caddyfile.
|
|
||||||
# A worker boots the app once and then serves many requests, so state kept in memory (static properties, singletons,
|
|
||||||
# changed config, ...) survives from one request to the next. With a single worker, consecutive requests always hit the
|
|
||||||
# same worker, so such leaks between requests (e.g. between users of different organizations) reproduce reliably.
|
|
||||||
#
|
|
||||||
# Usage: .oss-scanner/start-octane.sh (OCTANE_WORKERS and OCTANE_MAX_REQUESTS can be overridden)
|
|
||||||
# Stop: php artisan octane:stop
|
|
||||||
# After changing PHP code, run `php artisan octane:reload`: workers keep the code they booted with.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
.oss-scanner/start-postgres.sh
|
|
||||||
.oss-scanner/start-gotenberg.sh
|
|
||||||
|
|
||||||
php artisan migrate --force
|
|
||||||
if [ "$(PGPASSWORD=root psql -h 127.0.0.1 -U root -d laravel -tAc 'SELECT count(*) FROM users')" = "0" ]; then
|
|
||||||
php artisan db:seed --force
|
|
||||||
fi
|
|
||||||
php artisan optimize:clear >/dev/null
|
|
||||||
|
|
||||||
nohup php artisan octane:frankenphp \
|
|
||||||
--host=127.0.0.1 \
|
|
||||||
--port=8000 \
|
|
||||||
--workers="${OCTANE_WORKERS:-1}" \
|
|
||||||
--max-requests="${OCTANE_MAX_REQUESTS:-10000}" \
|
|
||||||
--caddyfile=docker/prod/deployment/octane/FrankenPHP/Caddyfile \
|
|
||||||
>/tmp/octane.log 2>&1 &
|
|
||||||
|
|
||||||
until curl -fs -o /dev/null http://127.0.0.1:8000/login; do
|
|
||||||
if ! kill -0 $! 2>/dev/null; then
|
|
||||||
cat /tmp/octane.log
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
echo "solidtime is running on http://127.0.0.1:8000 with Octane/FrankenPHP (log: /tmp/octane.log)"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
pg_ctlcluster 17 main start 2>/dev/null || true
|
|
||||||
until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done
|
|
||||||
echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)"
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Threat model
|
|
||||||
|
|
||||||
## What this project does
|
|
||||||
|
|
||||||
solidtime is an open-source, multi-tenant time tracking web application (Laravel backend, Vue 3 + Inertia frontend,
|
|
||||||
PostgreSQL). It runs as a hosted SaaS (solidtime.io) and is self-hosted by many organisations. Users belong to one or
|
|
||||||
more **organizations**; inside an organization each member has a role: `owner`, `admin`, `manager`, `employee` or
|
|
||||||
`placeholder` (an imported, non-login member). What each role may do is defined in `app/Service/PermissionStore.php`.
|
|
||||||
|
|
||||||
The most important security property is **isolation**: a user must never read or modify data of an organization they
|
|
||||||
are not a member of, and within an organization a member must not exceed the permissions of their role (e.g. an
|
|
||||||
employee must not see other members' time entries, billable rates, or manage members, unless the organization settings
|
|
||||||
explicitly allow it).
|
|
||||||
|
|
||||||
## Trust boundaries
|
|
||||||
|
|
||||||
- **Super admins are fully trusted.** They are the instance operators, configured via the `SUPER_ADMINS` env
|
|
||||||
variable, and have access to the Filament admin panel (`app/Filament`), which can view and change data of every
|
|
||||||
organization and impersonate users. Anything a super admin can do through the panel (including XSS, SQL injection,
|
|
||||||
SSRF or file access that is only reachable from the panel) is not a vulnerability.
|
|
||||||
- What **is** in scope: a user who is not a super admin reaching the admin panel, or any of its actions, at all.
|
|
||||||
- Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted.
|
|
||||||
- Everyone else, including organization owners and admins when acting outside their own organization, is untrusted.
|
|
||||||
|
|
||||||
## Runtime: long-lived Octane workers
|
|
||||||
|
|
||||||
In production (the hosted SaaS and the official Docker image, `docker/prod/`) the app does **not** run as one PHP
|
|
||||||
process per request. It runs on Laravel Octane with FrankenPHP in worker mode: each worker boots the application once
|
|
||||||
and then serves many requests from different users and organizations. Anything kept in memory survives from one request
|
|
||||||
to the next unless Octane resets it (`config/octane.php` lists what is reset). This includes static properties and
|
|
||||||
static caches, container bindings registered with `singleton()` instead of `scoped()` (see
|
|
||||||
`app/Providers/AppServiceProvider.php`), objects captured by those singletons, runtime `config()` / locale / timezone
|
|
||||||
changes, macros and event listeners registered during a request, and state in third-party packages.
|
|
||||||
|
|
||||||
Request A leaving state behind that request B (another user, possibly of another organization) then sees or is
|
|
||||||
affected by is in scope, rated by its impact like any other issue (see severity below). The PHPUnit suite cannot
|
|
||||||
show this class of bug, because it boots a fresh application for every test. It has to be reproduced over HTTP against
|
|
||||||
the Octane server, see "How to exercise it".
|
|
||||||
|
|
||||||
## Where untrusted input enters
|
|
||||||
|
|
||||||
All authenticated users, including employees of any organization and anyone who self-registers (registration is open
|
|
||||||
by default), are untrusted.
|
|
||||||
|
|
||||||
- **JSON API** `routes/api.php` (`/api/v1/...`), authenticated via Passport (session cookie or personal access token).
|
|
||||||
Most routes are scoped by `{organization}` and authorised in the controllers / form requests.
|
|
||||||
- **Public, unauthenticated** endpoints: `GET /api/v1/public/reports` (shared reports, accessed by a secret), login,
|
|
||||||
registration, password reset, email verification, organization invitation acceptance (`routes/web.php`).
|
|
||||||
- **Web / Inertia routes** `routes/web.php` and Fortify/Jetstream actions in `app/Actions`.
|
|
||||||
- **Imports** (`app/Service/Import/Importers`): user-uploaded CSV and ZIP files from Toggl, Clockify, Harvest,
|
|
||||||
generic CSV and solidtime's own export format. ZIP handling is in `ZipImportHelper.php`.
|
|
||||||
- **Exports / reports** (`app/Service/Export`, `app/Service/ReportExport`): CSV/XLSX/ODS and PDF. PDFs are rendered by
|
|
||||||
sending HTML to a Gotenberg (headless Chromium) service, so user-controlled content in that HTML matters.
|
|
||||||
- **OAuth** (Passport) authorization and token endpoints.
|
|
||||||
- **Filament admin panel** (`app/Filament`): only its access control is in scope (see Trust boundaries).
|
|
||||||
|
|
||||||
## Components that matter most / least
|
|
||||||
|
|
||||||
Most important: organization scoping and role checks in the API controllers, form requests (`app/Http/Requests`),
|
|
||||||
`PermissionStore`, public report sharing, invitations and member management (role changes, ownership transfer, member
|
|
||||||
merge), authentication flows (Fortify, 2FA, email change, API tokens), import parsing.
|
|
||||||
|
|
||||||
Less important / out of scope:
|
|
||||||
- `extensions/` is empty in this repository (proprietary modules are not part of the open-source code).
|
|
||||||
- `docker/`, `k8s/`, `e2e/`, `playwright/`, `docs/` and developer tooling.
|
|
||||||
- Third-party dependencies in `vendor/` and `node_modules/`, unless solidtime uses them in an unsafe way.
|
|
||||||
|
|
||||||
## How to exercise it
|
|
||||||
|
|
||||||
- `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`).
|
|
||||||
- `.oss-scanner/start-gotenberg.sh` starts the local Gotenberg server (PDF rendering via headless Chromium) on
|
|
||||||
http://127.0.0.1:3000, so the PDF export code path, including what Chromium does with the rendered HTML, can be
|
|
||||||
exercised.
|
|
||||||
- `php artisan test` runs the PHPUnit suite (start PostgreSQL and Gotenberg first); all tests are expected to pass. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users,
|
|
||||||
organizations and members with factories and call the API with a given role; they are the quickest way to write a
|
|
||||||
reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`.
|
|
||||||
- To run the app like production: `.oss-scanner/start-octane.sh` (http://127.0.0.1:8000). It starts PostgreSQL and
|
|
||||||
Gotenberg, migrates (and seeds an empty database, see `database/seeders/DatabaseSeeder.php` for the users) and runs
|
|
||||||
Octane/FrankenPHP with the production Caddyfile and a **single worker**, so consecutive requests always hit the same
|
|
||||||
worker and leaks between requests reproduce reliably. Workers keep the code they booted with: after changing PHP
|
|
||||||
code run `php artisan octane:reload`. Stop it with `php artisan octane:stop`. Note that the test suite and the app
|
|
||||||
share the same database, so `php artisan test` wipes the app's data.
|
|
||||||
- A reproducer for a leak between requests is a script that sends request A (e.g. as a member of organization X) and
|
|
||||||
then request B (as a user of organization Y) to the running Octane server and shows that B observes A's state. API
|
|
||||||
requests can be authenticated with a personal access token, e.g. created with
|
|
||||||
`php artisan tinker --execute="echo App\Models\User::where('email', '...')->first()->createToken('t')->accessToken;"`.
|
|
||||||
- There is no network: mail delivery is not available (mail uses the `array` driver in tests), and remote resources
|
|
||||||
referenced by PDF templates (e.g. fonts from fonts.bunny.net) fail to load, so PDFs fall back to local fonts.
|
|
||||||
|
|
||||||
## How we rate severity
|
|
||||||
|
|
||||||
- **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution;
|
|
||||||
SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a
|
|
||||||
member of, including through state leaking between requests in an Octane worker.
|
|
||||||
- **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only
|
|
||||||
actions); access to data the role must not see (other members' time entries, billable rates, member emails) when
|
|
||||||
the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF
|
|
||||||
rendering or imports; account takeover requiring user interaction.
|
|
||||||
- **Medium**: information disclosure with limited impact, CSRF on state-changing endpoints, issues requiring an
|
|
||||||
unusual but realistic configuration, denial of service by a single authenticated request (e.g. pathological
|
|
||||||
import file).
|
|
||||||
- **Low**: everything else with real security impact.
|
|
||||||
|
|
||||||
## Anything to leave alone
|
|
||||||
|
|
||||||
Please do not report (see also `SECURITY.md`):
|
|
||||||
- Theoretical findings without a working reproducer.
|
|
||||||
- Missing or weak security headers in isolation; TLS / mail DNS configuration.
|
|
||||||
- Self-XSS; CSRF on non-state-changing endpoints (logout, theme).
|
|
||||||
- CSV / spreadsheet formula injection in exports.
|
|
||||||
- Owners or admins acting destructively within their own organization.
|
|
||||||
- Anything requiring direct DB, shell or filesystem access on a self-hosted instance.
|
|
||||||
- Anything that requires being a super admin, including issues inside the Filament admin panel.
|
|
||||||
- Missing OAuth scope enforcement (not implemented yet).
|
|
||||||
- Rate-limit tuning and generic DoS through volume of requests.
|
|
||||||
|
|
||||||
## Reports and patches
|
|
||||||
|
|
||||||
Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of
|
|
||||||
`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue (for leaks between requests: a script against
|
|
||||||
`.oss-scanner/start-octane.sh` instead), and a minimal patch that follows the existing patterns
|
|
||||||
(authorisation in form requests/controllers via `PermissionStore`).
|
|
||||||
@@ -5,12 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Providers\FortifyServiceProvider;
|
|
||||||
use Illuminate\Auth\Passwords\PasswordBroker;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
class ResetUserPassword implements ResetsUserPasswords
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
@@ -24,16 +20,6 @@ class ResetUserPassword implements ResetsUserPasswords
|
|||||||
*/
|
*/
|
||||||
public function reset(User $user, array $input): void
|
public function reset(User $user, array $input): void
|
||||||
{
|
{
|
||||||
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
|
|
||||||
/** @var PasswordBroker $broker */
|
|
||||||
$broker = Password::broker(config('fortify.passwords'));
|
|
||||||
$broker->deleteToken($user);
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => [__('This password reset link is invalid.')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class TimeEntryMemberCanNotBeChangedWhileRunningApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'time_entry_member_can_not_be_changed_while_running';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
|||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
||||||
@@ -51,6 +52,7 @@ use Illuminate\Support\Carbon;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -613,7 +615,24 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:create:all');
|
$this->checkPermission($organization, 'time-entries:create:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
|
// Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry
|
||||||
|
$lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null;
|
||||||
|
$lock?->block(5);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->storeTimeEntry($organization, $member, $request);
|
||||||
|
} finally {
|
||||||
|
$lock?->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws TimeEntryStillRunningApiException
|
||||||
|
*/
|
||||||
|
private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource
|
||||||
|
{
|
||||||
|
// A user can only have one running time entry, across all organizations
|
||||||
|
if ($request->input('end') === null && TimeEntry::query()->where('user_id', $member->user_id)->whereNull('end')->exists()) {
|
||||||
throw new TimeEntryStillRunningApiException;
|
throw new TimeEntryStillRunningApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +669,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
|
||||||
*
|
*
|
||||||
* @operationId updateTimeEntry
|
* @operationId updateTimeEntry
|
||||||
*/
|
*/
|
||||||
@@ -676,6 +695,11 @@ class TimeEntryController extends Controller
|
|||||||
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
||||||
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
||||||
|
|
||||||
|
// The new member could already have a running time entry
|
||||||
|
if ($effectiveEnd === null && $newMember !== null && $newMember->getKey() !== $timeEntry->member_id) {
|
||||||
|
throw new TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
|
}
|
||||||
|
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
@@ -801,6 +825,12 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The new member could already have a running time entry
|
||||||
|
if ($newMember !== null && $timeEntry->end === null && $newMember->getKey() !== $timeEntry->member_id) {
|
||||||
|
$error->push($id);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
|
|||||||
@@ -160,23 +160,13 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSuperAdmin(): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return $this->is_placeholder === false
|
return $this->is_placeholder === false
|
||||||
&& in_array($this->email, config('auth.super_admins', []), true)
|
&& in_array($this->email, config('auth.super_admins', []), true)
|
||||||
&& $this->hasVerifiedEmail();
|
&& $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasLocalPassword(): bool
|
|
||||||
{
|
|
||||||
return is_string($this->password) && $this->password !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
|
||||||
{
|
|
||||||
return $this->isSuperAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isMemberOfOrganization(Organization $organization): bool
|
public function isMemberOfOrganization(Organization $organization): bool
|
||||||
{
|
{
|
||||||
if ($this->relationLoaded('organizations')) {
|
if ($this->relationLoaded('organizations')) {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ use Illuminate\Session\Middleware\StartSession;
|
|||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
use Nwidart\Modules\Facades\Module;
|
use Nwidart\Modules\Facades\Module;
|
||||||
use Nwidart\Modules\Laravel\Module as LaravelModule;
|
|
||||||
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
@@ -94,77 +93,22 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
$modules = Module::allEnabled();
|
$modules = Module::allEnabled();
|
||||||
|
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
$moduleNamespace = $this->getModuleAppNamespace($module);
|
|
||||||
|
|
||||||
$panel->discoverResources(
|
$panel->discoverResources(
|
||||||
in: module_path($module->getName(), 'app/Filament/Resources'),
|
in: module_path($module->getName(), 'app/Filament/Resources'),
|
||||||
for: $moduleNamespace.'\\Filament\\Resources'
|
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
|
||||||
);
|
);
|
||||||
|
|
||||||
$panel->discoverPages(
|
$panel->discoverPages(
|
||||||
in: module_path($module->getName(), 'app/Filament/Pages'),
|
in: module_path($module->getName(), 'app/Filament/Pages'),
|
||||||
for: $moduleNamespace.'\\Filament\\Pages'
|
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
|
||||||
);
|
);
|
||||||
|
|
||||||
$panel->discoverWidgets(
|
$panel->discoverWidgets(
|
||||||
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
||||||
for: $moduleNamespace.'\\Filament\\Widgets'
|
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var array<string, string> Cache of module name => resolved app namespace. */
|
|
||||||
private static array $moduleAppNamespaces = [];
|
|
||||||
|
|
||||||
private function getModuleAppNamespace(LaravelModule $module): string
|
|
||||||
{
|
|
||||||
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the PHP namespace mapped to a module's app/ directory so the
|
|
||||||
* Filament panel can discover its Resources/Pages/Widgets under the right
|
|
||||||
* namespace.
|
|
||||||
*
|
|
||||||
* Two module layouts currently coexist in this repo:
|
|
||||||
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
|
|
||||||
* app/ — e.g. "Extensions\SSO\" => app/, so classes are
|
|
||||||
* Extensions\SSO\Filament\... (this is the current convention).
|
|
||||||
* - the older layout: an "...\App" namespace maps to app/ — e.g.
|
|
||||||
* "Extensions\Billing\App\" => app/, so classes are
|
|
||||||
* Extensions\Billing\App\Filament\...
|
|
||||||
*
|
|
||||||
* The package's own namespace derivation assumes the v12 (bare) layout and
|
|
||||||
* would mis-resolve the legacy modules, so we read each module's composer
|
|
||||||
* PSR-4 map and use whichever namespace actually points at app/. The legacy
|
|
||||||
* "...\App" shape is only a fallback for when composer is missing/unreadable.
|
|
||||||
* Once every module adopts the bare layout this collapses to
|
|
||||||
* config('modules.namespace').'\\'.$module->getName().
|
|
||||||
*/
|
|
||||||
private function resolveModuleAppNamespace(LaravelModule $module): string
|
|
||||||
{
|
|
||||||
$fallback = 'Extensions\\'.$module->getName().'\\App';
|
|
||||||
|
|
||||||
$composerPath = module_path($module->getName(), 'composer.json');
|
|
||||||
$psr4 = [];
|
|
||||||
if (is_file($composerPath)) {
|
|
||||||
$composer = json_decode((string) file_get_contents($composerPath), true);
|
|
||||||
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ((array) $psr4 as $namespace => $path) {
|
|
||||||
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
|
|
||||||
return rtrim($namespace, '\\');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeComposerPath(mixed $path): string
|
|
||||||
{
|
|
||||||
return trim(str_replace('\\', '/', (string) $path), '/');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,73 +25,6 @@ use Laravel\Fortify\Fortify;
|
|||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Dummy bcrypt hash compared against when no user matches the submitted
|
|
||||||
* email. Hash::check is run against it so login takes the same time whether
|
|
||||||
* or not the email exists — otherwise an unknown email would skip the
|
|
||||||
* (deliberately slow) hash and return faster, letting an attacker enumerate
|
|
||||||
* registered accounts by timing the response. The plaintext is irrelevant:
|
|
||||||
* it is only ever checked against attacker-supplied input and never matches.
|
|
||||||
*/
|
|
||||||
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization rules applied AFTER the password is verified. Each rule
|
|
||||||
* receives the authenticated user + request and returns whether the login
|
|
||||||
* may proceed; any rule returning false denies it. This is an extension
|
|
||||||
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
|
|
||||||
* instead of replacing this credential check — which would silently drift
|
|
||||||
* from the host logic the next time it changes.
|
|
||||||
*
|
|
||||||
* @var array<int, \Closure(User, Request): bool>
|
|
||||||
*/
|
|
||||||
protected static array $loginRules = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization rules applied before a password reset is completed. Rules
|
|
||||||
* receive the user being reset + submitted input and return whether the
|
|
||||||
* local reset flow may set a new password for that account.
|
|
||||||
*
|
|
||||||
* @var array<int, \Closure(User, array<string, mixed>): bool>
|
|
||||||
*/
|
|
||||||
protected static array $passwordResetRules = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an additional rule that gates password login (see $loginRules).
|
|
||||||
*
|
|
||||||
* @param \Closure(User, Request): bool $rule
|
|
||||||
*/
|
|
||||||
public static function authenticateUsingRule(\Closure $rule): void
|
|
||||||
{
|
|
||||||
static::$loginRules[] = $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an additional rule that gates password reset completion.
|
|
||||||
*
|
|
||||||
* @param \Closure(User, array<string, mixed>): bool $rule
|
|
||||||
*/
|
|
||||||
public static function resetPasswordUsingRule(\Closure $rule): void
|
|
||||||
{
|
|
||||||
static::$passwordResetRules[] = $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether the given user may complete the local password reset flow.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $input
|
|
||||||
*/
|
|
||||||
public static function canResetPassword(User $user, array $input = []): bool
|
|
||||||
{
|
|
||||||
foreach (static::$passwordResetRules as $rule) {
|
|
||||||
if (! $rule($user, $input)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register any application services.
|
* Register any application services.
|
||||||
*/
|
*/
|
||||||
@@ -160,23 +93,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
->where('is_placeholder', '=', false)
|
->where('is_placeholder', '=', false)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
// Always run the hash check — against the real hash, or a dummy when
|
if ($user !== null && Hash::check($request->password, $user->password)) {
|
||||||
// there is no user — so login timing is identical either way (see
|
|
||||||
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
|
|
||||||
// have password = null) fail here, so they cannot password-login.
|
|
||||||
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
|
|
||||||
|
|
||||||
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
|
|
||||||
|
|
||||||
if ($user !== null && $passwordIsValid) {
|
|
||||||
// Credentials are valid; now apply any registered authorization
|
|
||||||
// rules (e.g. SSO enforcement may still block password login).
|
|
||||||
foreach (static::$loginRules as $rule) {
|
|
||||||
if (! $rule($user, $request)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
|
|
||||||
// Time entries
|
// Time entries
|
||||||
foreach ($timeEntriesReader as $timeEntryRow) {
|
foreach ($timeEntriesReader as $timeEntryRow) {
|
||||||
|
// Running time entries are skipped, since the user could already have a running time entry
|
||||||
|
if ($timeEntryRow['end'] === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
|
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
|
||||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
|
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
$member = $this->memberImportHelper->getModelById($memberId);
|
||||||
@@ -271,19 +275,15 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$timeEntry->start = $start->utc();
|
$timeEntry->start = $start->utc();
|
||||||
|
|
||||||
if ($timeEntryRow['end'] !== '') {
|
try {
|
||||||
try {
|
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
|
||||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
|
} catch (InvalidFormatException) {
|
||||||
} catch (InvalidFormatException) {
|
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
|
||||||
}
|
|
||||||
if ($end === null) {
|
|
||||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
|
||||||
}
|
|
||||||
$timeEntry->end = $end->utc();
|
|
||||||
} else {
|
|
||||||
$timeEntry->end = null;
|
|
||||||
}
|
}
|
||||||
|
if ($end === null) {
|
||||||
|
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||||
|
}
|
||||||
|
$timeEntry->end = $end->utc();
|
||||||
|
|
||||||
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
|
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ class MemberService
|
|||||||
|
|
||||||
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
||||||
{
|
{
|
||||||
|
// Running time entries are deleted instead of moved, since $toMember could already have a running time entry
|
||||||
|
TimeEntry::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereBelongsTo($fromMember, 'member')
|
||||||
|
->whereNull('end')
|
||||||
|
->delete();
|
||||||
|
|
||||||
// Time entries
|
// Time entries
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
|||||||
@@ -49,56 +49,6 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$this->createDefaultOrganizationForUser(
|
|
||||||
$user,
|
|
||||||
$currency,
|
|
||||||
$numberFormat,
|
|
||||||
$currencyFormat,
|
|
||||||
$dateFormat,
|
|
||||||
$intervalFormat,
|
|
||||||
$timeFormat,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a user without a password (e.g. provisioned via SSO). Such users
|
|
||||||
* can only authenticate through a linked identity provider.
|
|
||||||
*/
|
|
||||||
public function createPasswordlessUser(
|
|
||||||
string $name,
|
|
||||||
string $email,
|
|
||||||
string $timezone,
|
|
||||||
Weekday $weekStart,
|
|
||||||
?string $currency,
|
|
||||||
bool $verifyEmail = false
|
|
||||||
): User {
|
|
||||||
$user = new User;
|
|
||||||
$user->name = $name;
|
|
||||||
$user->email = strtolower($email);
|
|
||||||
$user->password = null;
|
|
||||||
$user->timezone = $timezone;
|
|
||||||
$user->week_start = $weekStart;
|
|
||||||
if ($verifyEmail) {
|
|
||||||
$user->email_verified_at = Carbon::now();
|
|
||||||
}
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
$this->createDefaultOrganizationForUser($user, $currency);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createDefaultOrganizationForUser(
|
|
||||||
User $user,
|
|
||||||
?string $currency,
|
|
||||||
?NumberFormat $numberFormat = null,
|
|
||||||
?CurrencyFormat $currencyFormat = null,
|
|
||||||
?DateFormat $dateFormat = null,
|
|
||||||
?IntervalFormat $intervalFormat = null,
|
|
||||||
?TimeFormat $timeFormat = null,
|
|
||||||
): void {
|
|
||||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
|
|
||||||
if ($organizations->isEmpty()) {
|
if ($organizations->isEmpty()) {
|
||||||
@@ -115,6 +65,8 @@ class UserService
|
|||||||
);
|
);
|
||||||
$this->switchCurrentOrganization($user, $organization);
|
$this->switchCurrentOrganization($user, $organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.10"
|
"ref": "v0.0.9"
|
||||||
},
|
},
|
||||||
"Auditing": {
|
"Auditing": {
|
||||||
"repository": "solidtime-io/extension-auditing",
|
"repository": "solidtime-io/extension-auditing",
|
||||||
"ref": "v0.0.5"
|
"ref": "v0.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
@@ -31,6 +32,7 @@ return [
|
|||||||
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
||||||
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||||
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
||||||
|
TimeEntryMemberCanNotBeChangedWhileRunningApiException::KEY => 'The member of a running time entry can not be changed',
|
||||||
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
||||||
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
||||||
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
||||||
|
|||||||
@@ -7,16 +7,10 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
|||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
|
|
||||||
withDefaults(
|
defineProps({
|
||||||
defineProps<{
|
canResetPassword: Boolean,
|
||||||
canResetPassword?: boolean;
|
status: String,
|
||||||
status?: string;
|
});
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
canResetPassword: false,
|
|
||||||
status: '',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -34,8 +28,8 @@ const submit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
flash?: {
|
flash: {
|
||||||
message?: string;
|
message: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -67,9 +61,6 @@ const page = usePage<{
|
|||||||
{{ page.props.flash?.message }}
|
{{ page.props.flash?.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
|
|
||||||
<slot name="error" />
|
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="email">Email</FieldLabel>
|
<FieldLabel for="email">Email</FieldLabel>
|
||||||
@@ -112,8 +103,5 @@ const page = usePage<{
|
|||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
|
|
||||||
<slot name="alternatives" />
|
|
||||||
</AuthenticationCard>
|
</AuthenticationCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,73 +10,35 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
|||||||
import { type DefineComponent } from 'vue';
|
import { type DefineComponent } from 'vue';
|
||||||
import { setupPrefetching } from '@/utils/prefetch';
|
import { setupPrefetching } from '@/utils/prefetch';
|
||||||
|
|
||||||
interface ExtensionManifest {
|
|
||||||
name?: string;
|
|
||||||
alias?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
|
|
||||||
eager: true,
|
|
||||||
import: 'default',
|
|
||||||
}) as Record<string, ExtensionManifest>;
|
|
||||||
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
|
|
||||||
const extensionPages = import.meta.glob<DefineComponent>([
|
|
||||||
'../../extensions/**/resources/js/Pages/**/*.vue',
|
|
||||||
'!**/BillingPortal.vue',
|
|
||||||
]);
|
|
||||||
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
|
|
||||||
(directories, [path, manifest]) => {
|
|
||||||
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
|
|
||||||
const extensionDirectory = match?.[1];
|
|
||||||
|
|
||||||
if (extensionDirectory === undefined) {
|
|
||||||
return directories;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
|
|
||||||
if (typeof key !== 'string' || key === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
directories[key] = extensionDirectory;
|
|
||||||
directories[key.toLowerCase()] = extensionDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
return directories;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
function resolveExtensionDirectory(moduleName: string): string {
|
|
||||||
return (
|
|
||||||
extensionDirectories[moduleName] ??
|
|
||||||
extensionDirectories[moduleName.toLowerCase()] ??
|
|
||||||
moduleName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => `${title} - ${appName}`,
|
title: (title) => `${title} - ${appName}`,
|
||||||
resolve: (name) => {
|
resolve: (name) => {
|
||||||
// "Module::Page" (both halves present) resolves to that extension's page
|
if (name.includes('Invoicing::')) {
|
||||||
// directory; everything else is a host page under resources/js/Pages.
|
const [module, page] = name.split('::');
|
||||||
const [module, ...pageSegments] = name.split('::');
|
|
||||||
const page = pageSegments.join('::');
|
|
||||||
|
|
||||||
if (module && page) {
|
const pagePath = module
|
||||||
const extensionDirectory = resolveExtensionDirectory(module);
|
? `../../extensions/${module}/resources/js/Pages/${page}.vue`
|
||||||
const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
|
: `./Pages/${page}.vue`;
|
||||||
|
|
||||||
return resolvePageComponent(pagePath, extensionPages);
|
// BillingPortal is a Vue 2 Component and therefore should not be imported
|
||||||
|
const pages = module
|
||||||
|
? import.meta.glob<DefineComponent>([
|
||||||
|
'../../extensions/**/resources/js/Pages/*.vue',
|
||||||
|
'!**/BillingPortal.vue',
|
||||||
|
])
|
||||||
|
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
|
||||||
|
|
||||||
|
return resolvePageComponent(pagePath, pages);
|
||||||
|
} else {
|
||||||
|
return resolvePageComponent(
|
||||||
|
`./Pages/${name}.vue`,
|
||||||
|
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvePageComponent(
|
|
||||||
`./Pages/${name}.vue`,
|
|
||||||
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
setup({ el, App, props, plugin }) {
|
setup({ el, App, props, plugin }) {
|
||||||
const app = createApp({ render: () => h(App, props) });
|
const app = createApp({ render: () => h(App, props) });
|
||||||
|
|||||||
3
resources/testfiles/solidtime_import_test_3/clients.csv
Normal file
3
resources/testfiles/solidtime_import_test_3/clients.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,organization_id,archived_at,created_at,updated_at
|
||||||
|
b4187a44-41f4-46d7-8460-f15a25b3aad6,"Big Company",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,"Other Company (Archived)",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
2
resources/testfiles/solidtime_import_test_3/members.csv
Normal file
2
resources/testfiles/solidtime_import_test_3/members.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id,user_id,name,email,organization_id,billable_rate,role,created_at,updated_at
|
||||||
|
06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,"Peter Tester",peter.test@email.test,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
1
resources/testfiles/solidtime_import_test_3/meta.json
Normal file
1
resources/testfiles/solidtime_import_test_3/meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"id":"d6a324ee-58d5-4096-8069-c63bd55608f7","version":"1.0","organizations":["ee5a8cd6-312f-4ae6-b044-e2014f09ecc2"],"exported_at":"2024-08-26T18:21:59Z"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
id,email,organization_id,role,created_at,updated_at
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
id,name,billable_rate,currency,created_at,updated_at
|
||||||
|
ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,"ACME Corp",,EUR,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
id,billable_rate,project_id,user_id,member_id,created_at,updated_at
|
||||||
|
180a1a98-2f1c-4596-86e4-63a6be0d7b1d,10002,06e79ec4-33f8-4730-804c-d03c014991d1,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,06e6e605-86bd-417b-b75d-02f671e5d520,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
4
resources/testfiles/solidtime_import_test_3/projects.csv
Normal file
4
resources/testfiles/solidtime_import_test_3/projects.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id,name,color,billable_rate,is_public,client_id,organization_id,is_billable,archived_at,created_at,updated_at
|
||||||
|
06e79ec4-33f8-4730-804c-d03c014991d1,"Project for Big Company",#ec407a,10001,false,b4187a44-41f4-46d7-8460-f15a25b3aad6,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
622c74a9-7e64-44c2-9426-2a37ac738206,"Project without Client",#ef5350,,false,,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
aa831162-dbb2-4cfe-bfe0-5e3a252c66f0,"Project (Archived)",#6a407f,,true,e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,2024-08-25T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
3
resources/testfiles/solidtime_import_test_3/tags.csv
Normal file
3
resources/testfiles/solidtime_import_test_3/tags.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,organization_id,created_at,updated_at
|
||||||
|
2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7,Development,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
bf6c0ac5-2587-474b-8983-40bb3ea8002f,Backend,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
3
resources/testfiles/solidtime_import_test_3/tasks.csv
Normal file
3
resources/testfiles/solidtime_import_test_3/tasks.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,project_id,organization_id,done_at,created_at,updated_at
|
||||||
|
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 1",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 2",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-24T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
id,description,start,end,billable_rate,billable,type,member_id,user_id,organization_id,client_id,project_id,task_id,tags,is_imported,still_active_email_sent_at,created_at,updated_at
|
||||||
|
00aae3be-18fc-462d-bee4-350fb605b2f3,,2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,,false,,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,"[""2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7"",""bf6c0ac5-2587-474b-8983-40bb3ea8002f""]",false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
1c7a905d-aa12-4d08-bc41-7e92577e7cdf,"Working hard",2024-03-04T09:23:00Z,2024-03-04T10:23:01Z,,true,work,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,b4187a44-41f4-46d7-8460-f15a25b3aad6,06e79ec4-33f8-4730-804c-d03c014991d1,b49688a0-94f3-4cb3-9ca1-5003de955fb0,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
9f2f5b83-3a6b-4a9c-8d21-4f1e5cf1b7aa,"Lunch break",2024-03-04T12:00:00Z,2024-03-04T12:30:00Z,,false,break,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
5d0e8c4a-7b1f-4e2a-9c3d-8a6b2f1e4c7d,"Still running",2024-03-04T13:00:00Z,,,true,work,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||||
|
@@ -390,6 +390,40 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_merge_into_deletes_running_time_entries_of_source_member(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'members:merge-into',
|
||||||
|
]);
|
||||||
|
$userSource = User::factory()->placeholder()->create();
|
||||||
|
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
||||||
|
$runningTimeEntrySource = TimeEntry::factory()->forOrganization($data->organization)->forMember($memberSource)->active()->create();
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forMember($memberSource)->createMany(2);
|
||||||
|
|
||||||
|
$userDestination = User::factory()->create();
|
||||||
|
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
|
||||||
|
$runningTimeEntryDestination = TimeEntry::factory()->forOrganization($data->organization)->forMember($memberDestination)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
||||||
|
'member_id' => $memberDestination->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(204);
|
||||||
|
$this->assertDatabaseMissing(Member::class, [
|
||||||
|
'id' => $memberSource->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing(TimeEntry::class, [
|
||||||
|
'id' => $runningTimeEntrySource->getKey(),
|
||||||
|
]);
|
||||||
|
$memberDestination->refresh();
|
||||||
|
$this->assertCount(3, $memberDestination->timeEntries);
|
||||||
|
$this->assertSame([$runningTimeEntryDestination->getKey()], TimeEntry::query()->whereBelongsTo($userDestination, 'user')->whereNull('end')->pluck('id')->all());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
|
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ use App\Models\Task;
|
|||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -2444,6 +2446,77 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('error', true);
|
$response->assertJsonPath('error', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_fails_if_user_already_has_active_time_entry_in_another_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
$otherOrganization = Organization::factory()->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($data->user)->create();
|
||||||
|
TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'start' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'end' => null,
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertJsonPath('key', 'time_entry_still_running');
|
||||||
|
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($data->organization, 'organization')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_releases_running_time_entry_lock_after_request(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'start' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'end' => null,
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$this->assertTrue(Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_waits_for_running_time_entry_lock_and_fails_if_it_is_not_released(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get();
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'start' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'end' => null,
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
$this->fail('Expected LockTimeoutException');
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame(0, TimeEntry::query()->count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
|
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -3164,6 +3237,80 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_fails_if_member_of_running_time_entry_is_changed(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
]);
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertJsonPath('key', 'time_entry_member_can_not_be_changed_while_running');
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $timeEntry->getKey(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
'user_id' => $data->user->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_can_update_running_time_entry_with_unchanged_member_id(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
'description' => 'New description',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $timeEntry->getKey(),
|
||||||
|
'description' => 'New description',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_can_stop_running_time_entry_and_change_member_in_the_same_request(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
]);
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $timeEntry->getKey(),
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
'user_id' => $otherUser->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -4031,6 +4178,51 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_multiple_does_not_change_member_of_running_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
]);
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
|
||||||
|
$runningTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
$runningTimeEntryOfOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->active()->create();
|
||||||
|
$finishedTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||||
|
'ids' => [
|
||||||
|
$runningTimeEntry->getKey(),
|
||||||
|
$runningTimeEntryOfOtherMember->getKey(),
|
||||||
|
$finishedTimeEntry->getKey(),
|
||||||
|
],
|
||||||
|
'changes' => [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'success' => [
|
||||||
|
$runningTimeEntryOfOtherMember->getKey(),
|
||||||
|
$finishedTimeEntry->getKey(),
|
||||||
|
],
|
||||||
|
'error' => [
|
||||||
|
$runningTimeEntry->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $runningTimeEntry->getKey(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $finishedTimeEntry->getKey(),
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
@@ -53,6 +54,7 @@ class ApiExceptionTest extends TestCase
|
|||||||
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
|
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
|
||||||
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
|
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
|
||||||
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
|
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
|
||||||
|
yield TimeEntryMemberCanNotBeChangedWhileRunningApiException::class => [new TimeEntryMemberCanNotBeChangedWhileRunningApiException];
|
||||||
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
|
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
|
||||||
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
|
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
|
||||||
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];
|
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];
|
||||||
|
|||||||
@@ -140,6 +140,31 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame([], $breakEntry->tags);
|
$this->assertSame([], $breakEntry->tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_skips_running_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createTestZip('solidtime_import_test_3');
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new SolidtimeImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
Queue::fake([
|
||||||
|
RecalculateSpentTimeForProject::class,
|
||||||
|
RecalculateSpentTimeForTask::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
$report = $importer->getReport();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame(3, $report->timeEntriesCreated);
|
||||||
|
$this->assertSame(3, TimeEntry::query()->count());
|
||||||
|
$this->assertSame(0, TimeEntry::query()->whereNull('end')->count());
|
||||||
|
$this->assertNull(TimeEntry::query()->where('description', 'Still running')->first());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_twice_succeeds(): void
|
public function test_import_of_test_file_twice_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -228,6 +228,30 @@ class MemberServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_assign_organization_entities_to_different_member_deletes_running_time_entries_of_the_member(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$fromUser = User::factory()->create();
|
||||||
|
$toUser = User::factory()->create();
|
||||||
|
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
|
||||||
|
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
|
||||||
|
$runningTimeEntryOfFromMember = TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->active()->create();
|
||||||
|
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(2);
|
||||||
|
$runningTimeEntryOfToMember = TimeEntry::factory()->forOrganization($organization)->forMember($toUserMember)->active()->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertDatabaseMissing(TimeEntry::class, [
|
||||||
|
'id' => $runningTimeEntryOfFromMember->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
|
||||||
|
$this->assertSame([$runningTimeEntryOfToMember->getKey()], TimeEntry::query()->whereBelongsTo($toUser, 'user')->whereNull('end')->pluck('id')->all());
|
||||||
|
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
|
public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service;
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Enums\Weekday;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
@@ -137,60 +135,4 @@ class UserServiceTest extends TestCase
|
|||||||
$this->assertSame(Role::Owner->value, $newMember->role);
|
$this->assertSame(Role::Owner->value, $newMember->role);
|
||||||
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
|
|
||||||
{
|
|
||||||
// Arrange — an accepted invitation exists for the email (e.g. the user
|
|
||||||
// followed the invite link, then signs up via SSO). Casing differs to
|
|
||||||
// prove the email is normalised before the invitation is matched.
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
OrganizationInvitation::factory()
|
|
||||||
->forOrganization($organization)
|
|
||||||
->role(Role::Employee)
|
|
||||||
->accepted()
|
|
||||||
->create([
|
|
||||||
'email' => 'invitee@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$user = $this->userService->createPasswordlessUser(
|
|
||||||
'Invitee',
|
|
||||||
'Invitee@Example.com',
|
|
||||||
'UTC',
|
|
||||||
Weekday::Monday,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert — invitation is materialised, no personal organization is created
|
|
||||||
$this->assertNull($user->password);
|
|
||||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
|
||||||
'email' => 'invitee@example.com',
|
|
||||||
]);
|
|
||||||
$user->refresh();
|
|
||||||
$this->assertSame(1, $user->organizations()->count());
|
|
||||||
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
|
|
||||||
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
|
||||||
$this->assertSame(Role::Employee->value, $member->role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
$user = $this->userService->createPasswordlessUser(
|
|
||||||
'Solo User',
|
|
||||||
'solo@example.com',
|
|
||||||
'UTC',
|
|
||||||
Weekday::Monday,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert — a personal organization is created, owned by the user and set current
|
|
||||||
$user->refresh();
|
|
||||||
$this->assertNull($user->password);
|
|
||||||
$this->assertSame(1, $user->organizations()->count());
|
|
||||||
$organization = $user->organizations()->first();
|
|
||||||
$this->assertTrue($organization->personal_team);
|
|
||||||
$this->assertSame($user->getKey(), $organization->user_id);
|
|
||||||
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user