Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
b354c3cb0f fix(projects): make project cards grid fill available width on wide screens 2026-07-05 15:06:18 -06:00
156 changed files with 2008 additions and 20171 deletions

View File

@@ -152,14 +152,6 @@ 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:
@@ -168,7 +160,6 @@ 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,5 +69,4 @@ 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
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS) CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
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

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

View File

@@ -1,50 +0,0 @@
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
it("should redact access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
});
it("should redact secret access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("supersecret");
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
});
it("should redact both credentials simultaneously", () => {
const cmd =
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIA123");
expect(redacted).not.toContain("secret456");
expect(redacted).toContain('--s3-region="us-east-1"');
});
it("should not modify non-credential flags", () => {
const cmd =
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).toBe(cmd);
});
it("should handle commands with no credentials", () => {
const cmd = "rclone lsf :s3:bucket/";
expect(redactRcloneCredentials(cmd)).toBe(cmd);
});
it("should handle error strings containing credentials", () => {
const errorStr =
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MYSECRET");
expect(redacted).toContain("[REDACTED]");
});
});

View File

@@ -1,93 +0,0 @@
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,31 +0,0 @@
import { getLogType } from "@/components/dashboard/docker/logs/utils";
import { expect, test } from "vitest";
test("classifies real failures as error", () => {
expect(getLogType("Error: connection refused at db:5432").type).toBe("error");
expect(getLogType("[ERROR] something went wrong").type).toBe("error");
expect(getLogType("Deployment failed").type).toBe("error");
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1',
).type,
).toBe("error");
});
test("does not classify explicit non-error key/values as error (#4538)", () => {
// ofelia job-completion summary for a successful run
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none',
).type,
).not.toBe("error");
expect(getLogType("request done, error: null").type).not.toBe("error");
expect(getLogType("checks passed, failures=0").type).not.toBe("error");
expect(getLogType('shutdown clean, error=""').type).not.toBe("error");
});
test("keeps statusCode-based classification", () => {
expect(getLogType('{"statusCode": "500"}').type).toBe("error");
expect(getLogType('{"statusCode": "204"}').type).toBe("success");
});

View File

