Compare commits

...

48 Commits

Author SHA1 Message Date
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
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
133 changed files with 19878 additions and 1941 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,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

@@ -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

@@ -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

@@ -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",
)}
>
@@ -333,7 +335,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",
)}
>

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",
)}
>
@@ -353,7 +355,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",
)}
>

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",
)}
>
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -320,16 +330,16 @@ 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>

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",
)}
>
@@ -351,7 +353,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",
)}
>

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";

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

@@ -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";

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",
)}
>
@@ -335,7 +337,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",
)}
>

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",
)}
>
@@ -331,7 +333,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",
)}
>

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",
)}
>
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -321,16 +323,16 @@ 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>

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",
)}
>
@@ -353,7 +355,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",
)}
>

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

@@ -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

@@ -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";

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";

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";

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";

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";

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";

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

@@ -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

@@ -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,7 +151,7 @@ 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-sm 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}

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>) {

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;

View File

@@ -0,0 +1 @@
ALTER TABLE "backup" ADD COLUMN "includeEncryptionKey" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1212,6 +1212,20 @@
"when": 1781045439162,
"tag": "0172_quick_the_professor",
"breakpoints": true
},
{
"idx": 173,
"version": "7",
"when": 1783494977500,
"tag": "0173_aspiring_annihilus",
"breakpoints": true
},
{
"idx": 174,
"version": "7",
"when": 1783674181297,
"tag": "0174_great_naoko",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.9",
"version": "v0.29.12",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -47,8 +47,9 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/api-key": "1.6.23",
"@better-auth/scim": "1.6.23",
"@better-auth/sso": "1.6.23",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
@@ -82,7 +83,7 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.5.4",
"better-auth": "1.6.23",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.67.3",
@@ -94,7 +95,7 @@
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",

View File

@@ -1039,7 +1039,9 @@ const EnvironmentPage = (
<CardHeader className="p-0">
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<FolderInput className="size-6 text-muted-foreground self-center" />
{currentEnvironment.project.name}
<p className="text-base font-medium max-w-[250px] truncate">
{currentEnvironment.project.name}
</p>
<AdvancedEnvironmentSelector
projectId={projectId}
currentEnvironmentId={environmentId}
@@ -1878,7 +1880,7 @@ export async function getServerSideProps(
// Try to find default, otherwise use first accessible
const targetEnv =
accessibleEnvironments.find((env) => env.isDefault) ||
accessibleEnvironments[0];
accessibleEnvironments[0]!;
return {
redirect: {

View File

@@ -29,8 +29,6 @@ const Page = () => {
<CardDescription>
Configure how many deployments can build at the same time on
each server. Builds of the same service are always serialized.
Free plan allows up to 2 concurrent builds; an enterprise
license unlocks more.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">

View File

@@ -54,20 +54,6 @@ const Page = ({ isCloud }: Props) => {
</EnterpriseFeatureGate>
</div>
</Card>
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
lockedProps={{
title: "Application Authentication",
description:
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
ctaLabel: "Go to License",
}}
>
<ForwardAuthServers />
</EnterpriseFeatureGate>
</div>
</Card>
{!isCloud && (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">

View File

@@ -310,7 +310,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
</button>
</div>
<div className="flex gap-4">
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
className="w-full"

View File

@@ -32,6 +32,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { scimRouter } from "./routers/proprietary/scim";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
import { redirectsRouter } from "./routers/redirects";
@@ -94,6 +95,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
scim: scimRouter,
forwardAuth: forwardAuthRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,

View File

@@ -1,6 +1,7 @@
import {
createBackup,
findBackupById,
findBackupsByDbId,
findComposeByBackupId,
findComposeById,
findLibsqlByBackupId,
@@ -55,6 +56,7 @@ import {
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
import {
apiCreateBackup,
apiFindOneBackup,
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
});
}
if (IS_CLOUD) {
const dbType = (
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
).find((type) => input[`${type}Id`]);
if (dbType) {
const existingBackups = await findBackupsByDbId(
input[`${dbType}Id`]!,
dbType,
);
await assertDatabaseBackupLimit(
ctx.session.activeOrganizationId,
existingBackups.length,
);
}
}
const newBackup = await createBackup(input);
const backup = await findBackupById(newBackup.backupId);

View File

@@ -2,8 +2,10 @@ import {
createEnvironment,
deleteEnvironment,
duplicateEnvironment,
filterEnvironmentServices,
findEnvironmentById,
findEnvironmentsByProjectId,
IS_CLOUD,
updateEnvironmentById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
import {
apiCreateEnvironment,
apiDuplicateEnvironment,
@@ -30,43 +33,18 @@ import {
projects,
} from "@/server/db/schema";
const filterEnvironmentServices = (
environment: any,
accessedServices: string[],
) => ({
...environment,
applications: environment.applications.filter((app: any) =>
accessedServices.includes(app.applicationId),
),
compose: environment.compose.filter((comp: any) =>
accessedServices.includes(comp.composeId),
),
libsql: environment.libsql.filter((db: any) =>
accessedServices.includes(db.libsqlId),
),
mariadb: environment.mariadb.filter((db: any) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db: any) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db: any) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db: any) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db: any) =>
accessedServices.includes(db.redisId),
),
});
export const environmentRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
await checkEnvironmentCreationPermission(ctx, input.projectId);
if (IS_CLOUD) {
await assertEnvironmentLimit(
ctx.session.activeOrganizationId,
input.projectId,
);
}
if (input.name === "production") {
throw new TRPCError({

View File

@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
assertMemberLimit,
assertOrganizationLimit,
} from "@/server/api/utils/plan-limits";
import {
invitation,
member,
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
message: "Only the organization owner can create an organization",
});
}
if (IS_CLOUD) {
await assertOrganizationLimit(ctx.user.id);
}
const result = await db
.insert(organization)
.values({
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
const orgId = ctx.session.activeOrganizationId;
const email = input.email.toLowerCase();
if (IS_CLOUD) {
await assertMemberLimit(orgId);
}
// Check if user is already a member
const existingUser = await db.query.user.findFirst({
where: eq(user.email, email),

View File

@@ -0,0 +1,75 @@
import { db } from "@dokploy/server/db";
import { scimProvider } from "@dokploy/server/db/schema";
import { requestToHeaders } from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
const providerIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z0-9][a-z0-9-]*$/,
"Provider ID must be lowercase alphanumeric with optional dashes",
);
export const scimRouter = createTRPCRouter({
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.scimProvider.findMany({
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
columns: {
id: true,
providerId: true,
organizationId: true,
},
orderBy: [asc(scimProvider.providerId)],
});
return providers;
}),
generateToken: enterpriseProcedure
.input(z.object({ providerId: providerIdSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await db.query.scimProvider.findFirst({
where: eq(scimProvider.providerId, input.providerId),
columns: { id: true, organizationId: true },
});
if (existing) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A SCIM provider with this ID already exists",
});
}
const result = await auth.generateSCIMToken({
body: {
providerId: input.providerId,
organizationId: ctx.session.activeOrganizationId,
},
headers: requestToHeaders(ctx.req),
});
return { scimToken: result.scimToken, providerId: input.providerId };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: providerIdSchema }))
.mutation(async ({ ctx, input }) => {
const [deleted] = await db
.delete(scimProvider)
.where(
and(
eq(scimProvider.providerId, input.providerId),
eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
),
)
.returning({ id: scimProvider.id });
if (!deleted) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SCIM provider not found or you do not have permission to delete it",
});
}
return { success: true };
}),
});

View File

@@ -8,6 +8,7 @@ import {
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { invalidateTrustedOriginsCache } from "@dokploy/server/services/admin";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
@@ -319,6 +320,7 @@ export const ssoRouter = createTRPCRouter({
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
invalidateTrustedOriginsCache();
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
@@ -346,6 +348,7 @@ export const ssoRouter = createTRPCRouter({
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
invalidateTrustedOriginsCache();
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
@@ -379,6 +382,7 @@ export const ssoRouter = createTRPCRouter({
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
invalidateTrustedOriginsCache();
return { success: true };
}),
});

View File

@@ -1,5 +1,7 @@
import {
getPublicWhitelabelingConfig,
getWebServerSettings,
hasValidLicense,
IS_CLOUD,
updateWebServerSettings,
} from "@dokploy/server";
@@ -13,10 +15,13 @@ import {
} from "../../trpc";
export const whitelabelingRouter = createTRPCRouter({
get: protectedProcedure.query(async () => {
get: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
return null;
}
if (!(await hasValidLicense(ctx.session.activeOrganizationId))) {
return null;
}
const settings = await getWebServerSettings();
return settings?.whitelabelingConfig ?? null;
}),
@@ -82,25 +87,5 @@ export const whitelabelingRouter = createTRPCRouter({
// Public endpoint only for unauthenticated pages (login, register, error)
// Returns only the fields needed for public pages
getPublic: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
const config = settings?.whitelabelingConfig;
if (!config) return null;
return {
appName: config.appName,
appDescription: config.appDescription,
logoUrl: config.logoUrl,
loginLogoUrl: config.loginLogoUrl,
faviconUrl: config.faviconUrl,
customCss: config.customCss,
metaTitle: config.metaTitle,
errorPageTitle: config.errorPageTitle,
errorPageDescription: config.errorPageDescription,
footerText: config.footerText,
};
}),
getPublic: publicProcedure.query(() => getPublicWhitelabelingConfig()),
});

View File

@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
input.applicationId ? "application" : "compose",
serviceId,
);
}
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
message: "You don't have access to this server.",
});
}
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
}
}
}
const newSchedule = await createSchedule({

View File

@@ -46,7 +46,6 @@ import {
redis,
server,
} from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
export const serverRouter = createTRPCRouter({
@@ -491,10 +490,6 @@ export const serverRouter = createTRPCRouter({
message: "You are not authorized to update this server",
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
return await updateServerById(input.serverId, {
buildsConcurrency: input.buildsConcurrency,
});

View File

@@ -71,7 +71,6 @@ import {
projects,
server,
} from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json";
@@ -480,11 +479,6 @@ export const settingsRouter = createTRPCRouter({
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
await updateWebServerSettings({
buildsConcurrency: input.buildsConcurrency,
});

View File

@@ -7,6 +7,7 @@ import {
import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
import {
type BillingTier,
getStripeItems,
@@ -31,44 +32,7 @@ import {
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return null;
const owner = await findUserById(ctx.user.ownerId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup" as const;
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby" as const;
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy" as const;
}
return null;
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
}),
getProducts: adminProcedure.query(async ({ ctx }) => {

View File

@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
create: protectedProcedure
.input(createVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const serviceId =
input.applicationId ||
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.redisId ||
input.libsqlId ||
input.composeId;
const serviceType = (
[
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"libsql",
"compose",
] as const
).find((type) => input[`${type}Id`]);
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
if (IS_CLOUD && serviceType && serviceId) {
const existingVolumeBackups = await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
});
await assertVolumeBackupLimit(
ctx.session.activeOrganizationId,
existingVolumeBackups.length,
);
}
const newVolumeBackup = await createVolumeBackup(input);
if (newVolumeBackup?.enabled) {

View File

@@ -0,0 +1,130 @@
import { db } from "@dokploy/server/db";
import {
environments,
member,
organization,
schedules,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
export type PlanLimitResource =
| "organization"
| "member"
| "environment"
| "volumeBackup"
| "databaseBackup"
| "scheduledJob";
const UNLIMITED = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<
"hobby" | "startup" | "legacy",
Record<PlanLimitResource, number>
> = {
hobby: {
organization: 1,
member: 1,
environment: 2,
volumeBackup: 1,
databaseBackup: 1,
scheduledJob: 1,
},
startup: {
organization: 3,
member: UNLIMITED,
environment: UNLIMITED,
volumeBackup: UNLIMITED,
databaseBackup: UNLIMITED,
scheduledJob: UNLIMITED,
},
legacy: {
organization: UNLIMITED,
member: UNLIMITED,
environment: UNLIMITED,
volumeBackup: UNLIMITED,
databaseBackup: UNLIMITED,
scheduledJob: UNLIMITED,
},
};
const resourceLabels: Record<PlanLimitResource, string> = {
organization: "organizations",
member: "users",
environment: "environments per project",
volumeBackup: "volume backups per application",
databaseBackup: "backups per database",
scheduledJob: "scheduled jobs per service",
};
const assertLimitForPlan = (
plan: "hobby" | "startup" | "legacy" | null,
resource: PlanLimitResource,
currentCount: number,
) => {
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
if (currentCount >= limit) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
});
}
};
export const assertOrganizationLimit = async (userId: string) => {
const plan = await getCurrentPlanForUser(userId);
const organizations = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
});
assertLimitForPlan(plan, "organization", organizations.length);
};
export const assertMemberLimit = async (organizationId: string) => {
const plan = await getCurrentPlan(organizationId);
const members = await db.query.member.findMany({
where: eq(member.organizationId, organizationId),
});
assertLimitForPlan(plan, "member", members.length);
};
export const assertEnvironmentLimit = async (
organizationId: string,
projectId: string,
) => {
const plan = await getCurrentPlan(organizationId);
const envs = await db.query.environments.findMany({
where: eq(environments.projectId, projectId),
});
assertLimitForPlan(plan, "environment", envs.length);
};
export const assertVolumeBackupLimit = async (
organizationId: string,
currentCount: number,
) => {
const plan = await getCurrentPlan(organizationId);
assertLimitForPlan(plan, "volumeBackup", currentCount);
};
export const assertDatabaseBackupLimit = async (
organizationId: string,
currentCount: number,
) => {
const plan = await getCurrentPlan(organizationId);
assertLimitForPlan(plan, "databaseBackup", currentCount);
};
export const assertScheduledJobLimit = async (
organizationId: string,
scheduleType: "application" | "compose" | "server",
serviceId: string,
) => {
const plan = await getCurrentPlan(organizationId);
const column = `${scheduleType}Id` as const;
const rows = await db.query.schedules.findMany({
where: eq(schedules[column], serviceId),
});
assertLimitForPlan(plan, "scheduledJob", rows.length);
};

View File

@@ -1,32 +1,30 @@
import { db } from "@dokploy/server/db";
import { organization, server } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { server } from "@dokploy/server/db/schema";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { LOCAL_PARTITION } from "./in-memory-queue";
/**
* Resolve the effective builds concurrency for a queue partition.
*
* Concurrent deployments (concurrency > 1) are an enterprise feature: without a
* valid license the effective concurrency is always clamped to 1, so the
* community experience is unchanged and an expired license degrades gracefully
* back to sequential deployments instead of breaking anything.
*
* - `LOCAL_PARTITION` -> concurrency stored on the web server settings (the
* local Dokploy web server), gated by the owner organization's license.
* - any other partition -> concurrency stored on the matching `server` row,
* gated by that server's organization license.
* local Dokploy web server).
* - any other partition -> concurrency stored on the matching `server` row.
*/
export const resolveBuildsConcurrency = async (
partition: string,
): Promise<number> => {
try {
if (partition === LOCAL_PARTITION) {
return await resolveLocalConcurrency();
const settings = await getWebServerSettings();
return normalize(settings?.buildsConcurrency ?? 1);
}
return await resolveServerConcurrency(partition);
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, partition),
columns: { buildsConcurrency: true },
});
return normalize(currentServer?.buildsConcurrency ?? 1);
} catch (error) {
console.error(
"Failed to resolve builds concurrency, defaulting to 1",
@@ -36,55 +34,4 @@ export const resolveBuildsConcurrency = async (
}
};
// Max concurrent builds allowed without an enterprise license. With a valid
// license the value is unbounded (N) — only the free tier is capped.
export const FREE_MAX_CONCURRENCY = 2;
const clamp = (value: number, licensed: boolean): number => {
const min = Math.max(1, Math.floor(value));
return licensed ? min : Math.min(FREE_MAX_CONCURRENCY, min);
};
/**
* Validate a requested builds-concurrency value before persisting it. Free tier
* may set up to FREE_MAX_CONCURRENCY; anything higher requires a valid
* enterprise license. Throws a TRPCError when the value is not allowed.
*/
export const assertBuildsConcurrencyAllowed = async (
value: number,
organizationId: string,
): Promise<void> => {
if (value <= FREE_MAX_CONCURRENCY) return;
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: `A valid enterprise license is required to set more than ${FREE_MAX_CONCURRENCY} concurrent builds.`,
});
}
};
const resolveLocalConcurrency = async (): Promise<number> => {
const settings = await getWebServerSettings();
const buildsConcurrency = settings?.buildsConcurrency ?? 1;
// Self-hosted is single-tenant; gate on any organization's license.
const anyOrg = await db.query.organization.findFirst({
columns: { id: true },
});
const licensed = anyOrg ? await hasValidLicense(anyOrg.id) : false;
return clamp(buildsConcurrency, licensed);
};
const resolveServerConcurrency = async (serverId: string): Promise<number> => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { buildsConcurrency: true, organizationId: true },
});
if (!currentServer) return 1;
const licensed = await hasValidLicense(currentServer.organizationId);
return clamp(currentServer.buildsConcurrency, licensed);
};
const normalize = (value: number): number => Math.max(1, Math.floor(value));

