Compare commits

..

4 Commits

Author SHA1 Message Date
Gregor Vostrak
595132ec2c fix diagnostics smoke test 2026-05-12 15:15:40 +02:00
Gregor Vostrak
0659cb3993 fix smoke test permissions 2026-05-11 20:15:21 +02:00
Gregor Vostrak
e8fc6fd77e removed SOLIDTIME_DROP_PRIVILEGES always option 2026-05-11 20:10:37 +02:00
Gregor Vostrak
a04185921d improve self-hosting permission handling 2026-05-11 19:08:55 +02:00
17 changed files with 994 additions and 85 deletions

258
.github/workflows/image-smoke-test.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
name: Image Smoke Tests
on:
pull_request:
paths:
- 'docker/prod/**'
- '.github/workflows/image-smoke-test.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
smoke:
name: Smoke (${{ matrix.mode }})
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
mode:
- default
- puid-pgid
- openshift
- drop-never
- diagnostic
- puid-mismatch-warning
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Copy .env template
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: Composer install
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: NPM ci
run: npm ci
- name: NPM build
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build smoke image
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
load: true
tags: solidtime-smoke:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Smoke: default (image config + fresh deploy with empty bind mounts)"
if: matrix.mode == 'default'
run: |
echo "[smoke] image's default USER is root (entrypoint needs root to drop privs)"
user=$(docker inspect --format '{{.Config.User}}' solidtime-smoke:test)
if [ "$user" != "root" ]; then
echo "Expected 'root', got '$user'. The Dockerfile must end with USER root so the entrypoint can chown/usermod and drop privileges."
exit 1
fi
echo "[smoke] storage tree is group-0 owned (OpenShift / arbitrary-UID compat)"
group=$(docker run --rm --entrypoint stat solidtime-smoke:test -c '%g' /var/www/html/storage)
if [ "$group" != "0" ]; then
echo "Expected group 0, got '$group'. The Dockerfile must chgrp -R 0 storage bootstrap/cache so arbitrary-UID containers can write."
exit 1
fi
mkdir -p test-storage test-cache
docker run --rm \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] framework subdirs exist"
test -d /var/www/html/storage/framework/cache/data
test -d /var/www/html/storage/framework/sessions
test -d /var/www/html/storage/framework/views
test -d /var/www/html/storage/framework/testing
test -d /var/www/html/storage/logs
test -d /var/www/html/storage/app/public
test -d /var/www/html/storage/app/private
test -d /var/www/html/bootstrap/cache
echo "[smoke] storage is writable"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] running as octane (UID 1000)"
[ "$(id -u)" = "1000" ]
echo "[smoke] PASS"
'
- name: "Smoke: PUID/PGID remap"
if: matrix.mode == 'puid-pgid'
run: |
mkdir -p test-storage test-cache
sudo chown -R 1501:1501 test-storage test-cache
docker run --rm \
-e PUID=1501 -e PGID=1501 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as remapped UID/GID 1501"
[ "$(id -u)" = "1501" ]
[ "$(id -g)" = "1501" ]
echo "[smoke] storage is writable as 1501"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: OpenShift / arbitrary UID + group 0"
if: matrix.mode == 'openshift'
run: |
mkdir -p test-storage test-cache
sudo chown -R 2000:0 test-storage test-cache
sudo chmod -R g+rwX test-storage test-cache
docker run --rm --user 2000:0 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as arbitrary UID 2000, group 0"
[ "$(id -u)" = "2000" ]
[ "$(id -g)" = "0" ]
echo "[smoke] storage is writable via group 0"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: SOLIDTIME_DROP_PRIVILEGES=never (run as root)"
if: matrix.mode == 'drop-never'
run: |
mkdir -p test-storage test-cache
docker run --rm \
-e SOLIDTIME_DROP_PRIVILEGES=never \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as root (privilege drop disabled)"
[ "$(id -u)" = "0" ]
echo "[smoke] bootstrap still ran"
test -d /var/www/html/storage/framework/cache/data
echo "[smoke] storage writable as root"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: PUID set + started non-root prints a warning but continues"
if: matrix.mode == 'puid-mismatch-warning'
run: |
mkdir -p test-storage test-cache
sudo chown -R 1500:1500 test-storage test-cache
set +e
docker run --rm \
--user 1500:1500 \
-e PUID=1500 -e PGID=1500 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as 1500 (user: directive wins)"
[ "$(id -u)" = "1500" ]
echo "[smoke] storage is writable as 1500"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] container completed successfully"
' \
>stdout.log 2>stderr.log
exit_code=$?
set -e
echo "[smoke] exit code: $exit_code"
echo "--- stderr ---"
cat stderr.log
echo "--- end stderr ---"
if [ "$exit_code" -ne 0 ]; then
echo "Expected the entrypoint to continue (warning is non-fatal)."
exit 1
fi
for needle in "PUID/PGID is set but the container started as UID" "remove any 'user:' directive" "Continuing as UID"; do
if ! grep -q "$needle" stderr.log; then
echo "Missing warning fragment: $needle"
exit 1
fi
done
echo "[smoke] PASS"
- name: "Smoke: diagnostic error path (read-only storage mount)"
if: matrix.mode == 'diagnostic'
run: |
# Pre-create the full storage tree on the host so the entrypoint's
# bootstrap_storage_tree() is a no-op (mkdir -p on existing dirs
# returns 0 even on a read-only mount). The write test then fires
# against the RO mount and triggers our diagnostic.
mkdir -p test-storage/framework/cache/data \
test-storage/framework/sessions \
test-storage/framework/views \
test-storage/framework/testing \
test-storage/logs \
test-storage/app/public \
test-storage/app/private \
test-cache
set +e
docker run --rm \
-v "$(pwd)/test-storage:/var/www/html/storage:ro" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache:ro" \
solidtime-smoke:test \
true \
>stdout.log 2>stderr.log
exit_code=$?
set -e
echo "[smoke] exit code: $exit_code"
echo "--- stderr ---"
cat stderr.log
echo "--- end stderr ---"
if [ "$exit_code" -eq 0 ]; then
echo "Expected the entrypoint to exit non-zero on an unwritable storage mount."
exit 1
fi
for needle in "not writable" "PUID=" "permissions"; do
if ! grep -q "$needle" stderr.log; then
echo "Missing diagnostic fragment: $needle"
exit 1
fi
done
echo "[smoke] PASS"