@@ -1,11 +1,16 @@
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),
}, },
@@ -14,56 +19,91 @@ 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 { resolveBuildsConcurrency } from "../../server/queues/concurrency"; import {
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", () => { describe("resolveBuildsConcurrency (enterprise gating)", () => {
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", async () => { it("returns the configured concurrency when licensed", 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("does not cap high values", async () => { it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
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", async () => { it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 }); findFirstServer.mockResolvedValue({
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 () => {
@@ -79,3 +119,30 @@ describe("resolveBuildsConcurrency", () => {
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,9 +188,6 @@ 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: "",
@@ -199,6 +196,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -247,7 +245,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -335,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -201,9 +201,6 @@ 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: "",
@@ -211,6 +208,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -260,7 +258,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -355,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -177,9 +177,6 @@ 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: "",
@@ -192,14 +189,7 @@ 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>
@@ -243,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -253,7 +243,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 ?? field.value.repo)} )?.name ?? "Select repository")}
<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>
@@ -330,16 +320,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!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 ?? field.value) )?.name
: "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,9 +196,6 @@ 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: "",
@@ -208,6 +205,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -256,7 +254,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -353,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -1,3 +1,4 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { import {
Ban, Ban,
CheckCircle2, CheckCircle2,
@@ -7,7 +8,6 @@ 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,3 +1,4 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { import {
ExternalLink, ExternalLink,
FileText, FileText,
@@ -8,7 +9,6 @@ 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", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -507,20 +507,11 @@ export const HandleVolumeBackups = ({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{mounts && mounts.length > 0 ? ( {mounts?.map((mount) => (
mounts.map((mount) => ( <SelectItem key={mount.Name} value={mount.Name || ""}>
<SelectItem {mount.Name}
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", "w-full justify-between bg-input!",
!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", "w-full justify-between bg-input!",
!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,9 +190,6 @@ 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: "",
@@ -201,6 +198,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -249,7 +247,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -337,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -188,9 +188,6 @@ 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: "",
@@ -198,6 +195,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -246,7 +244,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -333,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!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]); }, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GithubProvider) => { const onSubmit = async (data: GithubProvider) => {
await mutateAsync({ await mutateAsync({
@@ -179,9 +179,6 @@ 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: "",
@@ -189,6 +186,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -236,7 +234,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -246,7 +244,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 ?? field.value.repo)} )?.name ?? "Select repository")}
<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>
@@ -323,16 +321,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!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 ?? field.value) )?.name
: "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,9 +199,6 @@ 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: "",
@@ -211,6 +208,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
}); });
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
defaultValue={field.value}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
@@ -258,7 +256,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -355,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
" w-full justify-between", " w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -79,7 +79,6 @@ 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(),
@@ -224,7 +223,6 @@ export const HandleBackup = ({
: "", : "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
includeEncryptionKey: true,
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined, keepLatestCount: undefined,
@@ -264,7 +262,6 @@ 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,
@@ -312,7 +309,6 @@ 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,
@@ -413,7 +409,7 @@ export const HandleBackup = ({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -669,31 +665,6 @@ 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", "w-full justify-between bg-input!",
!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", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >

View File

@@ -97,23 +97,17 @@ export const getLogType = (message: string): LogStyle => {
return LOG_STYLES.info; return LOG_STYLES.info;
} }
// Key/value pairs that explicitly report a non-error (e.g. "error: none",
// "failed: false") must not trigger the error keyword patterns below
const nonErrorKeyValues =
/\b(?:error|err|errors|failed|failure|failures)s?\s*[:=]\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi;
const errorScope = lowerMessage.replace(nonErrorKeyValues, "");
if ( if (
/(?:^|\s)(?:error|err):?\s/i.test(errorScope) || /(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
/\b(?:exception|failed|failure)\b/i.test(errorScope) || /\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
/(?:stack\s?trace):\s*$/i.test(errorScope) || /(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) || /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) || /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) || /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) || /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
/\[(?:error|err|fatal)\]/i.test(errorScope) || /\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
/\b(?:crash|critical|fatal)\b/i.test(errorScope) || /\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope) /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
) { ) {
return LOG_STYLES.error; return LOG_STYLES.error;
} }

View File