View File

@@ -12,8 +12,8 @@ import type { DeploymentJob } from "./queue-types";
* Deployment queue.
*
* Self-hosted uses an in-memory, per-group FIFO queue with configurable
* concurrency per server (enterprise-gated). Cloud does not use the queue at
* all — deployments run directly in the background — so we expose a no-op.
* concurrency per server. Cloud does not use the queue at all — deployments
* run directly in the background — so we expose a no-op.
*/
interface DeploymentQueue {

View File

@@ -0,0 +1,69 @@
import { findUserById, IS_CLOUD } from "@dokploy/server";
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
import Stripe from "stripe";
import {
HOBBY_PRICE_ANNUAL_ID,
HOBBY_PRICE_MONTHLY_ID,
LEGACY_PRICE_IDS,
STARTUP_BASE_PRICE_ANNUAL_ID,
STARTUP_BASE_PRICE_MONTHLY_ID,
} from "@/server/utils/stripe";
export type BillingPlan = "legacy" | "hobby" | "startup";
export const getCurrentPlanForUser = async (
userId: string,
): Promise<BillingPlan | null> => {
if (!IS_CLOUD) return null;
const owner = await findUserById(userId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup";
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby";
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy";
}
return null;
};
export const getCurrentPlan = async (
organizationId: string,
): Promise<BillingPlan | null> => {
if (!IS_CLOUD) return null;
const ownerId = await getOrganizationOwnerId(organizationId);
if (!ownerId) return null;
return getCurrentPlanForUser(ownerId);
};

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