View File

@@ -3,18 +3,3 @@
## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -304,8 +304,28 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url,
],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();

View File

@@ -68,6 +68,7 @@ RUN apt-get update; \
wget \
vim \
git \
gosu \
ncdu \
procps \
unzip \
@@ -193,9 +194,20 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \
storage/framework/{sessions,views,cache/data,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
storage/app/public \
storage/app/private \
bootstrap/cache && \
ln -s ../storage/app/public public/storage && \
chmod -R a+rw storage bootstrap/cache
# OpenShift / arbitrary-UID compatibility: group 0 (root group) gets read+write+execute
# on writable paths. Any UID can run the container if it joins the root group.
# https://docs.openshift.com/container-platform/latest/openshift_images/create-images.html
USER root
RUN chgrp -R 0 storage bootstrap/cache && \
chmod -R g+rwX storage bootstrap/cache
#RUN composer install \
# --classmap-authoritative \

View File

@@ -1,7 +1,6 @@
[program:octane]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
user = %(ENV_USER)s
priority = 1
autostart = true
autorestart = true
@@ -14,7 +13,6 @@ stderr_logfile_maxbytes = 0
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 3
autostart = %(ENV_WITH_HORIZON)s
autorestart = true
@@ -27,7 +25,6 @@ stopwaitsecs = 3600
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
@@ -38,7 +35,6 @@ stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false
startsecs = 0
@@ -51,7 +47,6 @@ stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_REVERB)s
autorestart = true

View File