@@ -1,13 +1,10 @@
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";
@@ -40,7 +37,6 @@ export const columns: ColumnDef<Container>[] = [
}, },
{ {
accessorKey: "state", accessorKey: "state",
filterFn: "equals",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -60,7 +56,7 @@ export const columns: ColumnDef<Container>[] = [
variant={ variant={
value === "running" value === "running"
? "default" ? "default"
: value === "exited" || value === "dead" : value === "failed"
? "destructive" ? "destructive"
: "secondary" : "secondary"
} }
@@ -103,28 +99,6 @@ 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,
@@ -141,14 +115,6 @@ 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, RefreshCw } from "lucide-react"; import { ChevronDown, Container } 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,13 +26,6 @@ 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,
@@ -51,26 +44,10 @@ 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, refetch, isRefetching } = const { data, isPending } = api.docker.getContainers.useQuery({
api.docker.getContainers.useQuery( serverId,
{ });
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>(
@@ -129,48 +106,12 @@ 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 variant="outline" className="max-sm:w-full"> <Button
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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
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

@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Sqld Node</Label> <Label>Sqld Node</Label>
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label> <Label>Enable Namespaces</Label>

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
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

@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Database Name</Label> <Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} /> <Input disabled value={data?.databaseName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <div className="flex flex-col gap-2 md:col-span-2">

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
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

@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <div className="flex flex-col gap-2 md:col-span-2">

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
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

@@ -25,11 +25,11 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Database Name</Label> <Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} /> <Input disabled value={data?.databaseName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <div className="flex flex-col gap-2 md:col-span-2">

View File

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

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
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

@@ -25,11 +25,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Database Name</Label> <Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} /> <Input disabled value={data?.databaseName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
@@ -57,7 +57,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -62,7 +62,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mariadb: "mariadb:11", mariadb: "mariadb:11",
mysql: "mysql:8", mysql: "mysql:8",
postgres: "postgres:18", postgres: "postgres:18",
redis: "redis:8", redis: "redis:7",
}; };
const databasesUserDefaultPlaceholder: Record< const databasesUserDefaultPlaceholder: Record<

View File

@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[]; envVariables: EnvVariable[];
shortDescription: string; shortDescription: string;
domains: Domain[]; domains: Domain[];
configFiles?: Mount[] | null; configFiles?: Mount[];
} }
interface Mount { interface Mount {

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
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

@@ -25,7 +25,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value="default" /> <Input disabled value="default" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <div className="flex flex-col gap-2 md:col-span-2">

View File

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

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

View File

@@ -131,10 +131,7 @@ export const HandleAi = ({ aiId }: Props) => {
const apiUrl = form.watch("apiUrl"); const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey"); const apiKey = form.watch("apiKey");
// Any Ollama instance on the default port 11434 is treated as no-auth const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
// (covers localhost and self-hosted LAN deployments). Ollama Cloud
// (ollama.com on 443) falls through and requires an API key.
const isLocalOllama = apiUrl.includes(":11434");
const { const {
data: models, data: models,
isFetching: isLoadingServerModels, isFetching: isLoadingServerModels,
@@ -145,7 +142,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiKey: apiKey ?? "", apiKey: apiKey ?? "",
}, },
{ {
enabled: !!apiUrl && (isLocalOllama || !!apiKey), enabled: !!apiUrl && (isOllama || !!apiKey),
}, },
); );
@@ -278,7 +275,7 @@ export const HandleAi = ({ aiId }: Props) => {
)} )}
/> />
{!isLocalOllama && ( {!isOllama && (
<FormField <FormField
control={form.control} control={form.control}
name="apiKey" name="apiKey"

View File

@@ -4,7 +4,9 @@ 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";
const MAX_CONCURRENCY = 100; // Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
interface Props { interface Props {
/** /**
@@ -18,10 +20,14 @@ 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). Not shown in cloud. * (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
*/ */
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 ?? "" },
@@ -53,7 +59,10 @@ 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 clamp = (n: number) => Math.min(MAX_CONCURRENCY, Math.max(1, n)); const max = haveValidLicense
? 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);
@@ -92,7 +101,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
<Input <Input
type="number" type="number"
min={1} min={1}
max={MAX_CONCURRENCY} max={max}
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,7 +97,6 @@ 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">
@@ -174,19 +173,6 @@ 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 asChild> <AlertDialogDescription className="space-y-2">
<div className="space-y-2"> <p>This action will:</p>
<p>This action will:</p> <ul className="list-disc list-inside space-y-1">
<ul className="list-disc list-inside space-y-1"> <li>Stop the current database service</li>
<li>Stop the current database service</li> <li>Delete all existing data and volumes</li>
<li>Delete all existing data and volumes</li> <li>Reset to the default configuration</li>
<li>Reset to the default configuration</li> <li>Restart the service with a clean state</li>
<li>Restart the service with a clean state</li> </ul>
</ul> <p className="font-medium text-destructive mt-4">
<p className="font-medium text-destructive mt-4"> This action cannot be undone.
This action cannot be undone. </p>
</p>
</div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleRebuild} onClick={handleRebuild}
disabled={isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive" asChild
> >
Yes, rebuild database <Button isLoading={isPending} type="submit">
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"> <Button variant="outline" size="sm" className="w-full">
<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"> <Button variant="outline" size="sm" className="w-full">
<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 gap-4"> <div className="flex justify-end w-full space-x-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"> <Button variant="outline" size="sm" className="w-full">
<Settings className="h-4 w-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
Config Config
</Button> </Button>

View File

@@ -41,7 +41,6 @@ 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,
@@ -565,8 +564,6 @@ 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();
@@ -632,14 +629,9 @@ function SidebarLogo() {
isCollapsed && "hidden", isCollapsed && "hidden",
)} )}
> >
<div className="flex items-center gap-1.5"> <p className="text-sm font-medium leading-none">
<p className="text-sm font-medium leading-none"> {activeOrganization?.name ?? "Select Organization"}
{activeOrganization?.name ?? "Select Organization"} </p>
</p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
)}
</div>
</div> </div>
</div> </div>
<ChevronsUpDown <ChevronsUpDown

