mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-22 06:15:24 +02:00
Compare commits
71 Commits
fix/preven
...
feat/add-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d266b0840 | ||
|
|
54229b0dcd | ||
|
|
6b42c9d142 | ||
|
|
7665b38b79 | ||
|
|
d5de5b8ad7 | ||
|
|
fa201a5a96 | ||
|
|
d22d96105c | ||
|
|
bc5c65b2d2 | ||
|
|
431ad914f8 | ||
|
|
0575fabb0f | ||
|
|
385a494c83 | ||
|
|
d3f0bf654b | ||
|
|
9e8dacfe06 | ||
|
|
f450b13dc5 | ||
|
|
9e80bf45d0 | ||
|
|
a635908e43 | ||
|
|
960892fd8d | ||
|
|
acb3c1d238 | ||
|
|
68587c3c8b | ||
|
|
cae7a92599 | ||
|
|
f3d9960b7f | ||
|
|
66b4bf2c4e | ||
|
|
c4515a2ca4 | ||
|
|
1f33b0fd24 | ||
|
|
3c2f675eb9 | ||
|
|
61f6bbfe1c | ||
|
|
8caae549b2 | ||
|
|
30c3e44422 | ||
|
|
f72bc28d70 | ||
|
|
82c06a487a | ||
|
|
12a87f9f8b | ||
|
|
9a8de9ae16 | ||
|
|
6064b8ca48 | ||
|
|
7f27601f7f | ||
|
|
2e7f4dc1a2 | ||
|
|
2b52332e43 | ||
|
|
346216fc71 | ||
|
|
c9ffb99808 | ||
|
|
cbfa690a80 | ||
|
|
262960a59a | ||
|
|
709ffddd4f | ||
|
|
0c299a3807 | ||
|
|
25fa362cdb | ||
|
|
f680818b56 | ||
|
|
20226a300c | ||
|
|
5f5c4f0e18 | ||
|
|
c579dbeb1c | ||
|
|
cee1dc97ba | ||
|
|
b9419ed5f1 | ||
|
|
6bc07d7675 | ||
|
|
f72dfb3fc7 | ||
|
|
27a0490536 | ||
|
|
ec6849205a | ||
|
|
9934346d8c | ||
|
|
5c89973cc2 | ||
|
|
4e8cdfbc80 | ||
|
|
d0ea8b5283 | ||
|
|
060a053fdb | ||
|
|
304069d3c8 | ||
|
|
f3bb56910a | ||
|
|
6fdb2e4a21 | ||
|
|
74aecf6828 | ||
|
|
bcbf433607 | ||
|
|
7db1f3a69a | ||
|
|
67f0c93298 | ||
|
|
046c52529b | ||
|
|
27dd20b75d | ||
|
|
3142818cf2 | ||
|
|
d8465ac251 | ||
|
|
c33b41d082 | ||
|
|
3eea875932 |
@@ -35,4 +35,5 @@ COPY --from=build /prod/schedules/dist ./dist
|
|||||||
COPY --from=build /prod/schedules/package.json ./package.json
|
COPY --from=build /prod/schedules/package.json ./package.json
|
||||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
@@ -35,4 +35,5 @@ COPY --from=build /prod/api/dist ./dist
|
|||||||
COPY --from=build /prod/api/package.json ./package.json
|
COPY --from=build /prod/api/package.json ./package.json
|
||||||
COPY --from=build /prod/api/node_modules ./node_modules
|
COPY --from=build /prod/api/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const baseApp: ApplicationNested = {
|
|||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
createEnvFile: true,
|
||||||
|
bitbucketRepositorySlug: "",
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
buildServerId: "",
|
buildServerId: "",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const baseApp: ApplicationNested = {
|
|||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
createEnvFile: true,
|
||||||
|
bitbucketRepositorySlug: "",
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ interface HealthCheckFormProps {
|
|||||||
|
|
||||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [testCommands, setTestCommands] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
@@ -72,6 +71,8 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testCommands = form.watch("Test") || [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.healthCheckSwarm) {
|
if (data?.healthCheckSwarm) {
|
||||||
const hc = data.healthCheckSwarm;
|
const hc = data.healthCheckSwarm;
|
||||||
@@ -82,7 +83,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
StartPeriod: hc.StartPeriod,
|
StartPeriod: hc.StartPeriod,
|
||||||
Retries: hc.Retries,
|
Retries: hc.Retries,
|
||||||
});
|
});
|
||||||
setTestCommands(hc.Test || []);
|
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
@@ -117,17 +117,20 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addTestCommand = () => {
|
const addTestCommand = () => {
|
||||||
setTestCommands([...testCommands, ""]);
|
form.setValue("Test", [...testCommands, ""]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTestCommand = (index: number, value: string) => {
|
const updateTestCommand = (index: number, value: string) => {
|
||||||
const newCommands = [...testCommands];
|
const newCommands = [...testCommands];
|
||||||
newCommands[index] = value;
|
newCommands[index] = value;
|
||||||
setTestCommands(newCommands);
|
form.setValue("Test", newCommands);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTestCommand = (index: number) => {
|
const removeTestCommand = (index: number) => {
|
||||||
setTestCommands(testCommands.filter((_, i) => i !== index));
|
form.setValue(
|
||||||
|
"Test",
|
||||||
|
testCommands.filter((_: string, i: number) => i !== index),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +143,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
http://localhost:3000/health"])
|
http://localhost:3000/health"])
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<div className="space-y-2 mt-2">
|
<div className="space-y-2 mt-2">
|
||||||
{testCommands.map((cmd, index) => (
|
{testCommands.map((cmd: string, index: number) => (
|
||||||
<div key={index} className="flex gap-2">
|
<div key={index} className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={cmd}
|
value={cmd}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export { HealthCheckForm } from "./health-check-form";
|
|
||||||
export { RestartPolicyForm } from "./restart-policy-form";
|
|
||||||
export { PlacementForm } from "./placement-form";
|
|
||||||
export { UpdateConfigForm } from "./update-config-form";
|
|
||||||
export { RollbackConfigForm } from "./rollback-config-form";
|
|
||||||
export { ModeForm } from "./mode-form";
|
|
||||||
export { LabelsForm } from "./labels-form";
|
|
||||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
|
||||||
export { EndpointSpecForm } from "./endpoint-spec-form";
|
export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||||
|
export { HealthCheckForm } from "./health-check-form";
|
||||||
|
export { LabelsForm } from "./labels-form";
|
||||||
|
export { ModeForm } from "./mode-form";
|
||||||
|
export { PlacementForm } from "./placement-form";
|
||||||
|
export { RestartPolicyForm } from "./restart-policy-form";
|
||||||
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
export { filterEmptyValues, hasValues } from "./utils";
|
export { filterEmptyValues, hasValues } from "./utils";
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const PreferenceSchema = z.object({
|
const PreferenceSchema = z.object({
|
||||||
Spread: z.object({
|
SpreadDescriptor: z.string(),
|
||||||
SpreadDescriptor: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const PlatformSchema = z.object({
|
const PlatformSchema = z.object({
|
||||||
@@ -116,7 +114,14 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
placementSwarm: hasAnyValue ? formData : null,
|
placementSwarm: hasAnyValue
|
||||||
|
? {
|
||||||
|
...formData,
|
||||||
|
Preferences: formData.Preferences?.map((p) => ({
|
||||||
|
Spread: { SpreadDescriptor: p.SpreadDescriptor },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Placement updated successfully");
|
toast.success("Placement updated successfully");
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
repo: z.string().min(1, "Repo is required"),
|
repo: z.string().min(1, "Repo is required"),
|
||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
@@ -82,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
repository: {
|
repository: {
|
||||||
owner: "",
|
owner: "",
|
||||||
repo: "",
|
repo: "",
|
||||||
|
slug: "",
|
||||||
},
|
},
|
||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||||
{
|
{
|
||||||
owner: repository?.owner,
|
owner: repository?.owner,
|
||||||
repo: repository?.repo,
|
repo: repository?.slug || repository?.repo || "",
|
||||||
bitbucketId,
|
bitbucketId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
enabled:
|
||||||
|
!!repository?.owner &&
|
||||||
|
!!(repository?.slug || repository?.repo) &&
|
||||||
|
!!bitbucketId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
repository: {
|
repository: {
|
||||||
repo: data.bitbucketRepository || "",
|
repo: data.bitbucketRepository || "",
|
||||||
owner: data.bitbucketOwner || "",
|
owner: data.bitbucketOwner || "",
|
||||||
|
slug: data.bitbucketRepositorySlug || "",
|
||||||
},
|
},
|
||||||
buildPath: data.bitbucketBuildPath || "/",
|
buildPath: data.bitbucketBuildPath || "/",
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
bitbucketBranch: data.branch,
|
bitbucketBranch: data.branch,
|
||||||
bitbucketRepository: data.repository.repo,
|
bitbucketRepository: data.repository.repo,
|
||||||
|
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||||
bitbucketOwner: data.repository.owner,
|
bitbucketOwner: data.repository.owner,
|
||||||
bitbucketBuildPath: data.buildPath,
|
bitbucketBuildPath: data.buildPath,
|
||||||
bitbucketId: data.bitbucketId,
|
bitbucketId: data.bitbucketId,
|
||||||
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
repo: "",
|
repo: "",
|
||||||
|
slug: "",
|
||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
@@ -271,6 +279,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: repo.owner.username as string,
|
owner: repo.owner.username as string,
|
||||||
repo: repo.name,
|
repo: repo.name,
|
||||||
|
slug: repo.slug,
|
||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
repo: z.string().min(1, "Repo is required"),
|
repo: z.string().min(1, "Repo is required"),
|
||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
@@ -82,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
repository: {
|
repository: {
|
||||||
owner: "",
|
owner: "",
|
||||||
repo: "",
|
repo: "",
|
||||||
|
slug: "",
|
||||||
},
|
},
|
||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
@@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||||
{
|
{
|
||||||
owner: repository?.owner,
|
owner: repository?.owner,
|
||||||
repo: repository?.repo,
|
repo: repository?.slug || repository?.repo || "",
|
||||||
bitbucketId,
|
bitbucketId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
enabled:
|
||||||
|
!!repository?.owner &&
|
||||||
|
!!(repository?.slug || repository?.repo) &&
|
||||||
|
!!bitbucketId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
repository: {
|
repository: {
|
||||||
repo: data.bitbucketRepository || "",
|
repo: data.bitbucketRepository || "",
|
||||||
owner: data.bitbucketOwner || "",
|
owner: data.bitbucketOwner || "",
|
||||||
|
slug: data.bitbucketRepositorySlug || "",
|
||||||
},
|
},
|
||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
@@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
bitbucketBranch: data.branch,
|
bitbucketBranch: data.branch,
|
||||||
bitbucketRepository: data.repository.repo,
|
bitbucketRepository: data.repository.repo,
|
||||||
|
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||||
bitbucketOwner: data.repository.owner,
|
bitbucketOwner: data.repository.owner,
|
||||||
bitbucketId: data.bitbucketId,
|
bitbucketId: data.bitbucketId,
|
||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
@@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
repo: "",
|
repo: "",
|
||||||
|
slug: "",
|
||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
@@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
@@ -273,6 +281,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: repo.owner.username as string,
|
owner: repo.owner.username as string,
|
||||||
repo: repo.name,
|
repo: repo.name,
|
||||||
|
slug: repo.slug,
|
||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Docker Image</FormLabel>
|
<FormLabel>Docker Image</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="postgres:15" {...field} />
|
<Input placeholder="postgres:18" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
|||||||
mongo: "mongo:7",
|
mongo: "mongo:7",
|
||||||
mariadb: "mariadb:11",
|
mariadb: "mariadb:11",
|
||||||
mysql: "mysql:8",
|
mysql: "mysql:8",
|
||||||
postgres: "postgres:15",
|
postgres: "postgres:18",
|
||||||
redis: "redis:7",
|
redis: "redis:7",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
GotifyIcon,
|
GotifyIcon,
|
||||||
LarkIcon,
|
LarkIcon,
|
||||||
NtfyIcon,
|
NtfyIcon,
|
||||||
|
PushoverIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
@@ -114,6 +115,16 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
priority: z.number().min(1).max(5).default(3),
|
priority: z.number().min(1).max(5).default(3),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("pushover"),
|
||||||
|
userKey: z.string().min(1, { message: "User Key is required" }),
|
||||||
|
apiToken: z.string().min(1, { message: "API Token is required" }),
|
||||||
|
priority: z.number().min(-2).max(2).default(0),
|
||||||
|
retry: z.number().min(30).nullish(),
|
||||||
|
expire: z.number().min(1).max(10800).nullish(),
|
||||||
|
})
|
||||||
|
.merge(notificationBaseSchema),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("custom"),
|
type: z.literal("custom"),
|
||||||
@@ -166,6 +177,10 @@ export const notificationsMap = {
|
|||||||
icon: <NtfyIcon />,
|
icon: <NtfyIcon />,
|
||||||
label: "ntfy",
|
label: "ntfy",
|
||||||
},
|
},
|
||||||
|
pushover: {
|
||||||
|
icon: <PushoverIcon />,
|
||||||
|
label: "Pushover",
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||||
label: "Custom",
|
label: "Custom",
|
||||||
@@ -209,6 +224,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||||
api.notification.testCustomConnection.useMutation();
|
api.notification.testCustomConnection.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } =
|
||||||
|
api.notification.testPushoverConnection.useMutation();
|
||||||
|
|
||||||
const customMutation = notificationId
|
const customMutation = notificationId
|
||||||
? api.notification.updateCustom.useMutation()
|
? api.notification.updateCustom.useMutation()
|
||||||
: api.notification.createCustom.useMutation();
|
: api.notification.createCustom.useMutation();
|
||||||
@@ -233,6 +251,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
const larkMutation = notificationId
|
const larkMutation = notificationId
|
||||||
? api.notification.updateLark.useMutation()
|
? api.notification.updateLark.useMutation()
|
||||||
: api.notification.createLark.useMutation();
|
: api.notification.createLark.useMutation();
|
||||||
|
const pushoverMutation = notificationId
|
||||||
|
? api.notification.updatePushover.useMutation()
|
||||||
|
: api.notification.createPushover.useMutation();
|
||||||
|
|
||||||
const form = useForm<NotificationSchema>({
|
const form = useForm<NotificationSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -393,6 +414,23 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
|
} else if (notification.notificationType === "pushover") {
|
||||||
|
form.reset({
|
||||||
|
appBuildError: notification.appBuildError,
|
||||||
|
appDeploy: notification.appDeploy,
|
||||||
|
dokployRestart: notification.dokployRestart,
|
||||||
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
|
type: notification.notificationType,
|
||||||
|
userKey: notification.pushover?.userKey,
|
||||||
|
apiToken: notification.pushover?.apiToken,
|
||||||
|
priority: notification.pushover?.priority,
|
||||||
|
retry: notification.pushover?.retry ?? undefined,
|
||||||
|
expire: notification.pushover?.expire ?? undefined,
|
||||||
|
name: notification.name,
|
||||||
|
dockerCleanup: notification.dockerCleanup,
|
||||||
|
serverThreshold: notification.serverThreshold,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -408,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
custom: customMutation,
|
custom: customMutation,
|
||||||
|
pushover: pushoverMutation,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: NotificationSchema) => {
|
const onSubmit = async (data: NotificationSchema) => {
|
||||||
@@ -559,6 +598,28 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
notificationId: notificationId || "",
|
notificationId: notificationId || "",
|
||||||
customId: notification?.customId || "",
|
customId: notification?.customId || "",
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "pushover") {
|
||||||
|
if (data.priority === 2 && (data.retry == null || data.expire == null)) {
|
||||||
|
toast.error("Retry and expire are required for emergency priority (2)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promise = pushoverMutation.mutateAsync({
|
||||||
|
appBuildError: appBuildError,
|
||||||
|
appDeploy: appDeploy,
|
||||||
|
dokployRestart: dokployRestart,
|
||||||
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
|
userKey: data.userKey,
|
||||||
|
apiToken: data.apiToken,
|
||||||
|
priority: data.priority,
|
||||||
|
retry: data.priority === 2 ? data.retry : undefined,
|
||||||
|
expire: data.priority === 2 ? data.expire : undefined,
|
||||||
|
name: data.name,
|
||||||
|
dockerCleanup: dockerCleanup,
|
||||||
|
serverThreshold: serverThreshold,
|
||||||
|
notificationId: notificationId || "",
|
||||||
|
pushoverId: notification?.pushoverId || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -1255,6 +1316,147 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{type === "pushover" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="userKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="ub3de9kl2q..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="apiToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="a3d9k2q7m4..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
defaultValue={0}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="0"
|
||||||
|
value={field.value ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "" || value === "-") {
|
||||||
|
field.onChange(0);
|
||||||
|
} else {
|
||||||
|
const priority = Number.parseInt(value);
|
||||||
|
if (
|
||||||
|
!Number.isNaN(priority) &&
|
||||||
|
priority >= -2 &&
|
||||||
|
priority <= 2
|
||||||
|
) {
|
||||||
|
field.onChange(priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
min={-2}
|
||||||
|
max={2}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Message priority (-2 to 2, default: 0, emergency: 2)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.watch("priority") === 2 && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="retry"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Retry (seconds)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="30"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "") {
|
||||||
|
field.onChange(undefined);
|
||||||
|
} else {
|
||||||
|
const retry = Number.parseInt(value);
|
||||||
|
if (!Number.isNaN(retry)) {
|
||||||
|
field.onChange(retry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
How often (in seconds) to retry. Minimum 30
|
||||||
|
seconds.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expire"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Expire (seconds)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="3600"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "") {
|
||||||
|
field.onChange(undefined);
|
||||||
|
} else {
|
||||||
|
const expire = Number.parseInt(value);
|
||||||
|
if (!Number.isNaN(expire)) {
|
||||||
|
field.onChange(expire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10800}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
How long to keep retrying (max 10800 seconds / 3
|
||||||
|
hours).
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -1428,7 +1630,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingLark ||
|
isLoadingLark ||
|
||||||
isLoadingCustom
|
isLoadingCustom ||
|
||||||
|
isLoadingPushover
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1497,6 +1700,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
endpoint: data.endpoint,
|
endpoint: data.endpoint,
|
||||||
headers: headersRecord,
|
headers: headersRecord,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "pushover") {
|
||||||
|
if (
|
||||||
|
data.priority === 2 &&
|
||||||
|
(data.retry == null || data.expire == null)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Retry and expire are required for emergency priority (2)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await testPushoverConnection({
|
||||||
|
userKey: data.userKey,
|
||||||
|
apiToken: data.apiToken,
|
||||||
|
priority: data.priority,
|
||||||
|
retry: data.priority === 2 ? data.retry : undefined,
|
||||||
|
expire: data.priority === 2 ? data.expire : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const profileSchema = z.object({
|
|||||||
password: z.string().nullable(),
|
password: z.string().nullable(),
|
||||||
currentPassword: z.string().nullable(),
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
name: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
lastName: z.string().optional(),
|
lastName: z.string().optional(),
|
||||||
allowImpersonation: z.boolean().optional().default(false),
|
allowImpersonation: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
@@ -91,7 +91,7 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||||
name: data?.user?.firstName || "",
|
firstName: data?.user?.firstName || "",
|
||||||
lastName: data?.user?.lastName || "",
|
lastName: data?.user?.lastName || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -106,7 +106,7 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: form.getValues("currentPassword") || "",
|
currentPassword: form.getValues("currentPassword") || "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation,
|
allowImpersonation: data?.user?.allowImpersonation,
|
||||||
name: data?.user?.firstName || "",
|
firstName: data?.user?.firstName || "",
|
||||||
lastName: data?.user?.lastName || "",
|
lastName: data?.user?.lastName || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,7 +131,7 @@ export const ProfileForm = () => {
|
|||||||
image: values.image,
|
image: values.image,
|
||||||
currentPassword: values.currentPassword || undefined,
|
currentPassword: values.currentPassword || undefined,
|
||||||
allowImpersonation: values.allowImpersonation,
|
allowImpersonation: values.allowImpersonation,
|
||||||
name: values.name || undefined,
|
firstName: values.firstName || undefined,
|
||||||
lastName: values.lastName || undefined,
|
lastName: values.lastName || undefined,
|
||||||
});
|
});
|
||||||
await refetch();
|
await refetch();
|
||||||
@@ -141,7 +141,7 @@ export const ProfileForm = () => {
|
|||||||
password: "",
|
password: "",
|
||||||
image: values.image,
|
image: values.image,
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
name: values.name || "",
|
firstName: values.firstName || "",
|
||||||
lastName: values.lastName || "",
|
lastName: values.lastName || "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,7 +184,7 @@ export const ProfileForm = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="firstName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>First Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
|
|||||||
@@ -231,3 +231,29 @@ export const NtfyIcon = ({ className }: Props) => {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PushoverIcon = ({ className }: Props) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 600 600"
|
||||||
|
className={cn("size-8", className)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g stroke="none" strokeWidth="1">
|
||||||
|
<ellipse
|
||||||
|
style={{ fillRule: "evenodd" }}
|
||||||
|
fill="#249DF1"
|
||||||
|
transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"
|
||||||
|
cx="216.308"
|
||||||
|
cy="152.076"
|
||||||
|
rx="296.855"
|
||||||
|
ry="296.855"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FFFFFF"
|
||||||
|
d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,21 +4,35 @@ import { cn } from "@/lib/utils";
|
|||||||
import { GithubIcon } from "../icons/data-tools-icons";
|
import { GithubIcon } from "../icons/data-tools-icons";
|
||||||
import { Logo } from "../shared/logo";
|
import { Logo } from "../shared/logo";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
export const OnboardingLayout = ({ children }: Props) => {
|
export const OnboardingLayout = ({ children }: Props) => {
|
||||||
|
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||||
|
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
|
||||||
|
const logoUrl =
|
||||||
|
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||||
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
||||||
<div className="absolute inset-0 bg-muted" />
|
<div className="absolute inset-0 bg-muted" />
|
||||||
|
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="https://dokploy.com"
|
href="https://dokploy.com"
|
||||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||||
>
|
>
|
||||||
<Logo className="size-10" />
|
<Logo className="size-10" logoUrl={logoUrl ?? undefined} />
|
||||||
Dokploy
|
{appName}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
<blockquote className="space-y-2">
|
<blockquote className="space-y-2">
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import {
|
|||||||
Forward,
|
Forward,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
LogIn,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Package,
|
Package,
|
||||||
|
Palette,
|
||||||
PieChart,
|
PieChart,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -396,6 +399,33 @@ const MENU: Menu = {
|
|||||||
// Only enabled for admins in cloud environments
|
// Only enabled for admins in cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "License",
|
||||||
|
url: "/dashboard/settings/license",
|
||||||
|
icon: Key,
|
||||||
|
// Only enabled for admins in non-cloud environments
|
||||||
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
|
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "SSO",
|
||||||
|
url: "/dashboard/settings/sso",
|
||||||
|
icon: LogIn,
|
||||||
|
// Enabled for admins in both cloud and self-hosted (enterprise)
|
||||||
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Whitelabeling",
|
||||||
|
url: "/dashboard/settings/whitelabelling",
|
||||||
|
icon: Palette,
|
||||||
|
// Enterprise only – page shows gate if no license
|
||||||
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
help: [
|
help: [
|
||||||
@@ -526,6 +556,7 @@ function SidebarLogo() {
|
|||||||
refetch,
|
refetch,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = api.organization.all.useQuery();
|
} = api.organization.all.useQuery();
|
||||||
|
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||||
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
|
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
|
||||||
api.organization.delete.useMutation();
|
api.organization.delete.useMutation();
|
||||||
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
||||||
@@ -591,7 +622,11 @@ function SidebarLogo() {
|
|||||||
"transition-all",
|
"transition-all",
|
||||||
state === "collapsed" ? "size-4" : "size-5",
|
state === "collapsed" ? "size-4" : "size-5",
|
||||||
)}
|
)}
|
||||||
logoUrl={activeOrganization?.logo || undefined}
|
logoUrl={
|
||||||
|
activeOrganization?.logo ||
|
||||||
|
whitelabel?.whitelabelLogoUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -601,7 +636,9 @@ function SidebarLogo() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium leading-none">
|
<p className="text-sm font-medium leading-none">
|
||||||
{activeOrganization?.name ?? "Select Organization"}
|
{activeOrganization?.name ??
|
||||||
|
whitelabel?.whitelabelAppName ??
|
||||||
|
"Select Organization"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function SignInWithGithub() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
|
provider: "github",
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("An error occurred while signing in with GitHub", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={handleClick}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with GitHub
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function SignInWithGoogle() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("An error occurred while signing in with Google", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={handleClick}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
apps/dokploy/components/proprietary/enterprise-feature-gate.tsx
Normal file
114
apps/dokploy/components/proprietary/enterprise-feature-gate.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Lock } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface EnterpriseFeatureLockedProps {
|
||||||
|
/** Optional title override */
|
||||||
|
title?: string;
|
||||||
|
/** Optional description override */
|
||||||
|
description?: string;
|
||||||
|
/** Optional custom CTA label */
|
||||||
|
ctaLabel?: string;
|
||||||
|
/** Optional CTA href (default: /dashboard/settings/license) */
|
||||||
|
ctaHref?: string;
|
||||||
|
/** Compact variant (less padding, smaller icon) */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a locked state for enterprise features when the user has no valid license.
|
||||||
|
* Use standalone or via EnterpriseFeatureGate.
|
||||||
|
*/
|
||||||
|
export function EnterpriseFeatureLocked({
|
||||||
|
title = "Enterprise feature",
|
||||||
|
description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.",
|
||||||
|
ctaLabel = "Go to License",
|
||||||
|
ctaHref = "/dashboard/settings/license",
|
||||||
|
compact = false,
|
||||||
|
}: EnterpriseFeatureLockedProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed bg-transparent">
|
||||||
|
<CardHeader className={compact ? "pb-2" : undefined}>
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "rounded-full bg-muted p-3"
|
||||||
|
: "rounded-full bg-muted p-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Lock
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "size-6 text-muted-foreground"
|
||||||
|
: "size-8 text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
|
<CardDescription className="max-w-sm mx-auto">
|
||||||
|
{description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={compact ? "pt-0" : undefined}>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button asChild variant="secondary" size={compact ? "sm" : "default"}>
|
||||||
|
<Link href={ctaHref}>{ctaLabel}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnterpriseFeatureGateProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Props for the locked state when license is invalid */
|
||||||
|
lockedProps?: Omit<EnterpriseFeatureLockedProps, "compact">;
|
||||||
|
/** Show loading spinner while checking license */
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders children only when the instance has a valid enterprise license.
|
||||||
|
* Otherwise shows EnterpriseFeatureLocked.
|
||||||
|
*/
|
||||||
|
export function EnterpriseFeatureGate({
|
||||||
|
children,
|
||||||
|
lockedProps,
|
||||||
|
fallback,
|
||||||
|
}: EnterpriseFeatureGateProps) {
|
||||||
|
const { data: haveValidLicense, isLoading } =
|
||||||
|
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
if (fallback) return <>{fallback}</>;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Checking license...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!haveValidLicense) {
|
||||||
|
return <EnterpriseFeatureLocked {...lockedProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
232
apps/dokploy/components/proprietary/license-keys/license-key.tsx
Normal file
232
apps/dokploy/components/proprietary/license-keys/license-key.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { Key, Loader2, ShieldCheck } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export function LicenseKeySettings() {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data, isLoading } = api.licenseKey.getEnterpriseSettings.useQuery();
|
||||||
|
const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } =
|
||||||
|
api.licenseKey.updateEnterpriseSettings.useMutation();
|
||||||
|
const { mutateAsync: activateLicenseKey, isLoading: isActivating } =
|
||||||
|
api.licenseKey.activate.useMutation();
|
||||||
|
const { mutateAsync: validateLicenseKey, isLoading: isValidating } =
|
||||||
|
api.licenseKey.validate.useMutation();
|
||||||
|
const { mutateAsync: deactivateLicenseKey, isLoading: isDeactivating } =
|
||||||
|
api.licenseKey.deactivate.useMutation();
|
||||||
|
const { data: haveValidLicenseKey, isLoading: isCheckingLicenseKey } =
|
||||||
|
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||||
|
const [licenseKey, setLicenseKey] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.licenseKey) {
|
||||||
|
setLicenseKey(data.licenseKey);
|
||||||
|
}
|
||||||
|
}, [data?.licenseKey]);
|
||||||
|
|
||||||
|
const enabled = !!data?.enableEnterpriseFeatures;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||||
|
{isCheckingLicenseKey ? (
|
||||||
|
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Checking license key...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Key className="size-6 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-xl">License Key</CardTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{enabled ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
disabled={isLoading || isSaving || isDeactivating}
|
||||||
|
onCheckedChange={async (next) => {
|
||||||
|
try {
|
||||||
|
await updateEnterpriseSettings({
|
||||||
|
enableEnterpriseFeatures: next,
|
||||||
|
});
|
||||||
|
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||||
|
toast.success("Enterprise features updated");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Failed to update enterprise features");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To unlock extra features you need an enterprise license key.
|
||||||
|
Contact us{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com/contact"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline underline-offset-4"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{enabled ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="licenseKey">
|
||||||
|
License Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="licenseKey"
|
||||||
|
placeholder="Enter your enterprise license key"
|
||||||
|
value={licenseKey}
|
||||||
|
onChange={(e) => setLicenseKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:justify-self-end flex gap-2">
|
||||||
|
{haveValidLicenseKey && (
|
||||||
|
<DialogAction
|
||||||
|
title="Deactivate License Key"
|
||||||
|
description="Are you sure you want to deactivate this license key? This will disable enterprise features."
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deactivateLicenseKey();
|
||||||
|
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||||
|
await utils.licenseKey.haveValidLicenseKey.invalidate();
|
||||||
|
setLicenseKey("");
|
||||||
|
toast.success("License key deactivated");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to deactivate license key",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDeactivating || !haveValidLicenseKey}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isDeactivating || !haveValidLicenseKey}
|
||||||
|
isLoading={isDeactivating}
|
||||||
|
>
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
{haveValidLicenseKey && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={
|
||||||
|
isSaving || isCheckingLicenseKey || isDeactivating
|
||||||
|
}
|
||||||
|
isLoading={isValidating}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const valid = await validateLicenseKey();
|
||||||
|
if (valid) {
|
||||||
|
toast.success("License key is valid");
|
||||||
|
} else {
|
||||||
|
toast.error("License key is invalid");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to validate license key",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!haveValidLicenseKey && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSaving || isValidating || isDeactivating}
|
||||||
|
isLoading={isActivating}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await activateLicenseKey({ licenseKey });
|
||||||
|
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||||
|
await utils.licenseKey.haveValidLicenseKey.invalidate();
|
||||||
|
toast.success("License key activated");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to activate license key",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 max-w-[400px]">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<ShieldCheck className="size-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold">Enterprise Features</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Unlock advanced capabilities like SSO, Audit logs,
|
||||||
|
whitelabeling and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await updateEnterpriseSettings({
|
||||||
|
enableEnterpriseFeatures: true,
|
||||||
|
});
|
||||||
|
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||||
|
toast.success("Enterprise features enabled");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Failed to enable enterprise features");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={isSaving}
|
||||||
|
disabled={isLoading || isDeactivating}
|
||||||
|
>
|
||||||
|
Enable Enterprise Features
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx
Normal file
347
apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { FieldArrayPath } from "react-hook-form";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
||||||
|
|
||||||
|
const domainsArraySchema = z
|
||||||
|
.array(z.string().trim())
|
||||||
|
.superRefine((arr, ctx) => {
|
||||||
|
const filled = arr.filter((s) => s.length > 0);
|
||||||
|
if (filled.length < 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "At least one domain is required",
|
||||||
|
path: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopesArraySchema = z.array(z.string().trim());
|
||||||
|
|
||||||
|
const oidcProviderSchema = z.object({
|
||||||
|
providerId: z.string().min(1, "Provider ID is required").trim(),
|
||||||
|
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
|
||||||
|
domains: domainsArraySchema,
|
||||||
|
clientId: z.string().min(1, "Client ID is required").trim(),
|
||||||
|
clientSecret: z.string().min(1, "Client secret is required"),
|
||||||
|
scopes: scopesArraySchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
||||||
|
|
||||||
|
interface RegisterOidcDialogProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formDefaultValues = {
|
||||||
|
providerId: "",
|
||||||
|
issuer: "",
|
||||||
|
domains: [""],
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
scopes: [...DEFAULT_SCOPES],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<OidcProviderForm>({
|
||||||
|
resolver: zodResolver(oidcProviderSchema),
|
||||||
|
defaultValues: formDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "domains" as FieldArrayPath<OidcProviderForm>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: scopeFields,
|
||||||
|
append: appendScope,
|
||||||
|
remove: removeScope,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "scopes" as FieldArrayPath<OidcProviderForm>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
const onSubmit = async (data: OidcProviderForm) => {
|
||||||
|
try {
|
||||||
|
const scopes = data.scopes.filter(Boolean).length
|
||||||
|
? data.scopes.filter(Boolean)
|
||||||
|
: DEFAULT_SCOPES;
|
||||||
|
const domain = data.domains
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(",");
|
||||||
|
await mutateAsync({
|
||||||
|
providerId: data.providerId,
|
||||||
|
issuer: data.issuer,
|
||||||
|
domain,
|
||||||
|
oidcConfig: {
|
||||||
|
clientId: data.clientId,
|
||||||
|
clientSecret: data.clientSecret,
|
||||||
|
scopes,
|
||||||
|
pkce: true,
|
||||||
|
// Keycloak (and many IdPs) send preferred_username; better-auth expects name
|
||||||
|
mapping: {
|
||||||
|
id: "sub",
|
||||||
|
email: "email",
|
||||||
|
emailVerified: "email_verified",
|
||||||
|
name: "preferred_username",
|
||||||
|
image: "picture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("OIDC provider registered successfully");
|
||||||
|
form.reset(formDefaultValues);
|
||||||
|
setOpen(false);
|
||||||
|
await utils.sso.listProviders.invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to register SSO provider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Register OIDC provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
|
||||||
|
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
|
||||||
|
from the issuer URL when possible.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="providerId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provider ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. okta or my-idp" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Unique identifier; used in callback URL path.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="issuer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Issuer URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://idp.example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Discovery document is fetched from{" "}
|
||||||
|
<code className="rounded bg-muted px-1">
|
||||||
|
{"{issuer}"}/.well-known/openid-configuration
|
||||||
|
</code>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Domains</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => (append as (value: string) => void)("")}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
Add domain
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Email domains that use this provider (sign-in by email and org
|
||||||
|
assignment; subdomains matched automatically).
|
||||||
|
</p>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`domains.${index}`}
|
||||||
|
render={({ field: inputField }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="company.com"
|
||||||
|
className="flex-1"
|
||||||
|
{...inputField}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
disabled={fields.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(() => {
|
||||||
|
const err = form.formState.errors.domains;
|
||||||
|
const msg =
|
||||||
|
typeof err?.message === "string"
|
||||||
|
? err.message
|
||||||
|
: (err as { root?: { message?: string } } | undefined)?.root
|
||||||
|
?.message;
|
||||||
|
return msg ? (
|
||||||
|
<p className="text-sm font-medium text-destructive">{msg}</p>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="clientId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Client ID from IdP" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="clientSecret"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client secret</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Client secret from IdP"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Scopes (optional)</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => (appendScope as (value: string) => void)("")}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
Add scope
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
OIDC scopes to request (e.g. openid, email, profile). If empty,
|
||||||
|
openid, email and profile are used.
|
||||||
|
</FormDescription>
|
||||||
|
{scopeFields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`scopes.${index}`}
|
||||||
|
render={({ field: inputField }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="openid"
|
||||||
|
className="flex-1"
|
||||||
|
{...inputField}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeScope(index)}
|
||||||
|
disabled={scopeFields.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Register provider
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx
Normal file
322
apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const domainsArraySchema = z
|
||||||
|
.array(z.string().trim())
|
||||||
|
.superRefine((arr, ctx) => {
|
||||||
|
const filled = arr.filter((s) => s.length > 0);
|
||||||
|
if (filled.length < 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "At least one domain is required",
|
||||||
|
path: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const samlProviderSchema = z.object({
|
||||||
|
providerId: z.string().min(1, "Provider ID is required").trim(),
|
||||||
|
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
|
||||||
|
domains: domainsArraySchema,
|
||||||
|
entryPoint: z
|
||||||
|
.string()
|
||||||
|
.min(1, "IdP SSO URL is required")
|
||||||
|
.url("Invalid URL")
|
||||||
|
.trim(),
|
||||||
|
cert: z.string().min(1, "IdP signing certificate is required"),
|
||||||
|
callbackUrl: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Callback URL is required")
|
||||||
|
.url("Invalid URL")
|
||||||
|
.trim(),
|
||||||
|
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||||
|
|
||||||
|
interface RegisterSamlDialogProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formDefaultValues: SamlProviderForm = {
|
||||||
|
providerId: "",
|
||||||
|
issuer: "",
|
||||||
|
domains: [""],
|
||||||
|
entryPoint: "",
|
||||||
|
cert: "",
|
||||||
|
callbackUrl: "",
|
||||||
|
audience: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<SamlProviderForm>({
|
||||||
|
resolver: zodResolver(samlProviderSchema),
|
||||||
|
defaultValues: formDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "domains" as FieldArrayPath<SamlProviderForm>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
const onSubmit = async (data: SamlProviderForm) => {
|
||||||
|
try {
|
||||||
|
const domain = data.domains
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(",");
|
||||||
|
await mutateAsync({
|
||||||
|
providerId: data.providerId,
|
||||||
|
issuer: data.issuer,
|
||||||
|
domain,
|
||||||
|
samlConfig: {
|
||||||
|
entryPoint: data.entryPoint,
|
||||||
|
cert: data.cert,
|
||||||
|
callbackUrl: data.callbackUrl,
|
||||||
|
audience: data.audience,
|
||||||
|
wantAssertionsSigned: true,
|
||||||
|
signatureAlgorithm: "sha256",
|
||||||
|
digestAlgorithm: "sha256",
|
||||||
|
spMetadata: {
|
||||||
|
entityID: data.audience,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("SAML provider registered successfully");
|
||||||
|
form.reset(formDefaultValues);
|
||||||
|
setOpen(false);
|
||||||
|
await utils.sso.listProviders.invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to register SAML provider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Register SAML provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
|
||||||
|
OneLogin). You need the IdP's SSO URL and signing certificate.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="providerId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provider ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. okta-saml or azure-saml"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="issuer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Issuer URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://idp.example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Domains</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => append("")}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
Add domain
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Email domains that use this provider (sign-in by email and org
|
||||||
|
assignment; subdomains matched automatically).
|
||||||
|
</FormDescription>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`domains.${index}`}
|
||||||
|
render={({ field: inputField }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="company.com"
|
||||||
|
className="flex-1"
|
||||||
|
{...inputField}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
disabled={fields.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(() => {
|
||||||
|
const err = form.formState.errors.domains;
|
||||||
|
const msg =
|
||||||
|
typeof err?.message === "string"
|
||||||
|
? err.message
|
||||||
|
: (err as { root?: { message?: string } } | undefined)?.root
|
||||||
|
?.message;
|
||||||
|
return msg ? (
|
||||||
|
<p className="text-sm font-medium text-destructive">{msg}</p>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="entryPoint"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://idp.example.com/sso"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Single Sign-On URL from your IdP's SAML setup.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cert"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>IdP signing certificate (X.509)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="callbackUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Callback URL (ACS)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Use the callback URL shown in your IdP app config for this
|
||||||
|
provider.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="audience"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Audience (Entity ID)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://yourapp.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Register provider
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx
Normal file
127
apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Loader2, LogIn } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
const ssoEmailSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Enter your work email")
|
||||||
|
.email("Enter a valid email address")
|
||||||
|
.transform((v) => v.trim()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
||||||
|
|
||||||
|
interface SignInWithSSOProps {
|
||||||
|
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<SSOEmailForm>({
|
||||||
|
resolver: zodResolver(ssoEmailSchema),
|
||||||
|
defaultValues: { email: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: SSOEmailForm) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await authClient.signIn.sso({
|
||||||
|
email: values.email,
|
||||||
|
callbackURL: "/dashboard/projects",
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data?.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to sign in with SSO",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<LogIn className="mr-2 size-4" />
|
||||||
|
Sign in with SSO
|
||||||
|
</Button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
className="flex-1"
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Continue"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Use email and password instead
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
368
apps/dokploy/components/proprietary/sso/sso-settings.tsx
Normal file
368
apps/dokploy/components/proprietary/sso/sso-settings.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
|
|
||||||
|
type ProviderForDetails = {
|
||||||
|
id: string | null;
|
||||||
|
providerId: string;
|
||||||
|
issuer: string;
|
||||||
|
domain: string;
|
||||||
|
oidcConfig: string | null;
|
||||||
|
samlConfig: string | null;
|
||||||
|
organizationId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseOidcConfig(config: string | null): {
|
||||||
|
clientId?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
} | null {
|
||||||
|
if (!config) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(config) as {
|
||||||
|
clientId?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
};
|
||||||
|
return { clientId: parsed.clientId, scopes: parsed.scopes };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSamlConfig(
|
||||||
|
config: string | null,
|
||||||
|
): { entryPoint?: string } | null {
|
||||||
|
if (!config) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(config) as { entryPoint?: string };
|
||||||
|
return { entryPoint: parsed.entryPoint };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSOSettings = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [detailsProvider, setDetailsProvider] =
|
||||||
|
useState<ProviderForDetails | null>(null);
|
||||||
|
const [baseURL, setBaseURL] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setBaseURL(window.location.origin);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
||||||
|
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
||||||
|
api.sso.deleteProvider.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LogIn className="size-6 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Configure OIDC or SAML identity providers for enterprise sign-in.
|
||||||
|
Users can sign in with their organization's IdP.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Loading providers...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{providers && providers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<RegisterOidcDialog>
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
<LogIn className="mr-2 size-4" />
|
||||||
|
Add OIDC provider
|
||||||
|
</Button>
|
||||||
|
</RegisterOidcDialog>
|
||||||
|
<RegisterSamlDialog>
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
<LogIn className="mr-2 size-4" />
|
||||||
|
Add SAML provider
|
||||||
|
</Button>
|
||||||
|
</RegisterSamlDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers && providers.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-sm font-medium">Registered providers</span>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const isOidc = !!provider.oidcConfig;
|
||||||
|
const isSaml = !!provider.samlConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={provider.id}
|
||||||
|
className="overflow-hidden bg-background"
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<CardTitle className="text-base font-medium">
|
||||||
|
{provider.providerId}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{provider.issuer}
|
||||||
|
</CardDescription>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{provider.domain}
|
||||||
|
</Badge>
|
||||||
|
{isOidc && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
OIDC
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSaml && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
SAML
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2 pt-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setDetailsProvider({
|
||||||
|
id: provider.id,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
issuer: provider.issuer,
|
||||||
|
domain: provider.domain,
|
||||||
|
oidcConfig: provider.oidcConfig,
|
||||||
|
samlConfig: provider.samlConfig,
|
||||||
|
organizationId: provider.organizationId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye className="mr-1 size-3" />
|
||||||
|
View details
|
||||||
|
</Button>
|
||||||
|
<DialogAction
|
||||||
|
title="Remove SSO provider"
|
||||||
|
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteProvider({
|
||||||
|
providerId: provider.providerId,
|
||||||
|
});
|
||||||
|
toast.success("Provider removed");
|
||||||
|
await utils.sso.listProviders.invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to remove provider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 size-3" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 max-w-[400px]">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<LogIn className="size-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold">No SSO providers</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add an OIDC or SAML provider so users can sign in with their
|
||||||
|
organization's IdP (e.g. Okta, Azure AD).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
<RegisterOidcDialog>
|
||||||
|
<Button variant="secondary">
|
||||||
|
<LogIn className="mr-2 size-4" />
|
||||||
|
Add OIDC provider
|
||||||
|
</Button>
|
||||||
|
</RegisterOidcDialog>
|
||||||
|
<RegisterSamlDialog>
|
||||||
|
<Button variant="outline">
|
||||||
|
<LogIn className="mr-2 size-4" />
|
||||||
|
Add SAML provider
|
||||||
|
</Button>
|
||||||
|
</RegisterSamlDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!detailsProvider}
|
||||||
|
onOpenChange={(open) => !open && setDetailsProvider(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
{detailsProvider && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>SSO provider details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
View-only. To change settings, remove this provider and add it
|
||||||
|
again with the new values.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 py-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Provider ID
|
||||||
|
</span>
|
||||||
|
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
|
||||||
|
{detailsProvider.providerId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Issuer URL
|
||||||
|
</span>
|
||||||
|
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||||
|
{detailsProvider.issuer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Domain
|
||||||
|
</span>
|
||||||
|
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||||
|
{detailsProvider.domain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{detailsProvider.oidcConfig && (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const oidc = parseOidcConfig(detailsProvider.oidcConfig);
|
||||||
|
if (!oidc) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{oidc.clientId && (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Client ID
|
||||||
|
</span>
|
||||||
|
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
|
||||||
|
{oidc.clientId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{oidc.scopes && oidc.scopes.length > 0 && (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Scopes
|
||||||
|
</span>
|
||||||
|
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||||
|
{oidc.scopes.join(" ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{detailsProvider.samlConfig && (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const saml = parseSamlConfig(detailsProvider.samlConfig);
|
||||||
|
if (!saml?.entryPoint) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Entry point
|
||||||
|
</span>
|
||||||
|
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||||
|
{saml.entryPoint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Callback URL (configure in your IdP)
|
||||||
|
</span>
|
||||||
|
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||||
|
{baseURL || "{baseURL}"}/api/auth/sso/callback/
|
||||||
|
{detailsProvider.providerId}
|
||||||
|
</p>
|
||||||
|
{!baseURL && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Replace {"{baseURL}"} with your Dokploy URL (e.g. https://
|
||||||
|
your-domain.com).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDetailsProvider(null)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Loader2, Palette } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const whitelabelSchema = z.object({
|
||||||
|
whitelabelAppName: z.string().min(1).max(100),
|
||||||
|
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
|
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
|
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
|
whitelabelLoginTitle: z.string().max(200).optional(),
|
||||||
|
whitelabelLoginSubtitle: z.string().max(500).optional(),
|
||||||
|
whitelabelLoginBackgroundImageUrl: z
|
||||||
|
.union([z.string().url(), z.literal("")])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
|
||||||
|
|
||||||
|
export function WhitelabelSettings() {
|
||||||
|
const { data: settings, isLoading } =
|
||||||
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
|
||||||
|
api.settings.updateWhitelabelSettings.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const form = useForm<WhitelabelFormValues>({
|
||||||
|
resolver: zodResolver(whitelabelSchema),
|
||||||
|
defaultValues: {
|
||||||
|
whitelabelAppName: "Dokploy",
|
||||||
|
whitelabelLogoUrl: "",
|
||||||
|
whitelabelLoginLogoUrl: "",
|
||||||
|
whitelabelFaviconUrl: "",
|
||||||
|
whitelabelLoginTitle: "",
|
||||||
|
whitelabelLoginSubtitle: "",
|
||||||
|
whitelabelLoginBackgroundImageUrl: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
form.reset({
|
||||||
|
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
|
||||||
|
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
|
||||||
|
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
|
||||||
|
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
|
||||||
|
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
|
||||||
|
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
|
||||||
|
whitelabelLoginBackgroundImageUrl:
|
||||||
|
settings.whitelabelLoginBackgroundImageUrl ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: WhitelabelFormValues) => {
|
||||||
|
try {
|
||||||
|
await updateWhitelabel({
|
||||||
|
whitelabelAppName: values.whitelabelAppName || null,
|
||||||
|
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
|
||||||
|
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
|
||||||
|
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
|
||||||
|
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
|
||||||
|
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
|
||||||
|
whitelabelLoginBackgroundImageUrl:
|
||||||
|
values.whitelabelLoginBackgroundImageUrl || undefined,
|
||||||
|
});
|
||||||
|
toast.success("Whitelabel settings saved");
|
||||||
|
utils.settings.getWebServerSettings.invalidate();
|
||||||
|
utils.settings.getWhitelabelSettings.invalidate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to save whitelabel settings");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Loading whitelabel settings...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg ">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Palette className="size-6 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-xl">Whitelabeling</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Customize the application name, logos, and login page for your brand.
|
||||||
|
Leave URLs empty to use defaults.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 pt-2 border-t">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Brand</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Application name and main logo (sidebar, header).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelAppName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Application name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Dokploy"
|
||||||
|
{...field}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelLogoUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Logo URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Logo shown in the sidebar and header.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelFaviconUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Favicon URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/favicon.ico"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-6 border-t">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Login page</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize the sign-in and registration screens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelLoginLogoUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Login logo URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/login-logo.png"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Logo on the login and register pages. Falls back to the main
|
||||||
|
logo if empty.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelLoginTitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Login title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Sign in"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelLoginSubtitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Login subtitle</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your email and password to sign in"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="whitelabelLoginBackgroundImageUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Login background image URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/background.jpg"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional background image for the login page.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4 border-t">
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save changes"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/dokploy/drizzle/0135_illegal_magik.sql
Normal file
12
apps/dokploy/drizzle/0135_illegal_magik.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TYPE "public"."notificationType" ADD VALUE 'pushover' BEFORE 'custom';--> statement-breakpoint
|
||||||
|
CREATE TABLE "pushover" (
|
||||||
|
"pushoverId" text PRIMARY KEY NOT NULL,
|
||||||
|
"userKey" text NOT NULL,
|
||||||
|
"apiToken" text NOT NULL,
|
||||||
|
"priority" integer DEFAULT 0 NOT NULL,
|
||||||
|
"retry" integer,
|
||||||
|
"expire" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ADD COLUMN "pushoverId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_pushoverId_pushover_pushoverId_fk" FOREIGN KEY ("pushoverId") REFERENCES "public"."pushover"("pushoverId") ON DELETE cascade ON UPDATE no action;
|
||||||
2
apps/dokploy/drizzle/0136_tidy_puff_adder.sql
Normal file
2
apps/dokploy/drizzle/0136_tidy_puff_adder.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "bitbucketRepositorySlug" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "compose" ADD COLUMN "bitbucketRepositorySlug" text;
|
||||||
2
apps/dokploy/drizzle/0137_naive_power_pack.sql
Normal file
2
apps/dokploy/drizzle/0137_naive_power_pack.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "licenseKey" text;
|
||||||
13
apps/dokploy/drizzle/0138_common_mathemanic.sql
Normal file
13
apps/dokploy/drizzle/0138_common_mathemanic.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "sso_provider" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"issuer" text NOT NULL,
|
||||||
|
"oidc_config" text,
|
||||||
|
"saml_config" text,
|
||||||
|
"user_id" text,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"organization_id" text,
|
||||||
|
"domain" text NOT NULL,
|
||||||
|
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
1
apps/dokploy/drizzle/0139_smiling_havok.sql
Normal file
1
apps/dokploy/drizzle/0139_smiling_havok.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;
|
||||||
1
apps/dokploy/drizzle/0140_great_lightspeed.sql
Normal file
1
apps/dokploy/drizzle/0140_great_lightspeed.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
7038
apps/dokploy/drizzle/meta/0135_snapshot.json
Normal file
7038
apps/dokploy/drizzle/meta/0135_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7050
apps/dokploy/drizzle/meta/0136_snapshot.json
Normal file
7050
apps/dokploy/drizzle/meta/0136_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7063
apps/dokploy/drizzle/meta/0137_snapshot.json
Normal file
7063
apps/dokploy/drizzle/meta/0137_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7146
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
7146
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7153
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
7153
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7166
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
7166
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -946,6 +946,48 @@
|
|||||||
"when": 1767871040249,
|
"when": 1767871040249,
|
||||||
"tag": "0134_strong_hercules",
|
"tag": "0134_strong_hercules",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 135,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1768271617042,
|
||||||
|
"tag": "0135_illegal_magik",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 136,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769580434296,
|
||||||
|
"tag": "0136_tidy_puff_adder",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 137,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769616589728,
|
||||||
|
"tag": "0137_naive_power_pack",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 138,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769745328628,
|
||||||
|
"tag": "0138_common_mathemanic",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 139,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769746948088,
|
||||||
|
"tag": "0139_smiling_havok",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 140,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769854977685,
|
||||||
|
"tag": "0140_great_lightspeed",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ssoClient } from "@better-auth/sso/client";
|
||||||
import {
|
import {
|
||||||
adminClient,
|
adminClient,
|
||||||
apiKeyClient,
|
apiKeyClient,
|
||||||
@@ -13,6 +14,7 @@ export const authClient = createAuthClient({
|
|||||||
organizationClient(),
|
organizationClient(),
|
||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
apiKeyClient(),
|
apiKeyClient(),
|
||||||
|
ssoClient(),
|
||||||
adminClient(),
|
adminClient(),
|
||||||
inferAdditionalFields({
|
inferAdditionalFields({
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.26.6",
|
"version": "v0.26.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/sso": "1.4.18",
|
||||||
"@ai-sdk/anthropic": "^2.0.5",
|
"@ai-sdk/anthropic": "^2.0.5",
|
||||||
"@ai-sdk/azure": "^2.0.16",
|
"@ai-sdk/azure": "^2.0.16",
|
||||||
"@ai-sdk/cohere": "^2.0.4",
|
"@ai-sdk/cohere": "^2.0.4",
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
"ai": "^5.0.17",
|
"ai": "^5.0.17",
|
||||||
"ai-sdk-ollama": "^0.5.1",
|
"ai-sdk-ollama": "^0.5.1",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "v1.2.8-beta.7",
|
"better-auth": "1.4.18",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.4.2",
|
"bullmq": "5.4.2",
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ export default async function handler(
|
|||||||
const commitedPaths = await extractCommitedPaths(
|
const commitedPaths = await extractCommitedPaths(
|
||||||
req.body,
|
req.body,
|
||||||
application.bitbucket,
|
application.bitbucket,
|
||||||
application.bitbucketRepository || "",
|
application.bitbucketRepositorySlug ||
|
||||||
|
application.bitbucketRepository ||
|
||||||
|
"",
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ export default async function handler(
|
|||||||
const commitedPaths = await extractCommitedPaths(
|
const commitedPaths = await extractCommitedPaths(
|
||||||
req.body,
|
req.body,
|
||||||
composeResult.bitbucket,
|
composeResult.bitbucket,
|
||||||
composeResult.bitbucketRepository || "",
|
composeResult.bitbucketRepositorySlug ||
|
||||||
|
composeResult.bitbucketRepository ||
|
||||||
|
"",
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
|
|||||||
84
apps/dokploy/pages/dashboard/settings/license.tsx
Normal file
84
apps/dokploy/pages/dashboard/settings/license.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<LicenseKeySettings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="License">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (user.role === "member") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/settings/profile",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
84
apps/dokploy/pages/dashboard/settings/sso.tsx
Normal file
84
apps/dokploy/pages/dashboard/settings/sso.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { validateRequest } from "@dokploy/server";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||||
|
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<EnterpriseFeatureGate
|
||||||
|
lockedProps={{
|
||||||
|
title: "Enterprise SSO",
|
||||||
|
description:
|
||||||
|
"Single sign-on (SSO) with OIDC and SAML is part of Dokploy Enterprise. Add a valid license to configure it.",
|
||||||
|
ctaLabel: "Go to License",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SSOSettings />
|
||||||
|
</EnterpriseFeatureGate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="SSO">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (user.role === "member") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/settings/profile",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
84
apps/dokploy/pages/dashboard/settings/whitelabelling.tsx
Normal file
84
apps/dokploy/pages/dashboard/settings/whitelabelling.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { validateRequest } from "@dokploy/server";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||||
|
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<EnterpriseFeatureGate
|
||||||
|
lockedProps={{
|
||||||
|
title: "Enterprise Whitelabeling",
|
||||||
|
description:
|
||||||
|
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
|
||||||
|
ctaLabel: "Go to License",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WhitelabelSettings />
|
||||||
|
</EnterpriseFeatureGate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
|
const { req } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (user.role === "member") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/settings/profile",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: ctx.res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||||
|
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
|
||||||
|
import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google";
|
||||||
|
import { SignInWithSSO } from "@/components/proprietary/sso/sign-in-with-sso";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Logo } from "@/components/shared/logo";
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -37,6 +40,7 @@ import {
|
|||||||
} from "@/components/ui/input-otp";
|
} from "@/components/ui/input-otp";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const LoginSchema = z.object({
|
const LoginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -54,6 +58,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export default function Home({ IS_CLOUD }: Props) {
|
export default function Home({ IS_CLOUD }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||||
|
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||||
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||||
@@ -62,8 +68,6 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||||
const [isBackupCodeModalOpen, setIsBackupCodeModalOpen] = useState(false);
|
const [isBackupCodeModalOpen, setIsBackupCodeModalOpen] = useState(false);
|
||||||
const [backupCode, setBackupCode] = useState("");
|
const [backupCode, setBackupCode] = useState("");
|
||||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
|
||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
|
||||||
const loginForm = useForm<LoginForm>({
|
const loginForm = useForm<LoginForm>({
|
||||||
resolver: zodResolver(LoginSchema),
|
resolver: zodResolver(LoginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -161,56 +165,75 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGithubSignIn = async () => {
|
const loginContent = (
|
||||||
setIsGithubLoading(true);
|
<>
|
||||||
try {
|
{IS_CLOUD && <SignInWithGithub />}
|
||||||
const { error } = await authClient.signIn.social({
|
{IS_CLOUD && <SignInWithGoogle />}
|
||||||
provider: "github",
|
<Form {...loginForm}>
|
||||||
});
|
<form
|
||||||
|
onSubmit={loginForm.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="login-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={loginForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={loginForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button className="w-full" type="submit" isLoading={isLoginLoading}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
const loginLogoUrl =
|
||||||
toast.error(error.message);
|
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
|
||||||
return;
|
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
|
||||||
}
|
const loginSubtitle =
|
||||||
} catch (error) {
|
whitelabel?.whitelabelLoginSubtitle ??
|
||||||
toast.error("An error occurred while signing in with GitHub", {
|
"Enter your email and password to sign in";
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsGithubLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoogleSignIn = async () => {
|
|
||||||
setIsGoogleLoading(true);
|
|
||||||
try {
|
|
||||||
const { error } = await authClient.signIn.social({
|
|
||||||
provider: "google",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("An error occurred while signing in with Google", {
|
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsGoogleLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<div className="flex flex-row items-center justify-center gap-2">
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
<Logo className="size-12" />
|
<Logo
|
||||||
Sign in
|
className="size-12"
|
||||||
|
logoUrl={loginLogoUrl ?? undefined}
|
||||||
|
/>
|
||||||
|
{loginTitle}
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Enter your email and password to sign in
|
{loginSubtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -221,97 +244,11 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{!isTwoFactor ? (
|
{!isTwoFactor ? (
|
||||||
<>
|
<>
|
||||||
{IS_CLOUD && (
|
{showSignInWithSSO ? (
|
||||||
<Button
|
<SignInWithSSO>{loginContent}</SignInWithSSO>
|
||||||
variant="outline"
|
) : (
|
||||||
type="button"
|
loginContent
|
||||||
className="w-full mb-4"
|
|
||||||
onClick={handleGithubSignIn}
|
|
||||||
isLoading={isGithubLoading}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Sign in with GitHub
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{IS_CLOUD && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="w-full mb-4"
|
|
||||||
onClick={handleGoogleSignIn}
|
|
||||||
isLoading={isGoogleLoading}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Sign in with Google
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Form {...loginForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={loginForm.handleSubmit(onSubmit)}
|
|
||||||
className="space-y-4"
|
|
||||||
id="login-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={loginForm.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="john@example.com" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={loginForm.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
isLoading={isLoginLoading}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||||
|
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
|
||||||
|
import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Logo } from "@/components/shared/logo";
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -152,6 +154,17 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
{isCloud && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<SignInWithGithub />
|
||||||
|
<SignInWithGoogle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCloud && (
|
||||||
|
<p className="mb-4 text-center text-xs text-muted-foreground">
|
||||||
|
Or register with email
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { mountRouter } from "./routers/mount";
|
|||||||
import { mysqlRouter } from "./routers/mysql";
|
import { mysqlRouter } from "./routers/mysql";
|
||||||
import { notificationRouter } from "./routers/notification";
|
import { notificationRouter } from "./routers/notification";
|
||||||
import { organizationRouter } from "./routers/organization";
|
import { organizationRouter } from "./routers/organization";
|
||||||
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
import { postgresRouter } from "./routers/postgres";
|
import { postgresRouter } from "./routers/postgres";
|
||||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||||
@@ -82,6 +84,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
swarm: swarmRouter,
|
swarm: swarmRouter,
|
||||||
ai: aiRouter,
|
ai: aiRouter,
|
||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
|
licenseKey: licenseKeyRouter,
|
||||||
|
sso: ssoRouter,
|
||||||
schedule: scheduleRouter,
|
schedule: scheduleRouter,
|
||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
volumeBackups: volumeBackupsRouter,
|
volumeBackups: volumeBackupsRouter,
|
||||||
|
|||||||
@@ -469,6 +469,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
await updateApplication(input.applicationId, {
|
await updateApplication(input.applicationId, {
|
||||||
bitbucketRepository: input.bitbucketRepository,
|
bitbucketRepository: input.bitbucketRepository,
|
||||||
|
bitbucketRepositorySlug: input.bitbucketRepositorySlug,
|
||||||
bitbucketOwner: input.bitbucketOwner,
|
bitbucketOwner: input.bitbucketOwner,
|
||||||
bitbucketBranch: input.bitbucketBranch,
|
bitbucketBranch: input.bitbucketBranch,
|
||||||
bitbucketBuildPath: input.bitbucketBuildPath,
|
bitbucketBuildPath: input.bitbucketBuildPath,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createGotifyNotification,
|
createGotifyNotification,
|
||||||
createLarkNotification,
|
createLarkNotification,
|
||||||
createNtfyNotification,
|
createNtfyNotification,
|
||||||
|
createPushoverNotification,
|
||||||
createSlackNotification,
|
createSlackNotification,
|
||||||
createTelegramNotification,
|
createTelegramNotification,
|
||||||
findNotificationById,
|
findNotificationById,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendServerThresholdNotifications,
|
sendServerThresholdNotifications,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
updateGotifyNotification,
|
updateGotifyNotification,
|
||||||
updateLarkNotification,
|
updateLarkNotification,
|
||||||
updateNtfyNotification,
|
updateNtfyNotification,
|
||||||
|
updatePushoverNotification,
|
||||||
updateSlackNotification,
|
updateSlackNotification,
|
||||||
updateTelegramNotification,
|
updateTelegramNotification,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
@@ -46,6 +49,7 @@ import {
|
|||||||
apiCreateGotify,
|
apiCreateGotify,
|
||||||
apiCreateLark,
|
apiCreateLark,
|
||||||
apiCreateNtfy,
|
apiCreateNtfy,
|
||||||
|
apiCreatePushover,
|
||||||
apiCreateSlack,
|
apiCreateSlack,
|
||||||
apiCreateTelegram,
|
apiCreateTelegram,
|
||||||
apiFindOneNotification,
|
apiFindOneNotification,
|
||||||
@@ -55,6 +59,7 @@ import {
|
|||||||
apiTestGotifyConnection,
|
apiTestGotifyConnection,
|
||||||
apiTestLarkConnection,
|
apiTestLarkConnection,
|
||||||
apiTestNtfyConnection,
|
apiTestNtfyConnection,
|
||||||
|
apiTestPushoverConnection,
|
||||||
apiTestSlackConnection,
|
apiTestSlackConnection,
|
||||||
apiTestTelegramConnection,
|
apiTestTelegramConnection,
|
||||||
apiUpdateCustom,
|
apiUpdateCustom,
|
||||||
@@ -63,6 +68,7 @@ import {
|
|||||||
apiUpdateGotify,
|
apiUpdateGotify,
|
||||||
apiUpdateLark,
|
apiUpdateLark,
|
||||||
apiUpdateNtfy,
|
apiUpdateNtfy,
|
||||||
|
apiUpdatePushover,
|
||||||
apiUpdateSlack,
|
apiUpdateSlack,
|
||||||
apiUpdateTelegram,
|
apiUpdateTelegram,
|
||||||
notifications,
|
notifications,
|
||||||
@@ -342,6 +348,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
orderBy: desc(notifications.createdAt),
|
orderBy: desc(notifications.createdAt),
|
||||||
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
||||||
@@ -634,6 +641,62 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
createPushover: adminProcedure
|
||||||
|
.input(apiCreatePushover)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
return await createPushoverNotification(
|
||||||
|
input,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the notification",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
updatePushover: adminProcedure
|
||||||
|
.input(apiUpdatePushover)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const notification = await findNotificationById(input.notificationId);
|
||||||
|
if (
|
||||||
|
IS_CLOUD &&
|
||||||
|
notification.organizationId !== ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to update this notification",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await updatePushoverNotification({
|
||||||
|
...input,
|
||||||
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
testPushoverConnection: adminProcedure
|
||||||
|
.input(apiTestPushoverConnection)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
input,
|
||||||
|
"Test Notification",
|
||||||
|
"Hi, From Dokploy 👋",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error testing the notification",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
|
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
|
||||||
return await db.query.notifications.findMany({
|
return await db.query.notifications.findMany({
|
||||||
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
|||||||
206
apps/dokploy/server/api/routers/proprietary/license-key.ts
Normal file
206
apps/dokploy/server/api/routers/proprietary/license-key.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { user } from "@dokploy/server/db/schema";
|
||||||
|
import { validateLicenseKey } from "@dokploy/server/index";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import {
|
||||||
|
activateLicenseKey,
|
||||||
|
deactivateLicenseKey,
|
||||||
|
} from "@/server/utils/enterprise";
|
||||||
|
|
||||||
|
export const licenseKeyRouter = createTRPCRouter({
|
||||||
|
activate: adminProcedure
|
||||||
|
.input(z.object({ licenseKey: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const currentUserId = ctx.user.id;
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, currentUserId),
|
||||||
|
});
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser.enableEnterpriseFeatures) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
"Please activate enterprise features to activate license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await activateLicenseKey(input.licenseKey);
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
licenseKey: input.licenseKey,
|
||||||
|
isValidEnterpriseLicense: true,
|
||||||
|
})
|
||||||
|
.where(eq(user.id, currentUserId));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to activate license key",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validate: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
const currentUserId = ctx.user.id;
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, currentUserId),
|
||||||
|
});
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser.licenseKey) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "No license key found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser.enableEnterpriseFeatures) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
"Please activate enterprise features to validate license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const valid = await validateLicenseKey(currentUser.licenseKey);
|
||||||
|
if (valid) {
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ isValidEnterpriseLicense: true })
|
||||||
|
.where(eq(user.id, currentUserId));
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to validate license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
deactivate: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
const currentUserId = ctx.user.id;
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, currentUserId),
|
||||||
|
});
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!currentUser.licenseKey) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "No license key found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deactivateLicenseKey(currentUser.licenseKey);
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
licenseKey: null,
|
||||||
|
isValidEnterpriseLicense: false,
|
||||||
|
})
|
||||||
|
.where(eq(user.id, currentUserId));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to deactivate license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getEnterpriseSettings: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const currentUserId = ctx.user.id;
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, currentUserId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enableEnterpriseFeatures: !!currentUser.enableEnterpriseFeatures,
|
||||||
|
licenseKey: currentUser.licenseKey ?? "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const currentUserId = ctx.user.id;
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, currentUserId),
|
||||||
|
columns: {
|
||||||
|
enableEnterpriseFeatures: true,
|
||||||
|
isValidEnterpriseLicense: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return !!(
|
||||||
|
currentUser?.enableEnterpriseFeatures &&
|
||||||
|
currentUser?.isValidEnterpriseLicense
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
updateEnterpriseSettings: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
enableEnterpriseFeatures: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
const currentUserId = ctx.user.id;
|
||||||
|
|
||||||
|
if (input.enableEnterpriseFeatures === undefined) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "enableEnterpriseFeatures must be provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
enableEnterpriseFeatures: input.enableEnterpriseFeatures,
|
||||||
|
})
|
||||||
|
.where(eq(user.id, currentUserId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to update enterprise settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
113
apps/dokploy/server/api/routers/proprietary/sso.ts
Normal file
113
apps/dokploy/server/api/routers/proprietary/sso.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { member, ssoProvider } from "@dokploy/server/db/schema";
|
||||||
|
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
||||||
|
import { auth } from "@dokploy/server/lib/auth";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
enterpriseProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
} from "@/server/api/trpc";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
|
||||||
|
function requestToHeaders(req: {
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
}): Headers {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (req?.headers) {
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (value !== undefined && key.toLowerCase() !== "host") {
|
||||||
|
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ssoRouter = createTRPCRouter({
|
||||||
|
showSignInWithSSO: publicProcedure.query(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const owner = await db.query.member.findFirst({
|
||||||
|
where: eq(member.role, "owner"),
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: {
|
||||||
|
enableEnterpriseFeatures: true,
|
||||||
|
isValidEnterpriseLicense: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [asc(member.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!owner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||||
|
const providers = await db.query.ssoProvider.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
eq(ssoProvider.userId, ctx.session.userId),
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
providerId: true,
|
||||||
|
issuer: true,
|
||||||
|
domain: true,
|
||||||
|
oidcConfig: true,
|
||||||
|
samlConfig: true,
|
||||||
|
organizationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return providers;
|
||||||
|
}),
|
||||||
|
deleteProvider: enterpriseProcedure
|
||||||
|
.input(z.object({ providerId: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(ssoProvider)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ssoProvider.providerId, input.providerId),
|
||||||
|
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
eq(ssoProvider.userId, ctx.session.userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: ssoProvider.id });
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message:
|
||||||
|
"SSO provider not found or you do not have permission to delete it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
register: enterpriseProcedure
|
||||||
|
.input(ssoProviderBodySchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const organizationId = ctx.session.activeOrganizationId;
|
||||||
|
|
||||||
|
const result = await auth.registerSSOProvider({
|
||||||
|
body: {
|
||||||
|
...input,
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
headers: requestToHeaders(ctx.req),
|
||||||
|
});
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
apiServerSchema,
|
apiServerSchema,
|
||||||
apiTraefikConfig,
|
apiTraefikConfig,
|
||||||
apiUpdateDockerCleanup,
|
apiUpdateDockerCleanup,
|
||||||
|
apiUpdateWhitelabel,
|
||||||
projects,
|
projects,
|
||||||
server,
|
server,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
@@ -72,6 +73,7 @@ import { appRouter } from "../root";
|
|||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
|
enterpriseProcedure,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
@@ -84,6 +86,57 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
const settings = await getWebServerSettings();
|
const settings = await getWebServerSettings();
|
||||||
return settings;
|
return settings;
|
||||||
}),
|
}),
|
||||||
|
getWhitelabelSettings: publicProcedure.query(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
if (!settings) return null;
|
||||||
|
return {
|
||||||
|
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
|
||||||
|
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? null,
|
||||||
|
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? null,
|
||||||
|
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? null,
|
||||||
|
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? null,
|
||||||
|
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? null,
|
||||||
|
whitelabelLoginBackgroundImageUrl:
|
||||||
|
settings.whitelabelLoginBackgroundImageUrl ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
updateWhitelabelSettings: enterpriseProcedure
|
||||||
|
.input(apiUpdateWhitelabel)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (input.whitelabelAppName !== undefined)
|
||||||
|
updates.whitelabelAppName = input.whitelabelAppName;
|
||||||
|
if (input.whitelabelLogoUrl !== undefined)
|
||||||
|
updates.whitelabelLogoUrl =
|
||||||
|
input.whitelabelLogoUrl === "" ? null : input.whitelabelLogoUrl;
|
||||||
|
if (input.whitelabelLoginLogoUrl !== undefined)
|
||||||
|
updates.whitelabelLoginLogoUrl =
|
||||||
|
input.whitelabelLoginLogoUrl === ""
|
||||||
|
? null
|
||||||
|
: input.whitelabelLoginLogoUrl;
|
||||||
|
if (input.whitelabelFaviconUrl !== undefined)
|
||||||
|
updates.whitelabelFaviconUrl =
|
||||||
|
input.whitelabelFaviconUrl === ""
|
||||||
|
? null
|
||||||
|
: input.whitelabelFaviconUrl;
|
||||||
|
if (input.whitelabelLoginTitle !== undefined)
|
||||||
|
updates.whitelabelLoginTitle = input.whitelabelLoginTitle;
|
||||||
|
if (input.whitelabelLoginSubtitle !== undefined)
|
||||||
|
updates.whitelabelLoginSubtitle = input.whitelabelLoginSubtitle;
|
||||||
|
if (input.whitelabelLoginBackgroundImageUrl !== undefined)
|
||||||
|
updates.whitelabelLoginBackgroundImageUrl =
|
||||||
|
input.whitelabelLoginBackgroundImageUrl === ""
|
||||||
|
? null
|
||||||
|
: input.whitelabelLoginBackgroundImageUrl;
|
||||||
|
const updated = await updateWebServerSettings(updates as any);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
reloadServer: adminProcedure.mutation(async () => {
|
reloadServer: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
* need to use are documented accordingly near the end.
|
* need to use are documented accordingly near the end.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { user as userSchema } from "@dokploy/server/db/schema";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||||
import {
|
import {
|
||||||
@@ -217,3 +219,43 @@ export const adminProcedure = t.procedure.use(({ ctx, next }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires admin/owner role AND enterprise enabled with a license key in DB.
|
||||||
|
* Does NOT call the license server on every request; full validation (haveValidLicenseKey)
|
||||||
|
* is used in the UI gate and when activating/validating keys.
|
||||||
|
*/
|
||||||
|
export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
|
||||||
|
if (
|
||||||
|
!ctx.session ||
|
||||||
|
!ctx.user ||
|
||||||
|
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
|
||||||
|
) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await ctx.db.query.user.findFirst({
|
||||||
|
where: eq(userSchema.id, ctx.user.id),
|
||||||
|
columns: {
|
||||||
|
enableEnterpriseFeatures: true,
|
||||||
|
isValidEnterpriseLicense: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentUser?.enableEnterpriseFeatures ||
|
||||||
|
!currentUser.isValidEnterpriseLicense
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Valid enterprise license required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
session: ctx.session,
|
||||||
|
user: ctx.user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
initCancelDeployments,
|
initCancelDeployments,
|
||||||
initCronJobs,
|
initCronJobs,
|
||||||
|
initEnterpriseBackupCronJobs,
|
||||||
initializeNetwork,
|
initializeNetwork,
|
||||||
initSchedules,
|
initSchedules,
|
||||||
initVolumeBackupsCronJobs,
|
initVolumeBackupsCronJobs,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
import { migration } from "@/server/db/migration";
|
import { migration } from "@/server/db/migration";
|
||||||
|
import packageInfo from "../package.json";
|
||||||
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
|
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
|
||||||
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
|
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
|
||||||
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
|
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
|
||||||
@@ -33,13 +35,14 @@ if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
|||||||
setupDirectories();
|
setupDirectories();
|
||||||
createDefaultTraefikConfig();
|
createDefaultTraefikConfig();
|
||||||
createDefaultServerTraefikConfig();
|
createDefaultServerTraefikConfig();
|
||||||
console.log("✅ Critical initialization complete");
|
console.log("✅ initialization complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
void app.prepare().then(async () => {
|
void app.prepare().then(async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log("Running DokployVersion: ", packageInfo.version);
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
handle(req, res);
|
handle(req, res);
|
||||||
});
|
});
|
||||||
@@ -71,6 +74,8 @@ void app.prepare().then(async () => {
|
|||||||
|
|
||||||
server.listen(PORT, HOST);
|
server.listen(PORT, HOST);
|
||||||
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
||||||
|
await initEnterpriseBackupCronJobs();
|
||||||
|
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD) {
|
||||||
console.log("Starting Deployment Worker");
|
console.log("Starting Deployment Worker");
|
||||||
const { deploymentWorker } = await import("./queues/deployments-queue");
|
const { deploymentWorker } = await import("./queues/deployments-queue");
|
||||||
|
|||||||
83
apps/dokploy/server/utils/enterprise.ts
Normal file
83
apps/dokploy/server/utils/enterprise.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { getPublicIpWithFallback } from "@dokploy/server/index";
|
||||||
|
|
||||||
|
const LICENSE_KEY_URL = process.env.LICENSE_KEY_URL || "http://localhost:4002";
|
||||||
|
|
||||||
|
export const validateLicenseKey = async (licenseKey: string) => {
|
||||||
|
try {
|
||||||
|
const ip = await getPublicIpWithFallback();
|
||||||
|
const result = await fetch(`${LICENSE_KEY_URL}/licenses/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, ip }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const errorData = await result.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || "Failed to validate license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await result.json();
|
||||||
|
return data.valid;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to validate license key",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activateLicenseKey = async (licenseKey: string) => {
|
||||||
|
try {
|
||||||
|
const ip = await getPublicIpWithFallback();
|
||||||
|
const result = await fetch(`${LICENSE_KEY_URL}/licenses/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, ip }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const errorData = await result.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || "Failed to activate license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await result.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to activate license key",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deactivateLicenseKey = async (licenseKey: string) => {
|
||||||
|
try {
|
||||||
|
const ip = await getPublicIpWithFallback();
|
||||||
|
const result = await fetch(`${LICENSE_KEY_URL}/licenses/deactivate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, ip }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const errorData = await result.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || "Failed to deactivate license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await result.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to deactivate license key",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
await initializeNetwork();
|
await initializeNetwork();
|
||||||
createDefaultTraefikConfig();
|
createDefaultTraefikConfig();
|
||||||
createDefaultServerTraefikConfig();
|
createDefaultServerTraefikConfig();
|
||||||
await execAsync("docker pull traefik:v3.6.1");
|
await execAsync("docker pull traefik:v3.6.7");
|
||||||
await initializeStandaloneTraefik();
|
await initializeStandaloneTraefik();
|
||||||
await initializeRedis();
|
await initializeRedis();
|
||||||
await initializePostgres();
|
await initializePostgres();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!**/.docker",
|
"!**/.docker",
|
||||||
|
|||||||
274
packages/server/auth-schema2.ts
Normal file
274
packages/server/auth-schema2.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// import { relations } from "drizzle-orm";
|
||||||
|
// import {
|
||||||
|
// pgTable,
|
||||||
|
// text,
|
||||||
|
// timestamp,
|
||||||
|
// boolean,
|
||||||
|
// integer,
|
||||||
|
// index,
|
||||||
|
// uniqueIndex,
|
||||||
|
// } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// export const user = pgTable("user", {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// firstName: text("first_name").notNull(),
|
||||||
|
// email: text("email").notNull().unique(),
|
||||||
|
// emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
|
// image: text("image"),
|
||||||
|
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
// updatedAt: timestamp("updated_at")
|
||||||
|
// .defaultNow()
|
||||||
|
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
// .notNull(),
|
||||||
|
// twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||||
|
// role: text("role"),
|
||||||
|
// ownerId: text("owner_id"),
|
||||||
|
// allowImpersonation: boolean("allow_impersonation").default(false),
|
||||||
|
// lastName: text("last_name").default(""),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// export const session = pgTable(
|
||||||
|
// "session",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
// token: text("token").notNull().unique(),
|
||||||
|
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
// updatedAt: timestamp("updated_at")
|
||||||
|
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
// .notNull(),
|
||||||
|
// ipAddress: text("ip_address"),
|
||||||
|
// userAgent: text("user_agent"),
|
||||||
|
// userId: text("user_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// activeOrganizationId: text("active_organization_id"),
|
||||||
|
// },
|
||||||
|
// (table) => [index("session_userId_idx").on(table.userId)],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const account = pgTable(
|
||||||
|
// "account",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// accountId: text("account_id").notNull(),
|
||||||
|
// providerId: text("provider_id").notNull(),
|
||||||
|
// userId: text("user_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// accessToken: text("access_token"),
|
||||||
|
// refreshToken: text("refresh_token"),
|
||||||
|
// idToken: text("id_token"),
|
||||||
|
// accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
|
// refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
|
// scope: text("scope"),
|
||||||
|
// password: text("password"),
|
||||||
|
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
// updatedAt: timestamp("updated_at")
|
||||||
|
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
// .notNull(),
|
||||||
|
// },
|
||||||
|
// (table) => [index("account_userId_idx").on(table.userId)],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const verification = pgTable(
|
||||||
|
// "verification",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// identifier: text("identifier").notNull(),
|
||||||
|
// value: text("value").notNull(),
|
||||||
|
// expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
// updatedAt: timestamp("updated_at")
|
||||||
|
// .defaultNow()
|
||||||
|
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
// .notNull(),
|
||||||
|
// },
|
||||||
|
// (table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const apikey = pgTable(
|
||||||
|
// "apikey",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// name: text("name"),
|
||||||
|
// start: text("start"),
|
||||||
|
// prefix: text("prefix"),
|
||||||
|
// key: text("key").notNull(),
|
||||||
|
// userId: text("user_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// refillInterval: integer("refill_interval"),
|
||||||
|
// refillAmount: integer("refill_amount"),
|
||||||
|
// lastRefillAt: timestamp("last_refill_at"),
|
||||||
|
// enabled: boolean("enabled").default(true),
|
||||||
|
// rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||||
|
// rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||||
|
// rateLimitMax: integer("rate_limit_max").default(10),
|
||||||
|
// requestCount: integer("request_count").default(0),
|
||||||
|
// remaining: integer("remaining"),
|
||||||
|
// lastRequest: timestamp("last_request"),
|
||||||
|
// expiresAt: timestamp("expires_at"),
|
||||||
|
// createdAt: timestamp("created_at").notNull(),
|
||||||
|
// updatedAt: timestamp("updated_at").notNull(),
|
||||||
|
// permissions: text("permissions"),
|
||||||
|
// metadata: text("metadata"),
|
||||||
|
// },
|
||||||
|
// (table) => [
|
||||||
|
// index("apikey_key_idx").on(table.key),
|
||||||
|
// index("apikey_userId_idx").on(table.userId),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const ssoProvider = pgTable("sso_provider", {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// issuer: text("issuer").notNull(),
|
||||||
|
// oidcConfig: text("oidc_config"),
|
||||||
|
// samlConfig: text("saml_config"),
|
||||||
|
// userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// providerId: text("provider_id").notNull().unique(),
|
||||||
|
// organizationId: text("organization_id"),
|
||||||
|
// domain: text("domain").notNull(),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// export const twoFactor = pgTable(
|
||||||
|
// "two_factor",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// secret: text("secret").notNull(),
|
||||||
|
// backupCodes: text("backup_codes").notNull(),
|
||||||
|
// userId: text("user_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// },
|
||||||
|
// (table) => [
|
||||||
|
// index("twoFactor_secret_idx").on(table.secret),
|
||||||
|
// index("twoFactor_userId_idx").on(table.userId),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const organization = pgTable(
|
||||||
|
// "organization",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// name: text("name").notNull(),
|
||||||
|
// slug: text("slug").notNull().unique(),
|
||||||
|
// logo: text("logo"),
|
||||||
|
// createdAt: timestamp("created_at").notNull(),
|
||||||
|
// metadata: text("metadata"),
|
||||||
|
// },
|
||||||
|
// (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const member = pgTable(
|
||||||
|
// "member",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// organizationId: text("organization_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
// userId: text("user_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// role: text("role").default("member").notNull(),
|
||||||
|
// createdAt: timestamp("created_at").notNull(),
|
||||||
|
// },
|
||||||
|
// (table) => [
|
||||||
|
// index("member_organizationId_idx").on(table.organizationId),
|
||||||
|
// index("member_userId_idx").on(table.userId),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const invitation = pgTable(
|
||||||
|
// "invitation",
|
||||||
|
// {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// organizationId: text("organization_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
// email: text("email").notNull(),
|
||||||
|
// role: text("role"),
|
||||||
|
// status: text("status").default("pending").notNull(),
|
||||||
|
// expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
// inviterId: text("inviter_id")
|
||||||
|
// .notNull()
|
||||||
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// },
|
||||||
|
// (table) => [
|
||||||
|
// index("invitation_organizationId_idx").on(table.organizationId),
|
||||||
|
// index("invitation_email_idx").on(table.email),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// export const userRelations = relations(user, ({ many }) => ({
|
||||||
|
// sessions: many(session),
|
||||||
|
// accounts: many(account),
|
||||||
|
// apikeys: many(apikey),
|
||||||
|
// ssoProviders: many(ssoProvider),
|
||||||
|
// twoFactors: many(twoFactor),
|
||||||
|
// members: many(member),
|
||||||
|
// invitations: many(invitation),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [session.userId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const accountRelations = relations(account, ({ one }) => ({
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [account.userId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [apikey.userId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [ssoProvider.userId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [twoFactor.userId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const organizationRelations = relations(organization, ({ many }) => ({
|
||||||
|
// members: many(member),
|
||||||
|
// invitations: many(invitation),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const memberRelations = relations(member, ({ one }) => ({
|
||||||
|
// organization: one(organization, {
|
||||||
|
// fields: [member.organizationId],
|
||||||
|
// references: [organization.id],
|
||||||
|
// }),
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [member.userId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||||
|
// organization: one(organization, {
|
||||||
|
// fields: [invitation.organizationId],
|
||||||
|
// references: [organization.id],
|
||||||
|
// }),
|
||||||
|
// user: one(user, {
|
||||||
|
// fields: [invitation.inviterId],
|
||||||
|
// references: [user.id],
|
||||||
|
// }),
|
||||||
|
// }));
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
|
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
|
||||||
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
|
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dbml:generate": "npx tsx src/db/schema/dbml.ts"
|
"dbml:generate": "npx tsx src/db/schema/dbml.ts",
|
||||||
|
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.5",
|
"@ai-sdk/anthropic": "^2.0.5",
|
||||||
@@ -43,12 +44,13 @@
|
|||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@react-email/components": "^0.0.21",
|
"@react-email/components": "^0.0.21",
|
||||||
|
"@better-auth/sso":"1.4.18",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"ai": "^5.0.17",
|
"ai": "^5.0.17",
|
||||||
"ai-sdk-ollama": "^0.5.1",
|
"ai-sdk-ollama": "^0.5.1",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "v1.2.8-beta.7",
|
"better-auth": "1.4.18",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
"semver": "7.7.3"
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@better-auth/cli": "1.4.18",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
|
|||||||
@@ -34,6 +34,5 @@ if (DATABASE_URL) {
|
|||||||
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
|
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
|
||||||
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
|
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
|
||||||
`);
|
`);
|
||||||
dbUrl =
|
dbUrl = "postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
|
||||||
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
|
import { ssoProvider } from "./sso";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
|
|
||||||
export const account = pgTable("account", {
|
export const account = pgTable("account", {
|
||||||
@@ -78,6 +79,7 @@ export const organizationRelations = relations(
|
|||||||
servers: many(server),
|
servers: many(server),
|
||||||
projects: many(projects),
|
projects: many(projects),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
|
ssoProviders: many(ssoProvider),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { sshKeys } from "./ssh-key";
|
import { sshKeys } from "./ssh-key";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
export const sourceType = pgEnum("sourceType", [
|
export const sourceType = pgEnum("sourceType", [
|
||||||
"docker",
|
"docker",
|
||||||
"git",
|
"git",
|
||||||
@@ -136,6 +136,7 @@ export const applications = pgTable("application", {
|
|||||||
giteaBuildPath: text("giteaBuildPath").default("/"),
|
giteaBuildPath: text("giteaBuildPath").default("/"),
|
||||||
// Bitbucket
|
// Bitbucket
|
||||||
bitbucketRepository: text("bitbucketRepository"),
|
bitbucketRepository: text("bitbucketRepository"),
|
||||||
|
bitbucketRepositorySlug: text("bitbucketRepositorySlug"),
|
||||||
bitbucketOwner: text("bitbucketOwner"),
|
bitbucketOwner: text("bitbucketOwner"),
|
||||||
bitbucketBranch: text("bitbucketBranch"),
|
bitbucketBranch: text("bitbucketBranch"),
|
||||||
bitbucketBuildPath: text("bitbucketBuildPath").default("/"),
|
bitbucketBuildPath: text("bitbucketBuildPath").default("/"),
|
||||||
@@ -286,7 +287,12 @@ export const applicationsRelations = relations(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const createSchema = createInsertSchema(applications, {
|
const createSchema = createInsertSchema(applications, {
|
||||||
appName: z.string(),
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
applicationId: z.string(),
|
applicationId: z.string(),
|
||||||
autoDeploy: z.boolean(),
|
autoDeploy: z.boolean(),
|
||||||
@@ -451,6 +457,7 @@ export const apiSaveBitbucketProvider = createSchema
|
|||||||
bitbucketBuildPath: true,
|
bitbucketBuildPath: true,
|
||||||
bitbucketOwner: true,
|
bitbucketOwner: true,
|
||||||
bitbucketRepository: true,
|
bitbucketRepository: true,
|
||||||
|
bitbucketRepositorySlug: true,
|
||||||
bitbucketId: true,
|
bitbucketId: true,
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
watchPaths: true,
|
watchPaths: true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { schedules } from "./schedule";
|
|||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { applicationStatus, triggerType } from "./shared";
|
import { applicationStatus, triggerType } from "./shared";
|
||||||
import { sshKeys } from "./ssh-key";
|
import { sshKeys } from "./ssh-key";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||||
"git",
|
"git",
|
||||||
"github",
|
"github",
|
||||||
@@ -56,6 +56,7 @@ export const compose = pgTable("compose", {
|
|||||||
gitlabPathNamespace: text("gitlabPathNamespace"),
|
gitlabPathNamespace: text("gitlabPathNamespace"),
|
||||||
// Bitbucket
|
// Bitbucket
|
||||||
bitbucketRepository: text("bitbucketRepository"),
|
bitbucketRepository: text("bitbucketRepository"),
|
||||||
|
bitbucketRepositorySlug: text("bitbucketRepositorySlug"),
|
||||||
bitbucketOwner: text("bitbucketOwner"),
|
bitbucketOwner: text("bitbucketOwner"),
|
||||||
bitbucketBranch: text("bitbucketBranch"),
|
bitbucketBranch: text("bitbucketBranch"),
|
||||||
// Gitea
|
// Gitea
|
||||||
@@ -146,6 +147,12 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
composeFile: z.string().optional(),
|
composeFile: z.string().optional(),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export * from "./server";
|
|||||||
export * from "./session";
|
export * from "./session";
|
||||||
export * from "./shared";
|
export * from "./shared";
|
||||||
export * from "./ssh-key";
|
export * from "./ssh-key";
|
||||||
|
export * from "./sso";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./volume-backups";
|
export * from "./volume-backups";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type UpdateConfigSwarm,
|
type UpdateConfigSwarm,
|
||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
|
|
||||||
export const mariadb = pgTable("mariadb", {
|
export const mariadb = pgTable("mariadb", {
|
||||||
mariadbId: text("mariadbId")
|
mariadbId: text("mariadbId")
|
||||||
@@ -96,7 +96,12 @@ export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
|||||||
const createSchema = createInsertSchema(mariadb, {
|
const createSchema = createInsertSchema(mariadb, {
|
||||||
mariadbId: z.string(),
|
mariadbId: z.string(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
appName: z.string().min(1),
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
databaseName: z.string().min(1),
|
databaseName: z.string().min(1),
|
||||||
databaseUser: z.string().min(1),
|
databaseUser: z.string().min(1),
|
||||||
@@ -138,20 +143,18 @@ const createSchema = createInsertSchema(mariadb, {
|
|||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateMariaDB = createSchema
|
export const apiCreateMariaDB = createSchema.pick({
|
||||||
.pick({
|
name: true,
|
||||||
name: true,
|
appName: true,
|
||||||
appName: true,
|
dockerImage: true,
|
||||||
dockerImage: true,
|
databaseRootPassword: true,
|
||||||
databaseRootPassword: true,
|
environmentId: true,
|
||||||
environmentId: true,
|
description: true,
|
||||||
description: true,
|
databaseName: true,
|
||||||
databaseName: true,
|
databaseUser: true,
|
||||||
databaseUser: true,
|
databasePassword: true,
|
||||||
databasePassword: true,
|
serverId: true,
|
||||||
serverId: true,
|
});
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiFindOneMariaDB = createSchema
|
export const apiFindOneMariaDB = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
type UpdateConfigSwarm,
|
type UpdateConfigSwarm,
|
||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
|
|
||||||
export const mongo = pgTable("mongo", {
|
export const mongo = pgTable("mongo", {
|
||||||
mongoId: text("mongoId")
|
mongoId: text("mongoId")
|
||||||
@@ -98,7 +98,12 @@ export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(mongo, {
|
const createSchema = createInsertSchema(mongo, {
|
||||||
appName: z.string().min(1),
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
mongoId: z.string(),
|
mongoId: z.string(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -135,19 +140,17 @@ const createSchema = createInsertSchema(mongo, {
|
|||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateMongo = createSchema
|
export const apiCreateMongo = createSchema.pick({
|
||||||
.pick({
|
name: true,
|
||||||
name: true,
|
appName: true,
|
||||||
appName: true,
|
dockerImage: true,
|
||||||
dockerImage: true,
|
environmentId: true,
|
||||||
environmentId: true,
|
description: true,
|
||||||
description: true,
|
databaseUser: true,
|
||||||
databaseUser: true,
|
databasePassword: true,
|
||||||
databasePassword: true,
|
serverId: true,
|
||||||
serverId: true,
|
replicaSets: true,
|
||||||
replicaSets: true,
|
});
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiFindOneMongo = createSchema
|
export const apiFindOneMongo = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type UpdateConfigSwarm,
|
type UpdateConfigSwarm,
|
||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
|
|
||||||
export const mysql = pgTable("mysql", {
|
export const mysql = pgTable("mysql", {
|
||||||
mysqlId: text("mysqlId")
|
mysqlId: text("mysqlId")
|
||||||
@@ -93,7 +93,12 @@ export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
|||||||
|
|
||||||
const createSchema = createInsertSchema(mysql, {
|
const createSchema = createInsertSchema(mysql, {
|
||||||
mysqlId: z.string(),
|
mysqlId: z.string(),
|
||||||
appName: z.string().min(1),
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
databaseName: z.string().min(1),
|
databaseName: z.string().min(1),
|
||||||
@@ -135,20 +140,18 @@ const createSchema = createInsertSchema(mysql, {
|
|||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateMySql = createSchema
|
export const apiCreateMySql = createSchema.pick({
|
||||||
.pick({
|
name: true,
|
||||||
name: true,
|
appName: true,
|
||||||
appName: true,
|
dockerImage: true,
|
||||||
dockerImage: true,
|
environmentId: true,
|
||||||
environmentId: true,
|
description: true,
|
||||||
description: true,
|
databaseName: true,
|
||||||
databaseName: true,
|
databaseUser: true,
|
||||||
databaseUser: true,
|
databasePassword: true,
|
||||||
databasePassword: true,
|
databaseRootPassword: true,
|
||||||
databaseRootPassword: true,
|
serverId: true,
|
||||||
serverId: true,
|
});
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiFindOneMySql = createSchema
|
export const apiFindOneMySql = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const notificationType = pgEnum("notificationType", [
|
|||||||
"email",
|
"email",
|
||||||
"gotify",
|
"gotify",
|
||||||
"ntfy",
|
"ntfy",
|
||||||
|
"pushover",
|
||||||
"custom",
|
"custom",
|
||||||
"lark",
|
"lark",
|
||||||
]);
|
]);
|
||||||
@@ -64,6 +65,9 @@ export const notifications = pgTable("notification", {
|
|||||||
larkId: text("larkId").references(() => lark.larkId, {
|
larkId: text("larkId").references(() => lark.larkId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
organizationId: text("organizationId")
|
organizationId: text("organizationId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
@@ -149,6 +153,18 @@ export const lark = pgTable("lark", {
|
|||||||
webhookUrl: text("webhookUrl").notNull(),
|
webhookUrl: text("webhookUrl").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const pushover = pgTable("pushover", {
|
||||||
|
pushoverId: text("pushoverId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
userKey: text("userKey").notNull(),
|
||||||
|
apiToken: text("apiToken").notNull(),
|
||||||
|
priority: integer("priority").notNull().default(0),
|
||||||
|
retry: integer("retry"),
|
||||||
|
expire: integer("expire"),
|
||||||
|
});
|
||||||
|
|
||||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
slack: one(slack, {
|
slack: one(slack, {
|
||||||
fields: [notifications.slackId],
|
fields: [notifications.slackId],
|
||||||
@@ -182,6 +198,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
|||||||
fields: [notifications.larkId],
|
fields: [notifications.larkId],
|
||||||
references: [lark.larkId],
|
references: [lark.larkId],
|
||||||
}),
|
}),
|
||||||
|
pushover: one(pushover, {
|
||||||
|
fields: [notifications.pushoverId],
|
||||||
|
references: [pushover.pushoverId],
|
||||||
|
}),
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [notifications.organizationId],
|
fields: [notifications.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
@@ -439,6 +459,69 @@ export const apiTestLarkConnection = apiCreateLark.pick({
|
|||||||
webhookUrl: true,
|
webhookUrl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiCreatePushover = notificationsSchema
|
||||||
|
.pick({
|
||||||
|
appBuildError: true,
|
||||||
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
|
dokployRestart: true,
|
||||||
|
name: true,
|
||||||
|
appDeploy: true,
|
||||||
|
dockerCleanup: true,
|
||||||
|
serverThreshold: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
userKey: z.string().min(1),
|
||||||
|
apiToken: z.string().min(1),
|
||||||
|
priority: z.number().min(-2).max(2).default(0),
|
||||||
|
retry: z.number().min(30).nullish(),
|
||||||
|
expire: z.number().min(1).max(10800).nullish(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
data.priority !== 2 || (data.retry != null && data.expire != null),
|
||||||
|
{
|
||||||
|
message: "Retry and expire are required for emergency priority (2)",
|
||||||
|
path: ["retry"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const apiUpdatePushover = z.object({
|
||||||
|
notificationId: z.string().min(1),
|
||||||
|
pushoverId: z.string().min(1),
|
||||||
|
organizationId: z.string().optional(),
|
||||||
|
userKey: z.string().min(1).optional(),
|
||||||
|
apiToken: z.string().min(1).optional(),
|
||||||
|
priority: z.number().min(-2).max(2).optional(),
|
||||||
|
retry: z.number().min(30).nullish(),
|
||||||
|
expire: z.number().min(1).max(10800).nullish(),
|
||||||
|
appBuildError: z.boolean().optional(),
|
||||||
|
databaseBackup: z.boolean().optional(),
|
||||||
|
volumeBackup: z.boolean().optional(),
|
||||||
|
dokployRestart: z.boolean().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appDeploy: z.boolean().optional(),
|
||||||
|
dockerCleanup: z.boolean().optional(),
|
||||||
|
serverThreshold: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiTestPushoverConnection = z
|
||||||
|
.object({
|
||||||
|
userKey: z.string().min(1),
|
||||||
|
apiToken: z.string().min(1),
|
||||||
|
priority: z.number().min(-2).max(2),
|
||||||
|
retry: z.number().min(30).nullish(),
|
||||||
|
expire: z.number().min(1).max(10800).nullish(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
data.priority !== 2 || (data.retry != null && data.expire != null),
|
||||||
|
{
|
||||||
|
message: "Retry and expire are required for emergency priority (2)",
|
||||||
|
path: ["retry"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const apiSendTest = notificationsSchema
|
export const apiSendTest = notificationsSchema
|
||||||
.extend({
|
.extend({
|
||||||
botToken: z.string(),
|
botToken: z.string(),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type UpdateConfigSwarm,
|
type UpdateConfigSwarm,
|
||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
|
|
||||||
export const postgres = pgTable("postgres", {
|
export const postgres = pgTable("postgres", {
|
||||||
postgresId: text("postgresId")
|
postgresId: text("postgresId")
|
||||||
@@ -94,6 +94,12 @@ export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
|||||||
const createSchema = createInsertSchema(postgres, {
|
const createSchema = createInsertSchema(postgres, {
|
||||||
postgresId: z.string(),
|
postgresId: z.string(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
databasePassword: z
|
databasePassword: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||||
@@ -102,7 +108,7 @@ const createSchema = createInsertSchema(postgres, {
|
|||||||
}),
|
}),
|
||||||
databaseName: z.string().min(1),
|
databaseName: z.string().min(1),
|
||||||
databaseUser: z.string().min(1),
|
databaseUser: z.string().min(1),
|
||||||
dockerImage: z.string().default("postgres:15"),
|
dockerImage: z.string().default("postgres:18"),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
args: z.array(z.string()).optional(),
|
args: z.array(z.string()).optional(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
@@ -128,19 +134,17 @@ const createSchema = createInsertSchema(postgres, {
|
|||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreatePostgres = createSchema
|
export const apiCreatePostgres = createSchema.pick({
|
||||||
.pick({
|
name: true,
|
||||||
name: true,
|
appName: true,
|
||||||
appName: true,
|
databaseName: true,
|
||||||
databaseName: true,
|
databaseUser: true,
|
||||||
databaseUser: true,
|
databasePassword: true,
|
||||||
databasePassword: true,
|
dockerImage: true,
|
||||||
dockerImage: true,
|
environmentId: true,
|
||||||
environmentId: true,
|
description: true,
|
||||||
description: true,
|
serverId: true,
|
||||||
serverId: true,
|
});
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiFindOnePostgres = createSchema
|
export const apiFindOnePostgres = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type UpdateConfigSwarm,
|
type UpdateConfigSwarm,
|
||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { generateAppName } from "./utils";
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||||
|
|
||||||
export const redis = pgTable("redis", {
|
export const redis = pgTable("redis", {
|
||||||
redisId: text("redisId")
|
redisId: text("redisId")
|
||||||
@@ -88,7 +88,12 @@ export const redisRelations = relations(redis, ({ one, many }) => ({
|
|||||||
|
|
||||||
const createSchema = createInsertSchema(redis, {
|
const createSchema = createInsertSchema(redis, {
|
||||||
redisId: z.string(),
|
redisId: z.string(),
|
||||||
appName: z.string().min(1),
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(63)
|
||||||
|
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
|
||||||
|
.optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
databasePassword: z.string(),
|
databasePassword: z.string(),
|
||||||
@@ -117,17 +122,15 @@ const createSchema = createInsertSchema(redis, {
|
|||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateRedis = createSchema
|
export const apiCreateRedis = createSchema.pick({
|
||||||
.pick({
|
name: true,
|
||||||
name: true,
|
appName: true,
|
||||||
appName: true,
|
databasePassword: true,
|
||||||
databasePassword: true,
|
dockerImage: true,
|
||||||
dockerImage: true,
|
environmentId: true,
|
||||||
environmentId: true,
|
description: true,
|
||||||
description: true,
|
serverId: true,
|
||||||
serverId: true,
|
});
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiFindOneRedis = createSchema
|
export const apiFindOneRedis = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
121
packages/server/src/db/schema/sso.ts
Normal file
121
packages/server/src/db/schema/sso.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { organization } from "./account";
|
||||||
|
import { user } from "./user";
|
||||||
|
|
||||||
|
export const ssoProvider = pgTable("sso_provider", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
issuer: text("issuer").notNull(),
|
||||||
|
oidcConfig: text("oidc_config"),
|
||||||
|
samlConfig: text("saml_config"),
|
||||||
|
providerId: text("provider_id").notNull().unique(),
|
||||||
|
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
organizationId: text("organization_id").references(() => organization.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
domain: text("domain").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||||
|
organization: one(organization, {
|
||||||
|
fields: [ssoProvider.organizationId],
|
||||||
|
references: [organization.id],
|
||||||
|
}),
|
||||||
|
user: one(user, {
|
||||||
|
fields: [ssoProvider.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ssoProviderBodySchema = z.object({
|
||||||
|
providerId: z.string({}),
|
||||||
|
issuer: z.string({}),
|
||||||
|
domain: z.string({}),
|
||||||
|
oidcConfig: z
|
||||||
|
.object({
|
||||||
|
clientId: z.string({}),
|
||||||
|
clientSecret: z.string({}),
|
||||||
|
authorizationEndpoint: z.string({}).optional(),
|
||||||
|
tokenEndpoint: z.string({}).optional(),
|
||||||
|
userInfoEndpoint: z.string({}).optional(),
|
||||||
|
tokenEndpointAuthentication: z
|
||||||
|
.enum(["client_secret_post", "client_secret_basic"])
|
||||||
|
.optional(),
|
||||||
|
jwksEndpoint: z.string({}).optional(),
|
||||||
|
discoveryEndpoint: z.string().optional(),
|
||||||
|
skipDiscovery: z.boolean().optional(),
|
||||||
|
scopes: z.array(z.string()).optional(),
|
||||||
|
pkce: z.boolean().default(true).optional(),
|
||||||
|
mapping: z
|
||||||
|
.object({
|
||||||
|
id: z.string({}),
|
||||||
|
email: z.string({}),
|
||||||
|
emailVerified: z.string({}).optional(),
|
||||||
|
name: z.string({}),
|
||||||
|
image: z.string({}).optional(),
|
||||||
|
extraFields: z.record(z.string(), z.any()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
samlConfig: z
|
||||||
|
.object({
|
||||||
|
entryPoint: z.string({}),
|
||||||
|
cert: z.string({}),
|
||||||
|
callbackUrl: z.string({}),
|
||||||
|
audience: z.string().optional(),
|
||||||
|
idpMetadata: z
|
||||||
|
.object({
|
||||||
|
metadata: z.string().optional(),
|
||||||
|
entityID: z.string().optional(),
|
||||||
|
cert: z.string().optional(),
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
privateKeyPass: z.string().optional(),
|
||||||
|
isAssertionEncrypted: z.boolean().optional(),
|
||||||
|
encPrivateKey: z.string().optional(),
|
||||||
|
encPrivateKeyPass: z.string().optional(),
|
||||||
|
singleSignOnService: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
Binding: z.string(),
|
||||||
|
Location: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
spMetadata: z.object({
|
||||||
|
metadata: z.string().optional(),
|
||||||
|
entityID: z.string().optional(),
|
||||||
|
binding: z.string().optional(),
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
privateKeyPass: z.string().optional(),
|
||||||
|
isAssertionEncrypted: z.boolean().optional(),
|
||||||
|
encPrivateKey: z.string().optional(),
|
||||||
|
encPrivateKeyPass: z.string().optional(),
|
||||||
|
}),
|
||||||
|
wantAssertionsSigned: z.boolean().optional(),
|
||||||
|
authnRequestsSigned: z.boolean().optional(),
|
||||||
|
signatureAlgorithm: z.string().optional(),
|
||||||
|
digestAlgorithm: z.string().optional(),
|
||||||
|
identifierFormat: z.string().optional(),
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
decryptionPvk: z.string().optional(),
|
||||||
|
additionalParams: z.record(z.string(), z.any()).optional(),
|
||||||
|
mapping: z
|
||||||
|
.object({
|
||||||
|
id: z.string({}),
|
||||||
|
email: z.string({}),
|
||||||
|
emailVerified: z.string({}).optional(),
|
||||||
|
name: z.string({}),
|
||||||
|
firstName: z.string({}).optional(),
|
||||||
|
lastName: z.string({}).optional(),
|
||||||
|
extraFields: z.record(z.string(), z.any()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
organizationId: z.string({}).optional(),
|
||||||
|
overrideUserInfo: z.boolean({}).default(false).optional(),
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { account, apikey, organization } from "./account";
|
|||||||
import { backups } from "./backups";
|
import { backups } from "./backups";
|
||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
|
import { ssoProvider } from "./sso";
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@@ -53,6 +54,14 @@ export const user = pgTable("user", {
|
|||||||
// Metrics
|
// Metrics
|
||||||
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
|
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
|
||||||
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
|
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
|
||||||
|
// Enterprise / proprietary features
|
||||||
|
enableEnterpriseFeatures: boolean("enableEnterpriseFeatures")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
licenseKey: text("licenseKey"),
|
||||||
|
isValidEnterpriseLicense: boolean("isValidEnterpriseLicense")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
stripeCustomerId: text("stripeCustomerId"),
|
stripeCustomerId: text("stripeCustomerId"),
|
||||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||||
@@ -66,6 +75,7 @@ export const usersRelations = relations(user, ({ one, many }) => ({
|
|||||||
organizations: many(organization),
|
organizations: many(organization),
|
||||||
projects: many(projects),
|
projects: many(projects),
|
||||||
apiKeys: many(apikey),
|
apiKeys: many(apikey),
|
||||||
|
ssoProviders: many(ssoProvider),
|
||||||
backups: many(backups),
|
backups: many(backups),
|
||||||
schedules: many(schedules),
|
schedules: many(schedules),
|
||||||
}));
|
}));
|
||||||
@@ -214,6 +224,6 @@ export const apiUpdateUser = createSchema.partial().extend({
|
|||||||
.optional(),
|
.optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
currentPassword: z.string().optional(),
|
currentPassword: z.string().optional(),
|
||||||
name: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
lastName: z.string().optional(),
|
lastName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ const alphabet = "abcdefghijklmnopqrstuvwxyz123456789";
|
|||||||
|
|
||||||
const customNanoid = customAlphabet(alphabet, 6);
|
const customNanoid = customAlphabet(alphabet, 6);
|
||||||
|
|
||||||
|
/** App name: letters, numbers, dots, underscores, hyphens only (no spaces). Safe for shell/Docker. */
|
||||||
|
export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||||
|
|
||||||
|
export const APP_NAME_MESSAGE =
|
||||||
|
"App name can only contain letters, numbers, dots, underscores and hyphens";
|
||||||
|
|
||||||
export const generateAppName = (type: string) => {
|
export const generateAppName = (type: string) => {
|
||||||
const verb = faker.hacker.verb().replace(/ /g, "-");
|
const verb = faker.hacker.verb().replace(/ /g, "-");
|
||||||
const adjective = faker.hacker.adjective().replace(/ /g, "-");
|
const adjective = faker.hacker.adjective().replace(/ /g, "-");
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
|
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
// Whitelabel (Enterprise)
|
||||||
|
whitelabelAppName: text("whitelabelAppName").default("Dokploy"),
|
||||||
|
whitelabelLogoUrl: text("whitelabelLogoUrl"),
|
||||||
|
whitelabelLoginLogoUrl: text("whitelabelLoginLogoUrl"),
|
||||||
|
whitelabelFaviconUrl: text("whitelabelFaviconUrl"),
|
||||||
|
whitelabelLoginTitle: text("whitelabelLoginTitle"),
|
||||||
|
whitelabelLoginSubtitle: text("whitelabelLoginSubtitle"),
|
||||||
|
whitelabelLoginBackgroundImageUrl: text("whitelabelLoginBackgroundImageUrl"),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -125,13 +133,28 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
|||||||
cleanupCacheApplications: z.boolean().optional(),
|
cleanupCacheApplications: z.boolean().optional(),
|
||||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||||
cleanupCacheOnCompose: z.boolean().optional(),
|
cleanupCacheOnCompose: z.boolean().optional(),
|
||||||
|
whitelabelAppName: z.string().optional().nullable(),
|
||||||
|
whitelabelLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||||
|
whitelabelLoginLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||||
|
whitelabelFaviconUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||||
|
whitelabelLoginTitle: z.string().optional().nullable(),
|
||||||
|
whitelabelLoginSubtitle: z.string().optional().nullable(),
|
||||||
|
whitelabelLoginBackgroundImageUrl: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiAssignDomain = z
|
export const apiAssignDomain = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string(),
|
host: z.string(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||||
letsEncryptEmail: z.string().email().optional().nullable(),
|
letsEncryptEmail: z
|
||||||
|
.union([z.string().email(), z.literal("")])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.required()
|
.required()
|
||||||
@@ -151,6 +174,21 @@ export const apiUpdateDockerCleanup = z.object({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiUpdateWhitelabel = z.object({
|
||||||
|
whitelabelAppName: z.string().min(1).max(100).optional().nullable(),
|
||||||
|
whitelabelLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||||
|
whitelabelLoginLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||||
|
whitelabelFaviconUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||||
|
whitelabelLoginTitle: z.string().max(200).optional().nullable(),
|
||||||
|
whitelabelLoginSubtitle: z.string().max(500).optional().nullable(),
|
||||||
|
whitelabelLoginBackgroundImageUrl: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiUpdateWebServerMonitoring = z.object({
|
export const apiUpdateWebServerMonitoring = z.object({
|
||||||
metricsConfig: z
|
metricsConfig: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export * from "./services/port";
|
|||||||
export * from "./services/postgres";
|
export * from "./services/postgres";
|
||||||
export * from "./services/preview-deployment";
|
export * from "./services/preview-deployment";
|
||||||
export * from "./services/project";
|
export * from "./services/project";
|
||||||
|
export * from "./services/proprietary/sso";
|
||||||
export * from "./services/redirect";
|
export * from "./services/redirect";
|
||||||
export * from "./services/redis";
|
export * from "./services/redis";
|
||||||
export * from "./services/registry";
|
export * from "./services/registry";
|
||||||
@@ -79,6 +80,7 @@ export * from "./utils/builders/paketo";
|
|||||||
export * from "./utils/builders/static";
|
export * from "./utils/builders/static";
|
||||||
export * from "./utils/builders/utils";
|
export * from "./utils/builders/utils";
|
||||||
export * from "./utils/cluster/upload";
|
export * from "./utils/cluster/upload";
|
||||||
|
export * from "./utils/crons/enterprise";
|
||||||
export * from "./utils/databases/rebuild";
|
export * from "./utils/databases/rebuild";
|
||||||
export * from "./utils/docker/collision";
|
export * from "./utils/docker/collision";
|
||||||
export * from "./utils/docker/compose";
|
export * from "./utils/docker/compose";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { sso } from "@better-auth/sso";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
@@ -9,6 +10,7 @@ import { IS_CLOUD } from "../constants";
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import * as schema from "../db/schema";
|
import * as schema from "../db/schema";
|
||||||
import { getUserByToken } from "../services/admin";
|
import { getUserByToken } from "../services/admin";
|
||||||
|
import { getSSOProviders } from "../services/proprietary/sso";
|
||||||
import {
|
import {
|
||||||
getWebServerSettings,
|
getWebServerSettings,
|
||||||
updateWebServerSettings,
|
updateWebServerSettings,
|
||||||
@@ -17,11 +19,17 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
|||||||
import { sendEmail } from "../verification/send-verification-email";
|
import { sendEmail } from "../verification/send-verification-email";
|
||||||
import { getPublicIpWithFallback } from "../wss/utils";
|
import { getPublicIpWithFallback } from "../wss/utils";
|
||||||
|
|
||||||
const { handler, api } = betterAuth({
|
export const { handler, api } = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: schema,
|
schema: schema,
|
||||||
}),
|
}),
|
||||||
|
disabledPaths: [
|
||||||
|
"/sso/register",
|
||||||
|
"/organization/create",
|
||||||
|
"/organization/update",
|
||||||
|
"/organization/delete",
|
||||||
|
],
|
||||||
appName: "Dokploy",
|
appName: "Dokploy",
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
@@ -42,13 +50,18 @@ const { handler, api } = betterAuth({
|
|||||||
if (!settings) {
|
if (!settings) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providers = await getSSOProviders();
|
||||||
|
const domains = providers.map((provider) => provider.issuer);
|
||||||
return [
|
return [
|
||||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||||
|
...domains.map((domain) => domain),
|
||||||
...(process.env.NODE_ENV === "development"
|
...(process.env.NODE_ENV === "development"
|
||||||
? [
|
? [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||||
|
"https://dev-pee8hhc3qbjlqedb.us.auth0.com",
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
@@ -106,6 +119,10 @@ const { handler, api } = betterAuth({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const isSSORequest = context?.path.includes("/sso/callback");
|
||||||
|
if (!isSSORequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
where: eq(schema.member.role, "owner"),
|
where: eq(schema.member.role, "owner"),
|
||||||
});
|
});
|
||||||
@@ -118,6 +135,7 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
after: async (user, context) => {
|
after: async (user, context) => {
|
||||||
|
const isSSORequest = context?.path.includes("/sso/callback");
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
where: eq(schema.member.role, "owner"),
|
where: eq(schema.member.role, "owner"),
|
||||||
});
|
});
|
||||||
@@ -173,6 +191,29 @@ const { handler, api } = betterAuth({
|
|||||||
isDefault: true, // Mark first organization as default
|
isDefault: true, // Mark first organization as default
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else if (isSSORequest) {
|
||||||
|
const providerId = context?.params?.providerId;
|
||||||
|
if (!providerId) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Provider ID is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const provider = await db.query.ssoProvider.findFirst({
|
||||||
|
where: eq(schema.ssoProvider.providerId, providerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Provider not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.insert(schema.member).values({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: provider?.organizationId || "",
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -240,6 +281,7 @@ const { handler, api } = betterAuth({
|
|||||||
apiKey({
|
apiKey({
|
||||||
enableMetadata: true,
|
enableMetadata: true,
|
||||||
}),
|
}),
|
||||||
|
sso(),
|
||||||
twoFactor(),
|
twoFactor(),
|
||||||
organization({
|
organization({
|
||||||
async sendInvitationEmail(data, _request) {
|
async sendInvitationEmail(data, _request) {
|
||||||
@@ -273,6 +315,7 @@ const { handler, api } = betterAuth({
|
|||||||
export const auth = {
|
export const auth = {
|
||||||
handler,
|
handler,
|
||||||
createApiKey: api.createApiKey,
|
createApiKey: api.createApiKey,
|
||||||
|
registerSSOProvider: api.registerSSOProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateRequest = async (request: IncomingMessage) => {
|
export const validateRequest = async (request: IncomingMessage) => {
|
||||||
|
|||||||
@@ -253,7 +253,11 @@ export const deployApplication = async ({
|
|||||||
} finally {
|
} finally {
|
||||||
// Only extract commit info for non-docker sources
|
// Only extract commit info for non-docker sources
|
||||||
if (application.sourceType !== "docker") {
|
if (application.sourceType !== "docker") {
|
||||||
const commitInfo = await getGitCommitInfo(application);
|
const commitInfo = await getGitCommitInfo({
|
||||||
|
appName: application.appName,
|
||||||
|
type: "application",
|
||||||
|
serverId: serverId,
|
||||||
|
});
|
||||||
|
|
||||||
if (commitInfo) {
|
if (commitInfo) {
|
||||||
await updateDeployment(deployment.deploymentId, {
|
await updateDeployment(deployment.deploymentId, {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type Mariadb = typeof mariadb.$inferSelect;
|
|||||||
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
|
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
|
||||||
const appName = buildAppName("mariadb", input.appName);
|
const appName = buildAppName("mariadb", input.appName);
|
||||||
|
|
||||||
const valid = await validUniqueServerAppName(input.appName);
|
const valid = await validUniqueServerAppName(appName);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type apiCreateGotify,
|
type apiCreateGotify,
|
||||||
type apiCreateLark,
|
type apiCreateLark,
|
||||||
type apiCreateNtfy,
|
type apiCreateNtfy,
|
||||||
|
type apiCreatePushover,
|
||||||
type apiCreateSlack,
|
type apiCreateSlack,
|
||||||
type apiCreateTelegram,
|
type apiCreateTelegram,
|
||||||
type apiUpdateCustom,
|
type apiUpdateCustom,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
type apiUpdateGotify,
|
type apiUpdateGotify,
|
||||||
type apiUpdateLark,
|
type apiUpdateLark,
|
||||||
type apiUpdateNtfy,
|
type apiUpdateNtfy,
|
||||||
|
type apiUpdatePushover,
|
||||||
type apiUpdateSlack,
|
type apiUpdateSlack,
|
||||||
type apiUpdateTelegram,
|
type apiUpdateTelegram,
|
||||||
custom,
|
custom,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
lark,
|
lark,
|
||||||
notifications,
|
notifications,
|
||||||
ntfy,
|
ntfy,
|
||||||
|
pushover,
|
||||||
slack,
|
slack,
|
||||||
telegram,
|
telegram,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
@@ -694,6 +697,7 @@ export const findNotificationById = async (notificationId: string) => {
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
@@ -817,3 +821,99 @@ export const updateNotificationById = async (
|
|||||||
|
|
||||||
return result[0];
|
return result[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPushoverNotification = async (
|
||||||
|
input: typeof apiCreatePushover._type,
|
||||||
|
organizationId: string,
|
||||||
|
) => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const newPushover = await tx
|
||||||
|
.insert(pushover)
|
||||||
|
.values({
|
||||||
|
userKey: input.userKey,
|
||||||
|
apiToken: input.apiToken,
|
||||||
|
priority: input.priority,
|
||||||
|
retry: input.retry,
|
||||||
|
expire: input.expire,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.then((value) => value[0]);
|
||||||
|
|
||||||
|
if (!newPushover) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error input: Inserting pushover",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDestination = await tx
|
||||||
|
.insert(notifications)
|
||||||
|
.values({
|
||||||
|
pushoverId: newPushover.pushoverId,
|
||||||
|
name: input.name,
|
||||||
|
appDeploy: input.appDeploy,
|
||||||
|
appBuildError: input.appBuildError,
|
||||||
|
databaseBackup: input.databaseBackup,
|
||||||
|
volumeBackup: input.volumeBackup,
|
||||||
|
dokployRestart: input.dokployRestart,
|
||||||
|
dockerCleanup: input.dockerCleanup,
|
||||||
|
serverThreshold: input.serverThreshold,
|
||||||
|
notificationType: "pushover",
|
||||||
|
organizationId: organizationId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.then((value) => value[0]);
|
||||||
|
|
||||||
|
if (!newDestination) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error input: Inserting notification",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newDestination;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePushoverNotification = async (
|
||||||
|
input: typeof apiUpdatePushover._type,
|
||||||
|
) => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const newDestination = await tx
|
||||||
|
.update(notifications)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
appDeploy: input.appDeploy,
|
||||||
|
appBuildError: input.appBuildError,
|
||||||
|
databaseBackup: input.databaseBackup,
|
||||||
|
volumeBackup: input.volumeBackup,
|
||||||
|
dokployRestart: input.dokployRestart,
|
||||||
|
dockerCleanup: input.dockerCleanup,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
serverThreshold: input.serverThreshold,
|
||||||
|
})
|
||||||
|
.where(eq(notifications.notificationId, input.notificationId))
|
||||||
|
.returning()
|
||||||
|
.then((value) => value[0]);
|
||||||
|
|
||||||
|
if (!newDestination) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error Updating notification",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(pushover)
|
||||||
|
.set({
|
||||||
|
userKey: input.userKey,
|
||||||
|
apiToken: input.apiToken,
|
||||||
|
priority: input.priority,
|
||||||
|
retry: input.retry,
|
||||||
|
expire: input.expire,
|
||||||
|
})
|
||||||
|
.where(eq(pushover.pushoverId, input.pushoverId));
|
||||||
|
|
||||||
|
return newDestination;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
15
packages/server/src/services/proprietary/sso.tsx
Normal file
15
packages/server/src/services/proprietary/sso.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
|
||||||
|
export const getSSOProviders = async () => {
|
||||||
|
const providers = await db.query.ssoProvider.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
providerId: true,
|
||||||
|
issuer: true,
|
||||||
|
domain: true,
|
||||||
|
oidcConfig: true,
|
||||||
|
samlConfig: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return providers;
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ export const TRAEFIK_PORT =
|
|||||||
Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80;
|
Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80;
|
||||||
export const TRAEFIK_HTTP3_PORT =
|
export const TRAEFIK_HTTP3_PORT =
|
||||||
Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443;
|
Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443;
|
||||||
export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.1";
|
export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.4";
|
||||||
|
|
||||||
export interface TraefikOptions {
|
export interface TraefikOptions {
|
||||||
env?: string[];
|
env?: string[];
|
||||||
|
|||||||
66
packages/server/src/utils/crons/enterprise.ts
Normal file
66
packages/server/src/utils/crons/enterprise.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
|
||||||
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
|
import { scheduleJob } from "node-schedule";
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { user as userSchema } from "../../db/schema/user";
|
||||||
|
|
||||||
|
export const initEnterpriseBackupCronJobs = async () => {
|
||||||
|
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||||
|
const users = await db.query.user.findMany({
|
||||||
|
where: and(
|
||||||
|
isNotNull(userSchema.licenseKey),
|
||||||
|
isNotNull(userSchema.enableEnterpriseFeatures),
|
||||||
|
eq(userSchema.isValidEnterpriseLicense, true),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
for (const user of users) {
|
||||||
|
if (user.isValidEnterpriseLicense) {
|
||||||
|
console.log(
|
||||||
|
"Validating license key....",
|
||||||
|
user.firstName,
|
||||||
|
user.lastName,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const isValid = await validateLicenseKey(user.licenseKey || "");
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("License key is invalid");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await db
|
||||||
|
.update(userSchema)
|
||||||
|
.set({ isValidEnterpriseLicense: false })
|
||||||
|
.where(eq(userSchema.id, user.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateLicenseKey = async (licenseKey: string) => {
|
||||||
|
try {
|
||||||
|
const ip = await getPublicIpWithFallback();
|
||||||
|
const result = await fetch(
|
||||||
|
`${process.env.LICENSE_KEY_URL || "http://localhost:4002"}/licenses/validate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, ip }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const errorData = await result.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || "Failed to validate license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await result.json();
|
||||||
|
return data.valid;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to validate license key",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -48,12 +49,22 @@ export const sendBuildErrorNotifications = async ({
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
|
const {
|
||||||
notification;
|
email,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
slack,
|
||||||
|
gotify,
|
||||||
|
ntfy,
|
||||||
|
custom,
|
||||||
|
lark,
|
||||||
|
pushover,
|
||||||
|
} = notification;
|
||||||
try {
|
try {
|
||||||
if (email) {
|
if (email) {
|
||||||
const template = await renderAsync(
|
const template = await renderAsync(
|
||||||
@@ -349,6 +360,14 @@ export const sendBuildErrorNotifications = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
"Build Failed",
|
||||||
|
`Project: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}\nError: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -51,12 +52,22 @@ export const sendBuildSuccessNotifications = async ({
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
|
const {
|
||||||
notification;
|
email,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
slack,
|
||||||
|
gotify,
|
||||||
|
ntfy,
|
||||||
|
custom,
|
||||||
|
lark,
|
||||||
|
pushover,
|
||||||
|
} = notification;
|
||||||
try {
|
try {
|
||||||
if (email) {
|
if (email) {
|
||||||
const template = await renderAsync(
|
const template = await renderAsync(
|
||||||
@@ -363,6 +374,14 @@ export const sendBuildSuccessNotifications = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
"Build Success",
|
||||||
|
`Project: ${projectName}\nApplication: ${applicationName}\nEnvironment: ${environmentName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -48,12 +49,22 @@ export const sendDatabaseBackupNotifications = async ({
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
|
const {
|
||||||
notification;
|
email,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
slack,
|
||||||
|
gotify,
|
||||||
|
ntfy,
|
||||||
|
custom,
|
||||||
|
lark,
|
||||||
|
pushover,
|
||||||
|
} = notification;
|
||||||
try {
|
try {
|
||||||
if (email) {
|
if (email) {
|
||||||
const template = await renderAsync(
|
const template = await renderAsync(
|
||||||
@@ -377,6 +388,14 @@ export const sendDatabaseBackupNotifications = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
`Database Backup ${type === "success" ? "Successful" : "Failed"}`,
|
||||||
|
`Project: ${projectName}\nApplication: ${applicationName}\nDatabase: ${databaseType}\nDatabase Name: ${databaseName}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -35,12 +36,22 @@ export const sendDockerCleanupNotifications = async (
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
|
const {
|
||||||
notification;
|
email,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
slack,
|
||||||
|
gotify,
|
||||||
|
ntfy,
|
||||||
|
custom,
|
||||||
|
lark,
|
||||||
|
pushover,
|
||||||
|
} = notification;
|
||||||
try {
|
try {
|
||||||
if (email) {
|
if (email) {
|
||||||
const template = await renderAsync(
|
const template = await renderAsync(
|
||||||
@@ -230,6 +241,14 @@ export const sendDockerCleanupNotifications = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
"Docker Cleanup",
|
||||||
|
`Date: ${date.toLocaleString()}\nMessage: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -29,12 +30,22 @@ export const sendDokployRestartNotifications = async () => {
|
|||||||
ntfy: true,
|
ntfy: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
|
const {
|
||||||
notification;
|
email,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
slack,
|
||||||
|
gotify,
|
||||||
|
ntfy,
|
||||||
|
custom,
|
||||||
|
lark,
|
||||||
|
pushover,
|
||||||
|
} = notification;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (email) {
|
if (email) {
|
||||||
@@ -219,6 +230,14 @@ export const sendDokployRestartNotifications = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
"Dokploy Server Restarted",
|
||||||
|
`Date: ${date.toLocaleString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
sendCustomNotification,
|
sendCustomNotification,
|
||||||
sendDiscordNotification,
|
sendDiscordNotification,
|
||||||
sendLarkNotification,
|
sendLarkNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -38,6 +39,7 @@ export const sendServerThresholdNotifications = async (
|
|||||||
slack: true,
|
slack: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export const sendServerThresholdNotifications = async (
|
|||||||
const typeColor = 0xff0000; // Rojo para indicar alerta
|
const typeColor = 0xff0000; // Rojo para indicar alerta
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { discord, telegram, slack, custom, lark } = notification;
|
const { discord, telegram, slack, custom, lark, pushover } = notification;
|
||||||
|
|
||||||
if (discord) {
|
if (discord) {
|
||||||
const decorate = (decoration: string, text: string) =>
|
const decorate = (decoration: string, text: string) =>
|
||||||
@@ -266,5 +268,13 @@ export const sendServerThresholdNotifications = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
`Server ${payload.Type} Alert`,
|
||||||
|
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
gotify,
|
gotify,
|
||||||
lark,
|
lark,
|
||||||
ntfy,
|
ntfy,
|
||||||
|
pushover,
|
||||||
slack,
|
slack,
|
||||||
telegram,
|
telegram,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
@@ -223,3 +224,33 @@ export const sendLarkNotification = async (
|
|||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendPushoverNotification = async (
|
||||||
|
connection: typeof pushover.$inferInsert,
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
) => {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append("token", connection.apiToken);
|
||||||
|
formData.append("user", connection.userKey);
|
||||||
|
formData.append("title", title);
|
||||||
|
formData.append("message", message);
|
||||||
|
formData.append("priority", connection.priority?.toString() || "0");
|
||||||
|
|
||||||
|
// For emergency priority (2), retry and expire are required
|
||||||
|
if (connection.priority === 2) {
|
||||||
|
formData.append("retry", connection.retry?.toString() || "30");
|
||||||
|
formData.append("expire", connection.expire?.toString() || "3600");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("https://api.pushover.net/1/messages.json", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to send Pushover notification: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
|
sendPushoverNotification,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -53,11 +54,13 @@ export const sendVolumeBackupNotifications = async ({
|
|||||||
slack: true,
|
slack: true,
|
||||||
gotify: true,
|
gotify: true,
|
||||||
ntfy: true,
|
ntfy: true,
|
||||||
|
pushover: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const notification of notificationList) {
|
for (const notification of notificationList) {
|
||||||
const { email, discord, telegram, slack, gotify, ntfy } = notification;
|
const { email, discord, telegram, slack, gotify, ntfy, pushover } =
|
||||||
|
notification;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
|
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
|
||||||
@@ -270,5 +273,13 @@ export const sendVolumeBackupNotifications = async ({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pushover) {
|
||||||
|
await sendPushoverNotification(
|
||||||
|
pushover,
|
||||||
|
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
|
||||||
|
`Project: ${projectName}\nApplication: ${applicationName}\nVolume: ${volumeName}\nService Type: ${serviceType}${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const getBitbucketHeaders = (bitbucketProvider: Bitbucket) => {
|
|||||||
interface CloneBitbucketRepository {
|
interface CloneBitbucketRepository {
|
||||||
appName: string;
|
appName: string;
|
||||||
bitbucketRepository: string | null;
|
bitbucketRepository: string | null;
|
||||||
|
bitbucketRepositorySlug?: string | null;
|
||||||
bitbucketOwner: string | null;
|
bitbucketOwner: string | null;
|
||||||
bitbucketBranch: string | null;
|
bitbucketBranch: string | null;
|
||||||
bitbucketId: string | null;
|
bitbucketId: string | null;
|
||||||
@@ -117,7 +118,8 @@ export const cloneBitbucketRepository = async ({
|
|||||||
const outputPath = join(basePath, appName, "code");
|
const outputPath = join(basePath, appName, "code");
|
||||||
command += `rm -rf ${outputPath};`;
|
command += `rm -rf ${outputPath};`;
|
||||||
command += `mkdir -p ${outputPath};`;
|
command += `mkdir -p ${outputPath};`;
|
||||||
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
|
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
|
||||||
|
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
|
||||||
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
|
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
|
||||||
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
|
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
|
||||||
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
|
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
|
||||||
@@ -137,6 +139,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
|
|||||||
let repositories: {
|
let repositories: {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
slug: string;
|
||||||
owner: { username: string };
|
owner: { username: string };
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
|
|||||||
const mappedData = data.values.map((repo: any) => ({
|
const mappedData = data.values.map((repo: any) => ({
|
||||||
name: repo.name,
|
name: repo.name,
|
||||||
url: repo.links.html.href,
|
url: repo.links.html.href,
|
||||||
|
slug: repo.slug,
|
||||||
owner: {
|
owner: {
|
||||||
username: repo.workspace.slug,
|
username: repo.workspace.slug,
|
||||||
},
|
},
|
||||||
|
|||||||
1879
pnpm-lock.yaml
generated
1879
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user