@@ -1,6 +1,240 @@
#!/usr/bin/env sh
#!/bin/bash
set -e
# ============================================================================
# Solidtime container entrypoint.
#
# Layout:
# 1. Storage tree bootstrap (idempotent, runs as any user)
# 2. UID/GID remap + chown (root only, controlled by PUID/PGID env vars)
# 3. Pre-flight write test (fails fast with a diagnosis message)
# 4. Privilege drop via gosu, then re-exec self as APP_USER
# 5. Original CONTAINER_MODE routing (runs as APP_USER)
#
# Env vars:
# PUID, PGID UID/GID for the application user. Defaults 1000:1000.
# Only takes effect when the container starts as root
# (which is the image's default — if you set a
# `user:` directive in compose, PUID/PGID are ignored
# and a startup warning is printed).
# SOLIDTIME_DROP_PRIVILEGES auto (default) | never
# auto: if started as root, drop privileges to APP_USER; otherwise just exec.
# never: never drop privileges. Run as whatever UID/GID was started.
# ============================================================================
APP_USER="octane"
APP_PATH="${ROOT:-/var/www/html}"
STORAGE_PATH="${APP_PATH}/storage"
CACHE_PATH="${APP_PATH}/bootstrap/cache"
DEFAULT_UID=1000
DEFAULT_GID=1000
TARGET_UID="${PUID:-${DEFAULT_UID}}"
TARGET_GID="${PGID:-${DEFAULT_GID}}"
DROP_PRIVS="${SOLIDTIME_DROP_PRIVILEGES:-auto}"
WRITABLE_PATHS=(
"${STORAGE_PATH}/framework/cache/data"
"${STORAGE_PATH}/framework/sessions"
"${STORAGE_PATH}/framework/views"
"${STORAGE_PATH}/framework/testing"
"${STORAGE_PATH}/logs"
"${STORAGE_PATH}/app/public"
"${STORAGE_PATH}/app/private"
"${CACHE_PATH}"
)
case "${DROP_PRIVS}" in
never) SHOULD_DROP=0 ;;
auto)
if [ "$(id -u)" = "0" ]; then
SHOULD_DROP=1
else
SHOULD_DROP=0
fi
;;
*)
echo "[entrypoint] ERROR: invalid SOLIDTIME_DROP_PRIVILEGES='${DROP_PRIVS}'" >&2
echo "[entrypoint] Valid values: auto (default), never" >&2
exit 1
;;
esac
# Warn if PUID/PGID are set but the container started non-root. PUID/PGID only
# take effect during the drop-privileges flow, which requires starting as root.
# A common cause is leaving `user:` in the compose file alongside PUID env vars.
if { [ -n "${PUID}" ] || [ -n "${PGID}" ]; } \
&& [ "$(id -u)" != "0" ] \
&& [ "${SOLIDTIME_PRIVILEGES_DROPPED:-0}" != "1" ]; then
cat >&2 <<EOF
[entrypoint] WARNING: PUID/PGID is set but the container started as UID $(id -u) (not root).
[entrypoint] WARNING: PUID/PGID only apply when the entrypoint runs as root and drops privileges.
[entrypoint] WARNING:
[entrypoint] WARNING: To use PUID/PGID: remove any 'user:' directive from your compose file.
[entrypoint] WARNING: To run as a fixed UID: remove PUID/PGID from your env.
[entrypoint] WARNING:
[entrypoint] WARNING: Continuing as UID $(id -u). See:
[entrypoint] WARNING: https://docs.solidtime.io/self-hosting/guides/permissions
EOF
fi
bootstrap_storage_tree() {
mkdir -p "${WRITABLE_PATHS[@]}" 2>/dev/null || return 1
}
# Proactive warning when the existing storage directory is owned by a non-default
# UID (typical on NAS systems where host users aren't UID 1000) and PUID/PGID
# aren't set. Without this nudge, the chown step silently re-owns the files to
# 1000:1000 and the user only discovers the mismatch later when host-side tools
# (backup, file browser, rsync) show unfamiliar ownership.
maybe_warn_ownership_mismatch() {
[ "${SHOULD_DROP}" = "1" ] || return 0
[ -n "${PUID}" ] && return 0
[ -n "${PGID}" ] && return 0
[ -d "${STORAGE_PATH}" ] || return 0
local owner_uid owner_gid
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null)" || return 0
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null)" || return 0
# Root-owned: probably freshly created by the entrypoint, will be chowned shortly.
[ "${owner_uid}" = "0" ] && return 0
# Already the target: nothing to warn about.
[ "${owner_uid}" = "${TARGET_UID}" ] && return 0
cat >&2 <<EOF
[entrypoint] NOTE: ${STORAGE_PATH} is owned by UID ${owner_uid}:${owner_gid},
[entrypoint] but the container is starting as UID ${TARGET_UID}:${TARGET_GID}.
[entrypoint] Files will be chowned to ${TARGET_UID}:${TARGET_GID} and may
[entrypoint] appear with an unfamiliar owner on the host.
[entrypoint]
[entrypoint] If you want the container to write as UID ${owner_uid} (common
[entrypoint] on Synology / TrueNAS / Unraid where host users aren't UID
[entrypoint] 1000), set in your env and restart:
[entrypoint]
[entrypoint] PUID=${owner_uid}
[entrypoint] PGID=${owner_gid}
[entrypoint]
[entrypoint] More: https://docs.solidtime.io/self-hosting/guides/permissions
EOF
}
print_write_test_failure() {
local owner
owner="$(stat -c '%u:%g' "${STORAGE_PATH}" 2>/dev/null || echo unknown)"
local runtime_uid
local runtime_gid
if [ "$(id -u)" = "0" ] && [ "${SHOULD_DROP}" = "1" ]; then
runtime_uid="${TARGET_UID}"
runtime_gid="${TARGET_GID}"
else
runtime_uid="$(id -u)"
runtime_gid="$(id -g)"
fi
local owner_uid
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
local owner_gid
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
cat >&2 <<EOF
============================================================
ERROR: Solidtime writable directories are not writable.
Diagnosis:
Container will run as: UID ${runtime_uid}, GID ${runtime_gid}
Storage directory owner: ${owner}
Likely cause: a bind-mounted host directory is owned by a different
user than the container's application user.
Fix on the host:
sudo chown -R ${runtime_uid}:${runtime_gid} <your-bind-mount-path>
Or set PUID/PGID to match the host directory owner:
PUID=${owner_uid}
PGID=${owner_gid}
To run intentionally as root, set:
SOLIDTIME_DROP_PRIVILEGES=never
For more help: https://docs.solidtime.io/self-hosting/guides/permissions
============================================================
EOF
}
write_test_as_user() {
local user="$1"
local script='
set -e
for dir in "$@"; do
test_file="${dir}/.solidtime-write-test"
touch "${test_file}"
rm -f "${test_file}"
done
'
if [ -n "${user}" ]; then
gosu "${user}" sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
else
sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
fi
}
# ----------------------------------------------------------------------------
# Root preamble: bootstrap, remap, chown, write-test, then drop and re-exec.
# ----------------------------------------------------------------------------
if [ "$(id -u)" = "0" ]; then
if ! bootstrap_storage_tree; then
echo "[entrypoint] ERROR: failed to create storage subdirectories at ${STORAGE_PATH}" >&2
exit 1
fi
if [ "${SHOULD_DROP}" = "1" ]; then
maybe_warn_ownership_mismatch
if [ "${TARGET_UID}" != "${DEFAULT_UID}" ] || [ "${TARGET_GID}" != "${DEFAULT_GID}" ]; then
echo "[entrypoint] Remapping ${APP_USER} to ${TARGET_UID}:${TARGET_GID}"
groupmod -o -g "${TARGET_GID}" "${APP_USER}"
usermod -o -u "${TARGET_UID}" "${APP_USER}"
fi
# Idempotent chown: only fix entries whose owner or group is wrong.
# On large storage volumes (lots of user uploads) this is dramatically
# faster than a blanket `chown -R` every restart. Pattern borrowed from
# docker-library/postgres and linuxserver.io's baseimage.
find "${STORAGE_PATH}" "${CACHE_PATH}" \
\( ! -user "${TARGET_UID}" -o ! -group "${TARGET_GID}" \) \
-exec chown "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true
if ! write_test_as_user "${APP_USER}"; then
print_write_test_failure
exit 1
fi
exec gosu "${APP_USER}" env SOLIDTIME_PRIVILEGES_DROPPED=1 "$0" "$@"
fi
if ! write_test_as_user ""; then
print_write_test_failure
exit 1
fi
else
if ! bootstrap_storage_tree; then
echo "[entrypoint] WARNING: could not create some storage subdirectories at ${STORAGE_PATH} (will continue if existing tree is writable)" >&2
fi
if ! write_test_as_user ""; then
print_write_test_failure
exit 1
fi
fi
# ----------------------------------------------------------------------------
# Application: runs as APP_USER (or whatever non-root UID was started).
# ----------------------------------------------------------------------------
unset SOLIDTIME_PRIVILEGES_DROPPED
container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
@@ -8,14 +242,16 @@ auto_db_migrate=${AUTO_DB_MIGRATE:-false}
initialStuff() {
echo "Container mode: $container_mode"
if [ ${auto_db_migrate} = "true" ]; then
if [ "${auto_db_migrate}" = "true" ]; then
echo "Auto database migration enabled."
php artisan migrate --isolated --force
fi
php artisan storage:link; \
php artisan optimize:clear; \
php artisan optimize;
if [ ! -L "${APP_PATH}/public/storage" ]; then
php artisan storage:link
fi
php artisan optimize:clear
php artisan optimize
}
if [ "$1" != "" ]; then
@@ -23,11 +259,11 @@ if [ "$1" != "" ]; then
elif [ "${container_mode}" = "http" ]; then
initialStuff
echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then
if [ "${octane_server}" = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ "${octane_server}" = "swoole" ]; then
elif [ "${octane_server}" = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ "${octane_server}" = "roadrunner" ]; then
elif [ "${octane_server}" = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else
echo "Invalid Octane server supplied."

View File

@@ -1,6 +1,5 @@
[supervisord]
nodaemon = true
user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid

View File

@@ -1,7 +1,6 @@
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout

View File

@@ -1,7 +1,6 @@
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout

View File

@@ -1,7 +1,6 @@
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
@@ -12,7 +11,6 @@ stderr_logfile_maxbytes = 0
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = true
autorestart = false
startsecs = 0

View File

@@ -1,7 +1,6 @@
[program:worker]
process_name = %(program_name)s_%(process_num)s
command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout

View File

@@ -0,0 +1,448 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization, OrganizationInvitation, User } from '@/types/models';
import type { Membership, Permissions, Role } from '@/types/jetstream';
import { filterRoles } from '@/utils/roles';
type UserWithMembership = User & { membership: Membership };
const props = defineProps<{
team: Organization;
availableRoles: Role[];
userPermissions: Permissions;
}>();
const users = computed(() => {
return props.team.users as Array<UserWithMembership>;
});
const page = usePage<{
auth: {
user: User;
};
}>();
const addTeamMemberForm = useForm({
email: '',
role: null as string | null,
});
const updateRoleForm = useForm({
role: null as string | null,
});
const leaveTeamForm = useForm({});
const removeTeamMemberForm = useForm({});
const currentlyManagingRole = ref(false);
const managingRoleFor = ref<User | null>(null);
const confirmingLeavingTeam = ref(false);
const teamMemberBeingRemoved = ref<User | null>(null);
const addTeamMember = () => {
addTeamMemberForm.post(route('team-members.store', props.team.id), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => addTeamMemberForm.reset(),
});
};
const cancelTeamInvitation = (invitation: OrganizationInvitation) => {
router.delete(route('team-invitations.destroy', invitation.id), {
preserveScroll: true,
});
};
const manageRole = (teamMember: User & { membership: Membership }) => {
managingRoleFor.value = teamMember;
updateRoleForm.role = teamMember.membership.role;
currentlyManagingRole.value = true;
};
const updateRole = () => {
updateRoleForm.put(
route('team-members.update', {
team: props.team.id,
user: managingRoleFor.value?.id,
}),
{
preserveScroll: true,
onSuccess: () => (currentlyManagingRole.value = false),
}
);
};
const confirmLeavingTeam = () => {
confirmingLeavingTeam.value = true;
};
const leaveTeam = () => {
leaveTeamForm.delete(route('team-members.destroy', [props.team.id, page.props.auth.user.id]));
};
const confirmTeamMemberRemoval = (teamMember: User) => {
teamMemberBeingRemoved.value = teamMember;
};
const removeTeamMember = () => {
removeTeamMemberForm.delete(
route('team-members.destroy', {
team: props.team.id,
user: teamMemberBeingRemoved.value?.id,
}),
{
errorBag: 'removeTeamMember',
preserveScroll: true,
preserveState: true,
onSuccess: () => (teamMemberBeingRemoved.value = null),
}
);
};
const displayableRole = (role: string) => {
return props.availableRoles.find((r) => r.key === role)?.name;
};
</script>
<template>
<div>
<div v-if="userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Add Organization Member -->
<FormSection @submitted="addTeamMember">
<template #title> Add Organization Member</template>
<template #description>
Add a new member to your organization, allowing them to collaborate with you.
</template>
<template #form>
<div class="col-span-6">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you would like to add to
this organization.
</div>
</div>
<!-- Member Email -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="email">Email</FieldLabel>
<TextInput
id="email"
v-model="addTeamMemberForm.email"
type="email"
class="block w-full" />
<FieldError v-if="addTeamMemberForm.errors.email">{{
addTeamMemberForm.errors.email
}}</FieldError>
</Field>
<!-- Role -->
<div v-if="availableRoles.length > 0" class="col-span-6 lg:col-span-4">
<FieldLabel for="roles">Role</FieldLabel>
<FieldError v-if="addTeamMemberForm.errors.role">{{
addTeamMemberForm.errors.role
}}</FieldError>
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in filterRoles(availableRoles)"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
:class="{
'opacity-50':
addTeamMemberForm.role &&
addTeamMemberForm.role != role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-text-primary"
:class="{
'font-semibold': addTeamMemberForm.role == role.key,
}">
{{ role.name }}
</div>
<svg
v-if="addTeamMemberForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #actions>
<ActionMessage :on="addTeamMemberForm.recentlySuccessful" class="me-3">
Added.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': addTeamMemberForm.processing }"
:disabled="addTeamMemberForm.processing">
Add
</PrimaryButton>
</template>
</FormSection>
</div>
<div v-if="team.team_invitations.length > 0 && userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Organization Member Invitations -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Pending Organization Invitations</template>
<template #description>
These people have been invited to your organization and have been sent an
invitation email. They may join the organization by accepting the email
invitation.
</template>
<!-- Pending Organization Member Invitation List -->
<template #content>
<div class="space-y-6">
<div
v-for="invitation in team.team_invitations"
:key="invitation.id"
class="flex items-center justify-between">
<div class="text-muted">
{{ invitation.email }}
</div>
<div class="flex items-center">
<!-- Cancel Organization Invitation -->
<button
v-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500 focus:outline-none"
@click="cancelTeamInvitation(invitation)">
Cancel
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<div v-if="users.length > 0">
<SectionBorder />
<!-- Manage Organization Members -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Organization Members</template>
<template #description>
All of the people that are part of this organization.
</template>
<!-- Organization Member List -->
<template #content>
<div class="space-y-6">
<div
v-for="user in users"
:key="user.id"
class="flex items-center justify-between">
<div class="flex items-center">
<img
class="w-8 h-8 rounded-full object-cover"
:src="user.profile_photo_url"
:alt="user.name" />
<div class="ms-4 text-text-primary">
{{ user.name }}
</div>
</div>
<div class="flex items-center">
<!-- Manage Organization Member Role -->
<button
v-if="
userPermissions.canUpdateTeamMembers &&
availableRoles.length
"
class="ms-2 text-sm text-gray-400 underline"
@click="manageRole(user)">
{{ displayableRole(user.membership.role) }}
</button>
<div
v-else-if="availableRoles.length"
class="ms-2 text-sm text-gray-400">
{{ displayableRole(user.membership.role) }}
</div>
<!-- Leave Organization -->
<button
v-if="page.props.auth.user.id === user.id"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmLeavingTeam">
Leave
</button>
<!-- Remove Organization Member -->
<button
v-else-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmTeamMemberRemoval(user)">
Remove
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<!-- Role Management Modal -->
<DialogModal :show="currentlyManagingRole" @close="currentlyManagingRole = false">
<template #title> Manage Role</template>
<template #content>
<div v-if="managingRoleFor">
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in availableRoles"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i !== Object.keys(availableRoles).length - 1,
}"
@click="updateRoleForm.role = role.key">
<div
:class="{
'opacity-50':
updateRoleForm.role && updateRoleForm.role !== role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-muted"
:class="{
'font-semibold': updateRoleForm.role === role.key,
}">
{{ role.name }}
</div>
<svg
v-if="updateRoleForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="currentlyManagingRole = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': updateRoleForm.processing }"
:disabled="updateRoleForm.processing"
@click="updateRole">
Save
</PrimaryButton>
</template>
</DialogModal>
<!-- Leave Organization Confirmation Modal -->
<ConfirmationModal :show="confirmingLeavingTeam" @close="confirmingLeavingTeam = false">
<template #title> Leave Organization</template>
<template #content> Are you sure you would like to leave this organization? </template>
<template #footer>
<SecondaryButton @click="confirmingLeavingTeam = false"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': leaveTeamForm.processing }"
:disabled="leaveTeamForm.processing"
@click="leaveTeam">
Leave
</DangerButton>
</template>
</ConfirmationModal>
<!-- Remove Organization Member Confirmation Modal -->
<ConfirmationModal :show="!!teamMemberBeingRemoved" @close="teamMemberBeingRemoved = null">
<template #title> Remove Organization Member</template>
<template #content>
Are you sure you would like to remove this person from the organization?
</template>
<template #footer>
<SecondaryButton @click="teamMemberBeingRemoved = null"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': removeTeamMemberForm.processing }"
:disabled="removeTeamMemberForm.processing"
@click="removeTeamMember">
Remove
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -51,6 +51,9 @@ const updateTeamName = () => {
<div class="text-text-primary">
{{ team.owner.name }}
</div>
<div class="text-text-secondary text-sm">
{{ team.owner.email }}
</div>
</div>
</div>
</div>

View File

@@ -35,8 +35,7 @@ watch(open, (isOpen) => {
sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
return aSelected - bSelected;
});
}
});

View File

@@ -22,7 +22,9 @@ export interface Organization {
currency: string;
created_at: string | null;
updated_at: string | null;
owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
owner: User;
users: User[];
team_invitations: OrganizationInvitation[];
}
export interface OrganizationInvitation {
id: string;

View File

@@ -29,7 +29,9 @@ export interface Organization {
created_at: string | null;
updated_at: string | null;
// relations
owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
owner: User;
users: User[];
team_invitations: OrganizationInvitation[];
}
export interface OrganizationInvitation {

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Models\OrganizationInvitation;
use App\Providers\JetstreamServiceProvider;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Jetstream\Jetstream;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(JetstreamServiceProvider::class)]
class TeamShowEndpointTest extends EndpointTestAbstract
{
protected function setUp(): void
{
Jetstream::$inertiaManager = null;
parent::setUp();
}
public function test_team_show_does_not_expose_member_roster_invitations_or_owner_email(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => 'pending@example.com',
]);
$this->actingAs($data->user);
// Act
$response = $this->get('/teams/'.$data->organization->getKey());
// Assert
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->missing('team.users')
->missing('team.team_invitations')
->missing('team.owner.email')
->has('team.owner.id')
->has('team.owner.name')
->has('team.owner.profile_photo_url')
);
}
}