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") VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT 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 - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -160,6 +168,7 @@ jobs:
generate_release_notes: true generate_release_notes: true
draft: false draft: false
prerelease: false prerelease: false
files: install.sh
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -69,4 +69,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 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 { import {
canEditDeployGitSource, canEditDeployGitSource,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider"; } from "@dokploy/server/services/git-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({ const mockDb = vi.hoisted(() => ({
query: { query: {

View File

@@ -1,16 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn(); const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn(); const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({ vi.mock("@dokploy/server/db", () => ({
db: { db: {
query: { query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: { server: {
findFirst: (...args: unknown[]) => findFirstServer(...args), findFirst: (...args: unknown[]) => findFirstServer(...args),
}, },
@@ -19,91 +14,56 @@ vi.mock("@dokploy/server/db", () => ({
})); }));
vi.mock("@dokploy/server/db/schema", () => ({ vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {}, server: {},
})); }));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({ vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args), getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
})); }));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() })); vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import { import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue"; import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency (enterprise gating)", () => { describe("resolveBuildsConcurrency", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
}); });
describe("local web server partition", () => { describe("local web server partition", () => {
it("returns the configured concurrency when licensed", async () => { it("returns the configured concurrency", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 }); getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5); await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
}); });
it("clamps to the free max (2) when there is no valid license", async () => { it("does not cap high values", 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 () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 }); getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe( await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999, 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 () => { it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined); getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
}); });
}); });
describe("remote server partition", () => { describe("remote server partition", () => {
it("returns the server concurrency when its org is licensed", async () => { it("returns the server concurrency", async () => {
findFirstServer.mockResolvedValue({ findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(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 () => { 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); 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> <FormLabel>Bitbucket Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel> <FormLabel>Gitea Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel> <FormLabel>Github Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Github Account" /> <SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (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" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "pending" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? (branches?.find(
(branch) => branch.name === field.value, (branch) => branch.name === field.value,
)?.name )?.name ?? field.value)
: "Select branch"} : "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>

View File

@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel> <FormLabel>Gitlab Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -1,4 +1,3 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { import {
Ban, Ban,
CheckCircle2, CheckCircle2,
@@ -8,6 +7,7 @@ import {
Terminal, Terminal,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show"; import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show"; import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View File

@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel> <FormLabel>Bitbucket Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel> <FormLabel>Gitea Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.composeId, form]); }, [form.reset, data]);
const onSubmit = async (data: GithubProvider) => { const onSubmit = async (data: GithubProvider) => {
await mutateAsync({ await mutateAsync({
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel> <FormLabel>Github Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (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" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "pending" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? (branches?.find(
(branch) => branch.name === field.value, (branch) => branch.name === field.value,
)?.name )?.name ?? field.value)
: "Select branch"} : "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>

View File

@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel> <FormLabel>Gitlab Account</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value); field.onChange(value);
form.setValue("repository", { form.setValue("repository", {
owner: "", owner: "",
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between bg-input!", " w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -79,6 +79,7 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"), schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"), prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(), enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"), database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(), keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(), serviceName: z.string().nullable(),
@@ -223,6 +224,7 @@ export const HandleBackup = ({
: "", : "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
includeEncryptionKey: true,
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined, keepLatestCount: undefined,
@@ -262,6 +264,7 @@ export const HandleBackup = ({
: "", : "",
destinationId: backup?.destinationId ?? "", destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true, enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/", prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "", schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined, keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -309,6 +312,7 @@ export const HandleBackup = ({
prefix: data.prefix, prefix: data.prefix,
schedule: data.schedule, schedule: data.schedule,
enabled: data.enabled, enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database, database: data.database,
keepLatestCount: data.keepLatestCount ?? null, keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType, databaseType: data.databaseType || databaseType,
@@ -409,7 +413,7 @@ export const HandleBackup = ({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between bg-input!", "w-full justify-between",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -665,6 +669,31 @@ export const HandleBackup = ({
</FormItem> </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" && ( {backupType === "compose" && (
<> <>
{form.watch("databaseType") === "postgres" && ( {form.watch("databaseType") === "postgres" && (

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import {
useReactTable, useReactTable,
type VisibilityState, type VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ChevronDown, Container } from "lucide-react"; import { ChevronDown, Container, RefreshCw } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -26,6 +26,13 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -44,10 +51,26 @@ interface Props {
serverId?: string; serverId?: string;
} }
const CONTAINER_STATES = [
"running",
"exited",
"paused",
"restarting",
"created",
"removing",
"dead",
];
export const ShowContainers = ({ serverId }: Props) => { export const ShowContainers = ({ serverId }: Props) => {
const { data, isPending } = api.docker.getContainers.useQuery({ const { data, isPending, refetch, isRefetching } =
serverId, api.docker.getContainers.useQuery(
}); {
serverId,
},
{
refetchInterval: 10_000,
},
);
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -106,12 +129,48 @@ export const ShowContainers = ({ serverId }: Props) => {
} }
className="md:max-w-sm" 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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="outline" className="max-sm:w-full">
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" /> Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; 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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; 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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; 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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
toast.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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; 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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
</div> </div>
</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} /> <ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} /> <ShowNodeApplications serverId={serverId} />
</div> </div>

View File

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

View File

@@ -41,6 +41,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -564,6 +565,8 @@ function SidebarLogo() {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const isCollapsed = state === "collapsed" && !isMobile; const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery(); const { data: activeOrganization } = api.organization.active.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const { data: invitations, refetch: refetchInvitations } = const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery(); api.user.getInvitations.useQuery();
@@ -629,9 +632,14 @@ function SidebarLogo() {
isCollapsed && "hidden", isCollapsed && "hidden",
)} )}
> >
<p className="text-sm font-medium leading-none"> <div className="flex items-center gap-1.5">
{activeOrganization?.name ?? "Select Organization"} <p className="text-sm font-medium leading-none">
</p> {activeOrganization?.name ?? "Select Organization"}
</p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
)}
</div>
</div> </div>
</div> </div>
<ChevronsUpDown <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 { import {
Eye, Eye,
KeyRound,
Loader2, Loader2,
LogIn, LogIn,
Pencil, Pencil,
@@ -34,6 +35,7 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url"; import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog"; import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog"; import { RegisterSamlDialog } from "./register-saml-dialog";
import { ScimDialog } from "./scim-dialog";
type ProviderForDetails = { type ProviderForDetails = {
id: string | null; id: string | null;
@@ -169,15 +171,22 @@ export const SSOSettings = () => {
Users can sign in with their organization&apos;s IdP. Users can sign in with their organization&apos;s IdP.
</CardDescription> </CardDescription>
</div> </div>
<Button <div className="flex flex-wrap gap-2 shrink-0">
variant="outline" <Button
size="sm" variant="outline"
onClick={() => setManageOriginsOpen(true)} size="sm"
className="shrink-0" onClick={() => setManageOriginsOpen(true)}
> >
<Shield className="mr-2 size-4" /> <Shield className="mr-2 size-4" />
Manage origins Manage origins
</Button> </Button>
<ScimDialog>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 size-4" />
Manage SCIM
</Button>
</ScimDialog>
</div>
</div> </div>
{isPending ? ( {isPending ? (

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui"; import { Avatar as AvatarPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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 { 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">) { function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return ( return (

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk"; import { Command as CommandPrimitive } from "cmdk";
import { CheckIcon, SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import type * as React from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,7 +11,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { SearchIcon, CheckIcon } from "lucide-react"; import { cn } from "@/lib/utils";
function Command({ function Command({
className, className,
@@ -45,10 +44,6 @@ function CommandDialog({
}) { }) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent <DialogContent
className={cn( className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", "top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
@@ -56,7 +51,11 @@ function CommandDialog({
)} )}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
{children} <DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command>{children}</Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -68,7 +67,7 @@ function CommandInput({
}: React.ComponentProps<typeof CommandPrimitive.Input>) { }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return ( return (
<div data-slot="command-input-wrapper" className="p-1 pb-0"> <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 <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
@@ -122,7 +121,7 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -152,7 +151,7 @@ function CommandItem({
<CommandPrimitive.Item <CommandPrimitive.Item
data-slot="command-item" data-slot="command-item"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

@@ -1,10 +1,9 @@
"use client"; "use client";
import * as React from "react"; import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function ContextMenu({ function ContextMenu({
...props ...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 { 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({ function Dialog({
...props ...props
@@ -49,6 +49,8 @@ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
onPointerDownOutside,
onEscapeKeyDown,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; 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", "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, className,
)} )}
onPointerDownOutside={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onPointerDownOutside?.(event);
}}
onEscapeKeyDown={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onEscapeKeyDown?.(event);
}}
{...props} {...props}
> >
{children} {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 { 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({ function DropdownMenu({
onOpenChange,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: 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({ function DropdownMenuPortal({

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui"; import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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"; "use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui"; 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"; import { cn } from "@/lib/utils";
function Popover({ function Popover({
onOpenChange,
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: 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({ function PopoverTrigger({

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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 { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
function Select({ function Select({
...props ...props
@@ -58,7 +57,7 @@ function SelectTrigger({
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "item-aligned", position = "popper",
align = "center", align = "center",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui"; import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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 { 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>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />; 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-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; 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 { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui"; import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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"; import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui"; import { Toggle as TogglePrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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, "when": 1781045439162,
"tag": "0172_quick_the_professor", "tag": "0172_quick_the_professor",
"breakpoints": true "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", "name": "dokploy",
"version": "v0.29.9", "version": "v0.29.12",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -47,8 +47,9 @@
"@ai-sdk/mistral": "^3.0.20", "@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4", "@better-auth/api-key": "1.6.23",
"@better-auth/sso": "1.5.4", "@better-auth/scim": "1.6.23",
"@better-auth/sso": "1.6.23",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
@@ -82,7 +83,7 @@
"ai": "^6.0.86", "ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0", "ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"better-auth": "1.5.4", "better-auth": "1.6.23",
"bl": "6.0.11", "bl": "6.0.11",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"bullmq": "5.67.3", "bullmq": "5.67.3",
@@ -94,7 +95,7 @@
"dockerode": "4.0.2", "dockerode": "4.0.2",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"drizzle-orm": "0.45.1", "drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3", "drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3", "fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",

View File

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

View File

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

View File

@@ -54,20 +54,6 @@ const Page = ({ isCloud }: Props) => {
</EnterpriseFeatureGate> </EnterpriseFeatureGate>
</div> </div>
</Card> </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 && ( {!isCloud && (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full"> <Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md"> <div className="rounded-xl bg-background shadow-md">

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { import {
createBackup, createBackup,
findBackupById, findBackupById,
findBackupsByDbId,
findComposeByBackupId, findComposeByBackupId,
findComposeById, findComposeById,
findLibsqlByBackupId, findLibsqlByBackupId,
@@ -55,6 +56,7 @@ import {
withPermission, withPermission,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
import { import {
apiCreateBackup, apiCreateBackup,
apiFindOneBackup, 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 newBackup = await createBackup(input);
const backup = await findBackupById(newBackup.backupId); const backup = await findBackupById(newBackup.backupId);

View File

@@ -2,8 +2,10 @@ import {
createEnvironment, createEnvironment,
deleteEnvironment, deleteEnvironment,
duplicateEnvironment, duplicateEnvironment,
filterEnvironmentServices,
findEnvironmentById, findEnvironmentById,
findEnvironmentsByProjectId, findEnvironmentsByProjectId,
IS_CLOUD,
updateEnvironmentById, updateEnvironmentById,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; 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 { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
import { import {
apiCreateEnvironment, apiCreateEnvironment,
apiDuplicateEnvironment, apiDuplicateEnvironment,
@@ -30,43 +33,18 @@ import {
projects, projects,
} from "@/server/db/schema"; } 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({ export const environmentRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateEnvironment) .input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
await checkEnvironmentCreationPermission(ctx, input.projectId); await checkEnvironmentCreationPermission(ctx, input.projectId);
if (IS_CLOUD) {
await assertEnvironmentLimit(
ctx.session.activeOrganizationId,
input.projectId,
);
}
if (input.name === "production") { if (input.name === "production") {
throw new TRPCError({ throw new TRPCError({

View File

@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import {
assertMemberLimit,
assertOrganizationLimit,
} from "@/server/api/utils/plan-limits";
import { import {
invitation, invitation,
member, member,
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
message: "Only the organization owner can create an organization", message: "Only the organization owner can create an organization",
}); });
} }
if (IS_CLOUD) {
await assertOrganizationLimit(ctx.user.id);
}
const result = await db const result = await db
.insert(organization) .insert(organization)
.values({ .values({
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
const orgId = ctx.session.activeOrganizationId; const orgId = ctx.session.activeOrganizationId;
const email = input.email.toLowerCase(); const email = input.email.toLowerCase();
if (IS_CLOUD) {
await assertMemberLimit(orgId);
}
// Check if user is already a member // Check if user is already a member
const existingUser = await db.query.user.findFirst({ const existingUser = await db.query.user.findFirst({
where: eq(user.email, email), 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, requestToHeaders,
} from "@dokploy/server/index"; } from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth"; import { auth } from "@dokploy/server/lib/auth";
import { invalidateTrustedOriginsCache } from "@dokploy/server/services/admin";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
@@ -319,6 +320,7 @@ export const ssoRouter = createTRPCRouter({
.update(user) .update(user)
.set({ trustedOrigins: next }) .set({ trustedOrigins: next })
.where(eq(user.id, ownerId)); .where(eq(user.id, ownerId));
invalidateTrustedOriginsCache();
return { success: true }; return { success: true };
}), }),
removeTrustedOrigin: enterpriseProcedure removeTrustedOrigin: enterpriseProcedure
@@ -346,6 +348,7 @@ export const ssoRouter = createTRPCRouter({
.update(user) .update(user)
.set({ trustedOrigins: next }) .set({ trustedOrigins: next })
.where(eq(user.id, ownerId)); .where(eq(user.id, ownerId));
invalidateTrustedOriginsCache();
return { success: true }; return { success: true };
}), }),
updateTrustedOrigin: enterpriseProcedure updateTrustedOrigin: enterpriseProcedure
@@ -379,6 +382,7 @@ export const ssoRouter = createTRPCRouter({
.update(user) .update(user)
.set({ trustedOrigins: next }) .set({ trustedOrigins: next })
.where(eq(user.id, ownerId)); .where(eq(user.id, ownerId));
invalidateTrustedOriginsCache();
return { success: true }; return { success: true };
}), }),
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import Stripe from "stripe"; import Stripe from "stripe";
import { z } from "zod"; import { z } from "zod";
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
import { import {
type BillingTier, type BillingTier,
getStripeItems, getStripeItems,
@@ -31,44 +32,7 @@ import {
export const stripeRouter = createTRPCRouter({ export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */ /** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return null; return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
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;
}), }),
getProducts: adminProcedure.query(async ({ ctx }) => { getProducts: adminProcedure.query(async ({ ctx }) => {

View File

@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule, updateJob } from "@/server/utils/backup"; import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc"; import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(createVolumeBackupSchema) .input(createVolumeBackupSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const serviceId = const serviceType = (
input.applicationId || [
input.postgresId || "application",
input.mysqlId || "postgres",
input.mariadbId || "mysql",
input.mongoId || "mariadb",
input.redisId || "mongo",
input.libsqlId || "redis",
input.composeId; "libsql",
"compose",
] as const
).find((type) => input[`${type}Id`]);
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"], 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); const newVolumeBackup = await createVolumeBackup(input);
if (newVolumeBackup?.enabled) { 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 { db } from "@dokploy/server/db";
import { organization, server } from "@dokploy/server/db/schema"; import { server } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { LOCAL_PARTITION } from "./in-memory-queue"; import { LOCAL_PARTITION } from "./in-memory-queue";
/** /**
* Resolve the effective builds concurrency for a queue partition. * 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_PARTITION` -> concurrency stored on the web server settings (the
* local Dokploy web server), gated by the owner organization's license. * local Dokploy web server).
* - any other partition -> concurrency stored on the matching `server` row, * - any other partition -> concurrency stored on the matching `server` row.
* gated by that server's organization license.
*/ */
export const resolveBuildsConcurrency = async ( export const resolveBuildsConcurrency = async (
partition: string, partition: string,
): Promise<number> => { ): Promise<number> => {
try { try {
if (partition === LOCAL_PARTITION) { 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) { } catch (error) {
console.error( console.error(
"Failed to resolve builds concurrency, defaulting to 1", "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 const normalize = (value: number): number => Math.max(1, Math.floor(value));
// 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);
};

View File

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