mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-25 06:22:19 +00:00
Merge remote-tracking branch 'origin/canary' into feat/add-github-provider-baseurl
# Conflicts: # apps/dokploy/drizzle/meta/0177_snapshot.json # apps/dokploy/drizzle/meta/_journal.json
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const createApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
appName: "test-app",
|
||||
buildType: "railpack",
|
||||
sourceType: "git",
|
||||
buildPath: "/",
|
||||
railpackVersion: "0.15.4",
|
||||
env: "TEST_VAR=one",
|
||||
cleanCache: false,
|
||||
environment: {
|
||||
project: {
|
||||
env: "",
|
||||
},
|
||||
env: "",
|
||||
},
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
|
||||
const getSecretsHash = (command: string) => {
|
||||
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error("secrets-hash build arg was not found");
|
||||
}
|
||||
|
||||
return match[1];
|
||||
};
|
||||
|
||||
describe("getRailpackCommand", () => {
|
||||
it("includes secrets-hash without clean cache", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).not.toContain("cache-key=");
|
||||
});
|
||||
|
||||
it("includes cache-key only when clean cache is enabled", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({
|
||||
cleanCache: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=one",
|
||||
}),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=two",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes secrets-hash when referenced project or environment values change", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=one",
|
||||
},
|
||||
env: "SHARED_VALUE=alpha",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=two",
|
||||
},
|
||||
env: "SHARED_VALUE=beta",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
NODE_ENV=production
|
||||
POSTGRES_HOST=postgres.internal
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_URL=redis://redis.internal:6379
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
REDIS_URL=\${{environment.REDIS_URL}}
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
/**
|
||||
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
|
||||
* the service env, the environment env and the project env captured at deploy time.
|
||||
*/
|
||||
const fullContext = {
|
||||
env: serviceEnv,
|
||||
environment: {
|
||||
env: environmentEnv,
|
||||
project: {
|
||||
env: projectEnv,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("prepareEnvironmentVariables for application rollback", () => {
|
||||
it("resolves environment variables from the rollback snapshot", () => {
|
||||
const result = prepareEnvironmentVariables(
|
||||
fullContext.env,
|
||||
fullContext.environment.project.env,
|
||||
fullContext.environment.env,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=production",
|
||||
"REDIS_URL=redis://redis.internal:6379",
|
||||
"PORT=3000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves project and environment variables together on rollback", () => {
|
||||
const rollbackEnv = `
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
"POSTGRES_URL=postgres://postgres.internal:5432/app",
|
||||
"ENVIRONMENT=staging",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the environment env of the snapshot is not passed", () => {
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables(fullContext.env, projectEnv),
|
||||
).toThrow("Invalid environment variable: environment.NODE_ENV");
|
||||
});
|
||||
|
||||
it("maintains precedence: service > environment > project on rollback", () => {
|
||||
const conflictingProjectEnv = `
|
||||
NODE_ENV=project
|
||||
API_URL=https://project.api.com
|
||||
`;
|
||||
|
||||
const conflictingEnvironmentEnv = `
|
||||
NODE_ENV=environment
|
||||
API_URL=https://environment.api.com
|
||||
`;
|
||||
|
||||
const rollbackEnv = `
|
||||
NODE_ENV=service
|
||||
PROJECT_API_URL=\${{project.API_URL}}
|
||||
ENVIRONMENT_API_URL=\${{environment.API_URL}}
|
||||
SELF_REFERENCE=\${{NODE_ENV}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=service",
|
||||
"PROJECT_API_URL=https://project.api.com",
|
||||
"ENVIRONMENT_API_URL=https://environment.api.com",
|
||||
"SELF_REFERENCE=service",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,9 @@ export const ShowDeployments = ({
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const { data: deployments, isPending: isLoadingDeployments } =
|
||||
api.deployment.allByType.useQuery(
|
||||
{
|
||||
@@ -81,7 +84,7 @@ export const ShowDeployments = ({
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
||||
const { mutateAsync: removeDeployment } =
|
||||
api.deployment.removeDeployment.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
@@ -408,6 +411,11 @@ export const ShowDeployments = ({
|
||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setRemovingDeploymentIds((deploymentIds) => {
|
||||
const nextDeploymentIds = new Set(deploymentIds);
|
||||
nextDeploymentIds.add(deployment.deploymentId);
|
||||
return nextDeploymentIds;
|
||||
});
|
||||
try {
|
||||
await removeDeployment({
|
||||
deploymentId: deployment.deploymentId,
|
||||
@@ -415,13 +423,25 @@ export const ShowDeployments = ({
|
||||
toast.success("Deployment deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Error deleting deployment");
|
||||
} finally {
|
||||
setRemovingDeploymentIds((deploymentIds) => {
|
||||
const nextDeploymentIds = new Set(
|
||||
deploymentIds,
|
||||
);
|
||||
nextDeploymentIds.delete(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
return nextDeploymentIds;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRemovingDeployment}
|
||||
isLoading={removingDeploymentIds.has(
|
||||
deployment.deploymentId,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
+9
-5
@@ -447,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -497,14 +497,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -553,14 +557,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -467,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -521,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const handleRunManually = async (scheduleId: string) => {
|
||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
||||
try {
|
||||
await runManually({ scheduleId });
|
||||
toast.success("Schedule run successfully");
|
||||
const result = await runManually({ scheduleId });
|
||||
if (result.status === "error") {
|
||||
toast.error("Schedule run failed, check the deployment logs");
|
||||
} else {
|
||||
toast.success("Schedule run successfully");
|
||||
}
|
||||
await refetchSchedules();
|
||||
} catch {
|
||||
toast.error("Error running schedule");
|
||||
|
||||
+9
-5
@@ -451,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -506,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -442,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -489,14 +489,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -548,14 +552,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,14 @@ const networkFormSchema = z
|
||||
attachable: z.boolean(),
|
||||
enableIPv4: z.boolean(),
|
||||
enableIPv6: z.boolean(),
|
||||
mtu: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) =>
|
||||
value === "" ||
|
||||
(/^\d+$/.test(value) && +value >= 68 && +value <= 65535),
|
||||
{ message: "MTU must be a number between 68 and 65535" },
|
||||
),
|
||||
ipamDriver: z.string().optional(),
|
||||
ipamConfig: z.array(ipamConfigEntrySchema),
|
||||
})
|
||||
@@ -85,6 +93,7 @@ const defaultValues: NetworkFormValues = {
|
||||
attachable: false,
|
||||
enableIPv4: true,
|
||||
enableIPv6: false,
|
||||
mtu: "",
|
||||
ipamDriver: "",
|
||||
ipamConfig: [],
|
||||
};
|
||||
@@ -147,6 +156,7 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
|
||||
attachable: data.attachable,
|
||||
enableIPv4: data.enableIPv4,
|
||||
enableIPv6: data.enableIPv6,
|
||||
mtu: data.mtu ? Number(data.mtu) : undefined,
|
||||
ipam: {
|
||||
driver: data.ipamDriver || undefined,
|
||||
config: data.ipamConfig,
|
||||
@@ -232,6 +242,27 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mtu"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>MTU (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="1500"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Maximum transmission unit. Leave empty to use Docker's
|
||||
default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{toggleOptions.map((option) => (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Fingerprint, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ManagePasskeys = () => {
|
||||
const utils = api.useUtils();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { data: passkeys, isLoading } = api.user.listPasskeys.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isDialogOpen,
|
||||
},
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleAddPasskey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const result = await authClient.passkey.addPasskey({
|
||||
name: name.trim() || undefined,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Passkey added successfully");
|
||||
setName("");
|
||||
utils.user.listPasskeys.invalidate();
|
||||
} catch {
|
||||
toast.error("Failed to add passkey");
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const result = await authClient.passkey.deletePasskey({ id });
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Passkey removed");
|
||||
utils.user.listPasskeys.invalidate();
|
||||
} catch {
|
||||
toast.error("Failed to remove passkey");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
<Fingerprint className="size-4 text-muted-foreground" />
|
||||
Passkeys
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Passkeys</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sign in without a password using your device's biometrics, security
|
||||
key, or password manager.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[10vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : passkeys && passkeys.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.id}
|
||||
className="flex items-center justify-between gap-2 p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<Fingerprint className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">
|
||||
{passkey.name || "Unnamed passkey"}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{passkey.deviceType === "singleDevice"
|
||||
? "Device"
|
||||
: "Synced"}
|
||||
</Badge>
|
||||
</span>
|
||||
{passkey.createdAt && (
|
||||
<DateTooltip
|
||||
date={passkey.createdAt.toString()}
|
||||
className="text-sm"
|
||||
>
|
||||
Added
|
||||
</DateTooltip>
|
||||
)}
|
||||
</div>
|
||||
<DialogAction
|
||||
title="Remove this passkey?"
|
||||
description="You will no longer be able to sign in with it. This action cannot be undone."
|
||||
onClick={() => handleDeletePasskey(passkey.id)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
isLoading={deletingId === passkey.id}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-sm text-muted-foreground border rounded-lg">
|
||||
<Fingerprint className="size-6" />
|
||||
<span>No passkeys registered yet</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddPasskey} className="flex flex-col gap-2">
|
||||
<Label htmlFor="passkey-name">Passkey Name</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="passkey-name"
|
||||
placeholder="e.g. MacBook Touch ID"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" isLoading={isAdding}>
|
||||
<Plus className="size-4" />
|
||||
Add Passkey
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { Configure2FA } from "./configure-2fa";
|
||||
import { Enable2FA } from "./enable-2fa";
|
||||
import { ManagePasskeys } from "./manage-passkeys";
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z
|
||||
@@ -162,7 +163,10 @@ export const ProfileForm = () => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
<ManagePasskeys />
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
|
||||
defaultOpen ?? false,
|
||||
);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : uncontrolledOpen;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
open={isOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
// Radix closes menus on window blur, unmounting any dialog rendered inside
|
||||
if (!nextOpen && !document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(nextOpen);
|
||||
}
|
||||
if (!nextOpen) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -464,7 +464,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -473,8 +473,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
default: "h-8 text-sm group-data-[collapsible=icon]:p-2!",
|
||||
sm: "h-7 text-xs group-data-[collapsible=icon]:p-2!",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "passkey" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"public_key" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"credential_id" text NOT NULL,
|
||||
"counter" integer NOT NULL,
|
||||
"device_type" text NOT NULL,
|
||||
"backed_up" boolean NOT NULL,
|
||||
"transports" text,
|
||||
"created_at" timestamp,
|
||||
"aaguid" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "passkey_credentialID_idx" ON "passkey" USING btree ("credential_id");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "network" ADD COLUMN "mtu" integer;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "6ff8258f-9531-4915-afd1-71c71fc2d342",
|
||||
"id": "6b0d67f3-45fa-4c6c-917b-c944bcf0deca",
|
||||
"prevId": "383b87f1-fb41-4d41-9539-a87a3b2b677e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -762,6 +762,130 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.passkey": {
|
||||
"name": "passkey",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"credential_id": {
|
||||
"name": "credential_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"counter": {
|
||||
"name": "counter",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"device_type": {
|
||||
"name": "device_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"backed_up": {
|
||||
"name": "backed_up",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"transports": {
|
||||
"name": "transports",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"aaguid": {
|
||||
"name": "aaguid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"passkey_userId_idx": {
|
||||
"name": "passkey_userId_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"passkey_credentialID_idx": {
|
||||
"name": "passkey_credentialID_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "credential_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"passkey_user_id_user_id_fk": {
|
||||
"name": "passkey_user_id_user_id_fk",
|
||||
"tableFrom": "passkey",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.two_factor": {
|
||||
"name": "two_factor",
|
||||
"schema": "",
|
||||
@@ -3671,13 +3795,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"githubUrl": {
|
||||
"name": "githubUrl",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'https://github.com'"
|
||||
},
|
||||
"gitProviderId": {
|
||||
"name": "gitProviderId",
|
||||
"type": "text",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1244,8 +1244,15 @@
|
||||
{
|
||||
"idx": 177,
|
||||
"version": "7",
|
||||
"when": 1785438450780,
|
||||
"tag": "0177_damp_bushwacker",
|
||||
"when": 1785709848209,
|
||||
"tag": "0177_glossy_brother_voodoo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 178,
|
||||
"version": "7",
|
||||
"when": 1785751631838,
|
||||
"tag": "0178_silky_randall",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import {
|
||||
adminClient,
|
||||
@@ -13,6 +14,7 @@ export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
organizationClient(),
|
||||
twoFactorClient(),
|
||||
passkeyClient(),
|
||||
apiKeyClient(),
|
||||
ssoClient(),
|
||||
adminClient(),
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
|
||||
@@ -5,12 +5,11 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
error?: Error;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export default function Custom404({ statusCode, error }: Props) {
|
||||
const displayStatusCode = statusCode || 400;
|
||||
export default function ErrorPage({ statusCode }: Props) {
|
||||
const displayStatusCode = statusCode || 500;
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
@@ -45,12 +44,6 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
{errorDescription}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 text-red-500">
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
|
||||
<Link
|
||||
href="/dashboard/home"
|
||||
@@ -101,8 +94,7 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
Error.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { statusCode, error: err };
|
||||
ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? (err.statusCode ?? 500) : 404;
|
||||
return { statusCode };
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -68,6 +69,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||
const [isTwoFactor, setIsTwoFactor] = useState(false);
|
||||
@@ -123,6 +125,34 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
setIsLoginLoading(false);
|
||||
}
|
||||
};
|
||||
const onPasskeySignIn = async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.passkey();
|
||||
|
||||
if (error) {
|
||||
const errorCode = "code" in error ? error.code : undefined;
|
||||
if (
|
||||
errorCode !== "AUTH_CANCELLED" &&
|
||||
errorCode !== "ERROR_CEREMONY_ABORTED"
|
||||
) {
|
||||
toast.error(error.message || "Failed to sign in with passkey");
|
||||
setError(error.message || "Failed to sign in with passkey");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred while signing in with passkey");
|
||||
} finally {
|
||||
setIsPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (twoFactorCode.length !== 6) {
|
||||
@@ -227,6 +257,16 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4"
|
||||
type="button"
|
||||
onClick={onPasskeySignIn}
|
||||
isLoading={isPasskeyLoading}
|
||||
>
|
||||
<Fingerprint className="size-4" />
|
||||
Sign in with Passkey
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deleteAllMiddlewares,
|
||||
findApplicationById,
|
||||
findEnvironmentById,
|
||||
findPreviewDeploymentsByApplicationId,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
removeDeployments,
|
||||
removeDirectoryCode,
|
||||
removeMonitoringDirectory,
|
||||
removePreviewDeployment,
|
||||
removeService,
|
||||
removeTraefikConfig,
|
||||
startService,
|
||||
@@ -234,6 +236,15 @@ export const applicationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const previewDeploymentsList =
|
||||
await findPreviewDeploymentsByApplicationId(input.applicationId);
|
||||
|
||||
for (const previewDeployment of previewDeploymentsList) {
|
||||
try {
|
||||
await removePreviewDeployment(previewDeployment.previewDeploymentId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(applications)
|
||||
.where(eq(applications.applicationId, input.applicationId))
|
||||
|
||||
@@ -329,13 +329,17 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkPermission(ctx, { schedule: ["create"] });
|
||||
}
|
||||
try {
|
||||
await runCommand(input.scheduleId);
|
||||
const deployment = await runCommand(input.scheduleId);
|
||||
await audit(ctx, {
|
||||
action: "run",
|
||||
resourceType: "schedule",
|
||||
resourceId: input.scheduleId,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
status: deployment.status,
|
||||
deploymentId: deployment.deploymentId,
|
||||
logPath: deployment.logPath,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createOrganizationUserWithCredentials,
|
||||
findNotificationById,
|
||||
findOrganizationById,
|
||||
findPasskeysByUserId,
|
||||
findUserById,
|
||||
getDokployUrl,
|
||||
getUserByToken,
|
||||
@@ -173,6 +174,9 @@ export const userRouter = createTRPCRouter({
|
||||
getPermissions: protectedProcedure.query(async ({ ctx }) => {
|
||||
return resolvePermissions(ctx);
|
||||
}),
|
||||
listPasskeys: protectedProcedure.query(async ({ ctx }) => {
|
||||
return findPasskeysByUserId(ctx.user.id);
|
||||
}),
|
||||
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user