View File

@@ -1,235 +0,0 @@
"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,7 +2,6 @@
import { import {
Eye, Eye,
KeyRound,
Loader2, Loader2,
LogIn, LogIn,
Pencil, Pencil,
@@ -35,7 +34,6 @@ 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;
@@ -171,22 +169,15 @@ 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>
<div className="flex flex-wrap gap-2 shrink-0"> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onClick={() => setManageOriginsOpen(true)}
onClick={() => setManageOriginsOpen(true)} className="shrink-0"
> >
<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,10 +94,9 @@ 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">
{tags.length === 0 No tags found.
? "No tags created yet."
: "No tags found."}
</span> </span>
<HandleTag />
</div> </div>
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
@@ -119,9 +118,6 @@ 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,12 +111,19 @@ 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">
{tags.length === 0 No tags found.
? "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);
@@ -146,9 +153,6 @@ 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,7 +1,8 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui"; import { Accordion as AccordionPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
function Accordion({ function Accordion({
className, className,

View File

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

View File

@@ -1,4 +1,4 @@
import type * as React from "react"; import * 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 type { TooltipValueType } from "recharts";
import * as RechartsPrimitive from "recharts"; import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

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

View File

@@ -1,8 +1,9 @@
"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 type * as React from "react"; import { cn } from "@/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,7 +12,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 { cn } from "@/lib/utils"; import { SearchIcon, CheckIcon } from "lucide-react";
function Command({ function Command({
className, className,
@@ -44,6 +45,10 @@ 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",
@@ -51,11 +56,7 @@ function CommandDialog({
)} )}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
<DialogHeader className="sr-only"> {children}
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command>{children}</Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -67,7 +68,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! 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"> <InputGroup className="h-11! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
@@ -121,7 +122,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 in-data-[slot=dialog-content]:**:[[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0", "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",
className, className,
)} )}
{...props} {...props}
@@ -151,7 +152,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 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", "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",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { CheckIcon, ChevronRightIcon } from "lucide-react"; import * as React from "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 { XIcon } from "lucide-react"; import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui"; 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"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ function Dialog({
...props ...props
@@ -49,8 +49,6 @@ 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;
@@ -64,20 +62,6 @@ 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,25 +1,13 @@
import { CheckIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; 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"; import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({ function DropdownMenu({
onOpenChange,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return ( return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
<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 { type Label as LabelPrimitive, Slot } from "radix-ui"; import { Label as LabelPrimitive, Slot } from "radix-ui";
import * as React from "react"; import * as React from "react";
import { import {
Controller, Controller,

View File

@@ -1,11 +1,12 @@
"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,7 +1,8 @@
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MinusIcon } from "lucide-react";
function InputOTP({ function InputOTP({
className, className,

View File

@@ -1,17 +1,13 @@
import copy from "copy-to-clipboard"; import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import { Clipboard, EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import { generateRandomPassword } from "@/lib/password-utils"; import { generateRandomPassword } from "@/lib/password-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "./button";
export interface InputProps extends React.ComponentProps<"input"> { export interface InputProps extends React.ComponentProps<"input"> {
errorMessage?: string; errorMessage?: string;
enablePasswordGenerator?: boolean; enablePasswordGenerator?: boolean;
passwordGeneratorLength?: number; passwordGeneratorLength?: number;
enableCopyButton?: boolean;
} }
function Input({ function Input({
@@ -20,7 +16,6 @@ function Input({
errorMessage, errorMessage,
enablePasswordGenerator = false, enablePasswordGenerator = false,
passwordGeneratorLength, passwordGeneratorLength,
enableCopyButton = false,
ref, ref,
...props ...props
}: InputProps) { }: InputProps) {
@@ -70,67 +65,49 @@ function Input({
input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
}; };
const handleCopy = () => { return (
copy(inputRef.current?.value || ""); <>
toast.success("Value is copied to clipboard"); <div className="relative w-full">
}; <input
type={inputType}
const inputElement = ( data-slot="input"
<div className="relative w-full"> className={cn(
<input "h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
type={inputType} isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
data-slot="input" className,
className={cn( )}
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", ref={setRefs}
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"), {...props}
className, />
)} {isPassword && (
ref={setRefs} <div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
{...props} {shouldShowGenerator && (
/> <button
{isPassword && ( type="button"
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground"> className="hover:text-foreground focus:outline-none"
{shouldShowGenerator && ( onClick={handleGeneratePassword}
aria-label="Generate password"
title="Generate password"
tabIndex={-1}
>
<RefreshCcw className="h-4 w-4" />
</button>
)}
<button <button
type="button" type="button"
className="hover:text-foreground focus:outline-none" className="hover:text-foreground focus:outline-none"
onClick={handleGeneratePassword} onClick={() => setShowPassword(!showPassword)}
aria-label="Generate password"
title="Generate password"
tabIndex={-1} tabIndex={-1}
> >
<RefreshCcw className="h-4 w-4" /> {showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button> </button>
)} </div>
<button )}
type="button" </div>
className="hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
);
return (
<>
{enableCopyButton ? (
<div className="flex w-full items-center space-x-2">
{inputElement}
<Button type="button" variant={"secondary"} onClick={handleCopy}>
<Clipboard className="size-4 text-muted-foreground" />
</Button>
</div>
) : (
inputElement
)}
{errorMessage && ( {errorMessage && (
<span className="text-sm text-red-600 text-secondary-foreground"> <span className="text-sm text-red-600 text-secondary-foreground">
{errorMessage} {errorMessage}

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

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

View File

@@ -1,26 +1,14 @@
"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 ( return <PopoverPrimitive.Root data-slot="popover" {...props} />;
<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,7 +1,8 @@
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "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
@@ -57,7 +58,7 @@ function SelectTrigger({
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "popper", position = "item-aligned",
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,8 +1,9 @@
import { XIcon } from "lucide-react"; import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui"; import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
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-y-auto overflow-x-hidden", "no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-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,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon, TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} 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 type * as React from "react"; import * 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 type * as React from "react"; import * 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

@@ -1,13 +0,0 @@
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

@@ -1 +0,0 @@
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,20 +1212,6 @@
"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,23 +0,0 @@
import { z } from "zod";
/**
* Maximum length allowed for an API key name.
*
* This mirrors the default `maximumNameLength` enforced by the
* `@better-auth/api-key` plugin. Names longer than this are rejected by
* better-auth with a 400, so we validate against it up front to surface a
* clear field-level error instead of an opaque 500.
*/
export const API_KEY_NAME_MAX_LENGTH = 32;
/**
* Shared validation for an API key name, used by both the tRPC input schema
* and the client form so the two can't drift.
*/
export const apiKeyNameSchema = z
.string()
.min(1, "Name is required")
.max(
API_KEY_NAME_MAX_LENGTH,
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.29.12", "version": "v0.29.8",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -47,9 +47,8 @@
"@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.6.23", "@better-auth/api-key": "1.5.4",
"@better-auth/scim": "1.6.23", "@better-auth/sso": "1.5.4",
"@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",
@@ -83,7 +82,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.6.23", "better-auth": "1.5.4",
"bl": "6.0.11", "bl": "6.0.11",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"bullmq": "5.67.3", "bullmq": "5.67.3",
@@ -95,7 +94,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.2", "drizzle-orm": "0.45.1",
"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,9 +1039,7 @@ 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" />
<p className="text-base font-medium max-w-[250px] truncate"> {currentEnvironment.project.name}
{currentEnvironment.project.name}
</p>
<AdvancedEnvironmentSelector <AdvancedEnvironmentSelector
projectId={projectId} projectId={projectId}
currentEnvironmentId={environmentId} currentEnvironmentId={environmentId}
@@ -1880,7 +1878,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,6 +29,8 @@ 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

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

View File

@@ -32,7 +32,6 @@ 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";
@@ -95,7 +94,6 @@ 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,

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