mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dcd0e1859 | |||
| 85786a3196 | |||
| ea3f9f3b53 | |||
| e99a98e4fe | |||
| 5611662351 | |||
| 887a457f15 | |||
| df4e299545 | |||
| aaff71bbf6 | |||
| be128d5a4a | |||
| 467c80a73d | |||
| 2d5a6ef679 | |||
| 8291165259 | |||
| e701644fa5 | |||
| 94cd5035ef | |||
| ea1c6fc3fe | |||
| df0986b4e1 | |||
| caca3c19ae | |||
| 91db4e60c4 | |||
| 1e6ab0fab7 | |||
| 67f6890fc8 | |||
| 988f5b64d6 | |||
| e369541a16 | |||
| 7c1889205c | |||
| 0f28775a1e | |||
| 6ed5746983 | |||
| e8f94a94da | |||
| 150c34dc0a | |||
| 91eaab6a43 | |||
| f0f7843ece | |||
| 6e760da93b | |||
| 0c19973fbf | |||
| 600657ec62 | |||
| 383438e4fc | |||
| 57a5465d89 | |||
| ab87f2daad | |||
| 3b065db8a0 | |||
| 8409b8ce60 | |||
| 57d92b75de | |||
| 7e9fdbb5ff | |||
| 2311f346d4 | |||
| e9623119bd | |||
| d7884c7ce9 | |||
| 28f71e727a | |||
| 29e5940e8d | |||
| 41251b4f1e | |||
| 15bf663aad | |||
| 13b043edd9 | |||
| 48dfb2b820 | |||
| 62e948a804 | |||
| b6c83e082e | |||
| b5c3d8660e | |||
| 5c0d3b84af | |||
| 7effae35cb | |||
| 7fb02abb3e | |||
| 631816f8d1 | |||
| 1a29694481 | |||
| 1572008cdf | |||
| 5f10ed6884 | |||
| 399e20c1ed | |||
| 5faa363f7a | |||
| 5cbc7efc61 | |||
| 12ea7a8920 | |||
| 782bb4e249 | |||
| f4e1154d31 | |||
| bf547e2fd0 | |||
| 9568a4f9f8 | |||
| b839e6d6be | |||
| 7ae566f4de | |||
| ea9a466c01 | |||
| d1fda6368c | |||
| d389b3c8d0 | |||
| 0889d763ef | |||
| bbca400a6d | |||
| 03cdd00fa8 | |||
| ad00669f0f | |||
| f58ff1062e | |||
| c180f7502c | |||
| 4877c4ffa3 | |||
| dfa9c17d7d | |||
| e79ed7691f | |||
| e25e241cf5 | |||
| f617561d0b | |||
| f904a7bd55 | |||
| 261ebb2317 | |||
| c901260d81 | |||
| 8c9e473b4e | |||
| e2c9bd2833 | |||
| 8b38acdebd | |||
| 6dacab06cd | |||
| b2ed974422 | |||
| 62e43b164b | |||
| 4746cfc1ca | |||
| 18e617eb14 | |||
| f67fab0675 | |||
| 344febb4c1 | |||
| fff5612c2e | |||
| c5f12927b5 | |||
| 56eaa93b91 | |||
| e67d9efb38 | |||
| c32c8d01a9 | |||
| 1d26fdd027 | |||
| 0dcfcc19da | |||
| 2e2e0c8c29 | |||
| a424f64370 | |||
| 3893150f5c | |||
| 8806d4ef80 | |||
| 6d51c100ae | |||
| 13bb1ac5d1 | |||
| 5606a2bd1e | |||
| 58f3714ddc | |||
| 6282b6ef39 | |||
| f83097b80f | |||
| 59a74849a8 | |||
| 27dab3f4bf | |||
| d56ef890ae | |||
| b75785dc6a | |||
| 80a6baf58e | |||
| be4a5da405 | |||
| 2bc22c520d | |||
| e629248dea | |||
| ff3ebefc70 | |||
| c0f07f1427 | |||
| da3c0b1326 | |||
| f1e2467bb9 | |||
| 5344945f83 | |||
| f23b5b9532 | |||
| 79baa24763 | |||
| 6dc22bdeed | |||
| ac2e081134 | |||
| 26d716b34f | |||
| 2a7668662f | |||
| 49bf39dc18 | |||
| 0393c2635b | |||
| c668f6c7bd | |||
| 36ba9fcb3e |
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
|
|||||||
until it answers (usually ~10-15s).
|
until it answers (usually ~10-15s).
|
||||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||||
|
|
||||||
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
Note: `mcp__dokploy__*` resolves its URL from
|
||||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||||
|
|||||||
+2
-2
@@ -123,13 +123,13 @@ pnpm run docker:push
|
|||||||
In the case you lost your password, you can reset the owner's password using the following command
|
In the case you lost your password, you can reset the owner's password using the following command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run reset-password
|
pnpm --filter=dokploy run reset-password
|
||||||
```
|
```
|
||||||
|
|
||||||
To reset the password of a specific user instead, pass their email as an argument
|
To reset the password of a specific user instead, pass their email as an argument
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run reset-password -- user@example.com
|
pnpm --filter=dokploy run reset-password user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands print the new randomly generated password to the console.
|
Both commands print the new randomly generated password to the console.
|
||||||
|
|||||||
@@ -6,3 +6,13 @@ npm run dev
|
|||||||
```
|
```
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Inngest
|
||||||
|
|
||||||
|
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev:inngest
|
||||||
|
```
|
||||||
|
|
||||||
|
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||||
|
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
|
||||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -54,7 +54,16 @@ const inputEncoding: Record<string, string> = {
|
|||||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("getCreateEnvFileCommand", () => {
|
const hasDocker = () => {
|
||||||
|
try {
|
||||||
|
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||||
mkdirSync(codePath, { recursive: true });
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => {
|
|||||||
expect(createInit.method).toBe("POST");
|
expect(createInit.method).toBe("POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the existing record instead of creating a duplicate", async () => {
|
it("updates the existing record when content matches", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
.mockResolvedValueOnce(
|
||||||
|
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
||||||
|
)
|
||||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||||
|
|
||||||
const result = await cloudflareClient.upsertRecord(config, {
|
const result = await cloudflareClient.upsertRecord(config, {
|
||||||
@@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => {
|
|||||||
expect(updateInit.method).toBe("PUT");
|
expect(updateInit.method).toBe("PUT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
||||||
|
|
||||||
|
const result = await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "new-2" });
|
||||||
|
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(createInit.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(cfSuccess([]))
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as typeof fetch;
|
||||||
|
|
||||||
|
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
||||||
|
|
||||||
|
const ikPage = (data: unknown, page: number, pages: number) =>
|
||||||
|
jsonResponse({ result: "success", data, page, pages });
|
||||||
|
|
||||||
|
const ikError = (description: string, status = 400) =>
|
||||||
|
jsonResponse(
|
||||||
|
{ result: "error", error: { code: "not_authorized", description } },
|
||||||
|
false,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "infomaniak" as const,
|
||||||
|
apiToken: "ik_test_token",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastCall = () =>
|
||||||
|
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
||||||
|
|
||||||
|
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.listZones", () => {
|
||||||
|
it("exposes each domain product as a zone keyed by its name", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikPage(
|
||||||
|
[
|
||||||
|
{ id: 1, customer_name: "example.com" },
|
||||||
|
{ id: 2, customer_name: "example.ch" },
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const zones = await infomaniakClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones).toEqual([
|
||||||
|
{ id: "example.com", name: "example.com" },
|
||||||
|
{ id: "example.ch", name: "example.ch" },
|
||||||
|
]);
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
// The documented endpoint is the plural one; the singular is legacy and
|
||||||
|
// returns no pagination metadata at all.
|
||||||
|
expect(url).toContain("/1/products?service_name=domain");
|
||||||
|
expect(url).toContain("page=1");
|
||||||
|
expect(init.headers).toMatchObject({
|
||||||
|
Authorization: "Bearer ik_test_token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks every page so accounts with many domains keep all their zones", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
||||||
|
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
||||||
|
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
||||||
|
|
||||||
|
const zones = await infomaniakClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after a single page when the response has no pagination", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
||||||
|
|
||||||
|
const zones = await infomaniakClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates the API error description", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||||
|
|
||||||
|
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
||||||
|
"Authorization required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.listRecords", () => {
|
||||||
|
it("rebuilds the fqdn from the relative source", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||||
|
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records).toEqual([
|
||||||
|
{
|
||||||
|
id: "10",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "11",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
ttl: 600,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(lastCall()[0]).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records[0]?.name).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unquotes TXT targets", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
type: "TXT",
|
||||||
|
source: "_acme-challenge",
|
||||||
|
target: '"token-value"',
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records[0]?.content).toBe("token-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a CAA target untouched", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
type: "CAA",
|
||||||
|
source: "",
|
||||||
|
target: '0 issue "letsencrypt.org"',
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.upsertRecord", () => {
|
||||||
|
it("creates the record when no matching source and type exists", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "42" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(lastBody()).toEqual({
|
||||||
|
type: "A",
|
||||||
|
source: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the existing record when content matches", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "7" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(lastBody().ttl).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "50" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com.",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().source).toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([".", "", "@"])(
|
||||||
|
"matches an existing apex record stored with a %s source",
|
||||||
|
async (source) => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "8" });
|
||||||
|
expect(lastCall()[1].method).toBe("PUT");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("matches the existing apex record instead of creating a duplicate", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
type: "TXT",
|
||||||
|
source: ".",
|
||||||
|
target: '"v=spf1 -all"',
|
||||||
|
ttl: 3600,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "TXT",
|
||||||
|
name: "example.com",
|
||||||
|
content: "v=spf1 -all",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "8" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
||||||
|
);
|
||||||
|
expect(lastBody().target).toBe('"v=spf1 -all"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes a TXT target on write", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "TXT",
|
||||||
|
name: "_acme-challenge.example.com",
|
||||||
|
content: "token-value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().target).toBe('"token-value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-quote a TXT target that is already quoted", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "TXT",
|
||||||
|
name: "_acme-challenge.example.com",
|
||||||
|
content: '"token-value"',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().target).toBe('"token-value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0] as [string];
|
||||||
|
expect(url).toContain("filter%5Bsource%5D=app");
|
||||||
|
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
||||||
|
// filter[source] matches substrings: asking for "auto" also returns
|
||||||
|
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 61,
|
||||||
|
type: "CNAME",
|
||||||
|
source: "autoconfig",
|
||||||
|
target: "a.example.net",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 62,
|
||||||
|
type: "CNAME",
|
||||||
|
source: "autodiscover",
|
||||||
|
target: "b.example.net",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "CNAME",
|
||||||
|
name: "auto.example.com",
|
||||||
|
content: "c.example.net",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "63" });
|
||||||
|
expect(lastCall()[1].method).toBe("POST");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.updateRecord", () => {
|
||||||
|
it("updates the record and keeps its id", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.updateRecord(
|
||||||
|
config,
|
||||||
|
"example.com",
|
||||||
|
"7",
|
||||||
|
{
|
||||||
|
type: "CNAME",
|
||||||
|
name: "www.example.com",
|
||||||
|
content: "example.com",
|
||||||
|
ttl: 900,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "7" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(lastBody()).toEqual({
|
||||||
|
type: "CNAME",
|
||||||
|
source: "www",
|
||||||
|
target: "example.com",
|
||||||
|
ttl: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default ttl when none is provided", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||||
|
|
||||||
|
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().ttl).toBe(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.deleteRecord", () => {
|
||||||
|
it("deletes the record", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess(null));
|
||||||
|
|
||||||
|
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
||||||
|
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a delete failure", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
||||||
|
).rejects.toThrow("Record not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.testConnection", () => {
|
||||||
|
it("resolves when the domain listing succeeds", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess([]));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infomaniakClient.testConnection(config),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on an invalid token", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||||
|
|
||||||
|
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
||||||
|
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as typeof fetch;
|
||||||
|
|
||||||
|
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
|
||||||
|
|
||||||
|
const textResponse = (body: string, ok = true, status = 200) =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
text: async () => body,
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
const ovhSuccess = (data: unknown) =>
|
||||||
|
textResponse(data === undefined ? "" : JSON.stringify(data));
|
||||||
|
|
||||||
|
const ovhError = (message: string, status = 403) =>
|
||||||
|
textResponse(JSON.stringify({ message }), false, status);
|
||||||
|
|
||||||
|
const SERVER_TIME = 1788268225;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "ovh" as const,
|
||||||
|
endpoint: "ovh-eu" as const,
|
||||||
|
applicationKey: "app-key",
|
||||||
|
applicationSecret: "app-secret",
|
||||||
|
consumerKey: "consumer-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
|
||||||
|
// is keyed by base url, never leaks a measurement between them.
|
||||||
|
let endpointCursor = 0;
|
||||||
|
const endpoints = [
|
||||||
|
"ovh-eu",
|
||||||
|
"ovh-ca",
|
||||||
|
"ovh-us",
|
||||||
|
"kimsufi-eu",
|
||||||
|
"kimsufi-ca",
|
||||||
|
"soyoustart-eu",
|
||||||
|
"soyoustart-ca",
|
||||||
|
] as const;
|
||||||
|
const baseUrls: Record<(typeof endpoints)[number], string> = {
|
||||||
|
"ovh-eu": "https://eu.api.ovh.com/1.0",
|
||||||
|
"ovh-ca": "https://ca.api.ovh.com/1.0",
|
||||||
|
"ovh-us": "https://api.us.ovhcloud.com/1.0",
|
||||||
|
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
|
||||||
|
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
|
||||||
|
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
|
||||||
|
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
|
||||||
|
const freshConfig = () => {
|
||||||
|
const endpoint = endpoints[
|
||||||
|
endpointCursor % endpoints.length
|
||||||
|
] as (typeof endpoints)[number];
|
||||||
|
endpointCursor += 1;
|
||||||
|
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replies to /auth/time, then to each queued API response in order. */
|
||||||
|
const mockApi = (...responses: Response[]) => {
|
||||||
|
let call = 0;
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/auth/time")) {
|
||||||
|
return Promise.resolve(textResponse(String(SERVER_TIME)));
|
||||||
|
}
|
||||||
|
const response = responses[call];
|
||||||
|
call += 1;
|
||||||
|
return Promise.resolve(response ?? ovhSuccess(null));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiCalls = () =>
|
||||||
|
mockFetch.mock.calls.filter(
|
||||||
|
([url]) => !(url as string).endsWith("/auth/time"),
|
||||||
|
) as [string, RequestInit][];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient request signing", () => {
|
||||||
|
it("signs the request with the API server clock, not the local one", async () => {
|
||||||
|
const { baseUrl, ...cfg } = freshConfig();
|
||||||
|
mockApi(ovhSuccess(["example.com"]));
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
|
||||||
|
|
||||||
|
await ovhClient.listZones(cfg);
|
||||||
|
|
||||||
|
const [url, init] = apiCalls()[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(url).toBe(`${baseUrl}/domain/zone`);
|
||||||
|
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
|
||||||
|
expect(headers["X-Ovh-Application"]).toBe("app-key");
|
||||||
|
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
|
||||||
|
|
||||||
|
const expected = createHash("sha1")
|
||||||
|
.update(
|
||||||
|
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs a request body when one is sent", async () => {
|
||||||
|
const { baseUrl, ...cfg } = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
|
||||||
|
|
||||||
|
await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = apiCalls()[1] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
const body = init.body as string;
|
||||||
|
expect(body).not.toBe("");
|
||||||
|
const expected = createHash("sha1")
|
||||||
|
.update(
|
||||||
|
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
|
||||||
|
"+",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.listZones", () => {
|
||||||
|
it("maps each zone name to a zone", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess(["example.com", "example.fr"]));
|
||||||
|
|
||||||
|
const zones = await ovhClient.listZones(cfg);
|
||||||
|
|
||||||
|
expect(zones).toEqual([
|
||||||
|
{ id: "example.com", name: "example.com" },
|
||||||
|
{ id: "example.fr", name: "example.fr" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the missing root right when OVH refuses the zone listing", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("This call has not been granted", 403));
|
||||||
|
|
||||||
|
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
|
||||||
|
// so the raw OVH message would send users looking in the wrong place.
|
||||||
|
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
|
||||||
|
/missing the `GET \/domain\/zone` right/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates the API error message", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("Invalid signature", 403));
|
||||||
|
|
||||||
|
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.listRecords", () => {
|
||||||
|
it("resolves each id into a full record", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([1, 2]),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 1,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
}),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 2,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: null,
|
||||||
|
target: "5.6.7.8",
|
||||||
|
ttl: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||||
|
|
||||||
|
expect(records).toEqual([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
ttl: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the records in the order of the returned ids", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
const record = (id: number, subDomain: string) => ({
|
||||||
|
id,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain,
|
||||||
|
target: `10.0.0.${id}`,
|
||||||
|
ttl: 60,
|
||||||
|
});
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([1, 2, 3, 4, 5]),
|
||||||
|
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||||
|
|
||||||
|
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.upsertRecord", () => {
|
||||||
|
it("creates the record then refreshes the zone", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||||
|
|
||||||
|
const result = await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "9" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[0]?.[0]).toContain(
|
||||||
|
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
|
||||||
|
);
|
||||||
|
expect(calls[1]?.[1].method).toBe("POST");
|
||||||
|
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the existing record when content matches", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([4]),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "4" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||||
|
expect(calls[2]?.[1].method).toBe("PUT");
|
||||||
|
expect(calls[3]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([4]),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess({ id: 10 }),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "10" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
expect(calls[3]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the ttl so OVH applies the zone default", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||||
|
|
||||||
|
await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
|
||||||
|
"ttl",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||||
|
|
||||||
|
await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com.",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
|
||||||
|
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.updateRecord", () => {
|
||||||
|
it("updates in place when the type is unchanged", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "4" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[1]?.[1].method).toBe("PUT");
|
||||||
|
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
expect(calls[2]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess({ id: 11 }),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "11" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
|
||||||
|
expect(calls[3]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the original record when the replacement fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
const original = {
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
};
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess(original),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhError("Invalid target", 400),
|
||||||
|
ovhSuccess({ id: 12 }),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "not a valid target",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Invalid target");
|
||||||
|
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
// The original record is put back with its own type, target and ttl.
|
||||||
|
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
});
|
||||||
|
expect(calls[4]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the lost record when the restore also fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhError("Invalid target", 400),
|
||||||
|
ovhError("Service unavailable", 503),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "not a valid target",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the change was applied when only the zone refresh fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess({ id: 11 }),
|
||||||
|
ovhError("Service unavailable", 503),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The replacement succeeded, so the record exists at the provider — only
|
||||||
|
// publishing failed. Rolling back would destroy correct state.
|
||||||
|
await expect(
|
||||||
|
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "example.com",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not tell the user to recreate a record that was restored but not published", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null), // DELETE de l'ancien
|
||||||
|
ovhError("Invalid target", 400), // POST de remplacement -> échec
|
||||||
|
ovhSuccess({ id: 12 }), // POST de restauration -> succès
|
||||||
|
ovhError("Service unavailable", 503), // refresh -> échec
|
||||||
|
);
|
||||||
|
|
||||||
|
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "not a valid target",
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
|
||||||
|
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
|
||||||
|
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.deleteRecord", () => {
|
||||||
|
it("deletes the record then refreshes the zone", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||||
|
|
||||||
|
await ovhClient.deleteRecord(cfg, "example.com", "4");
|
||||||
|
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||||
|
expect(calls[0]?.[1].method).toBe("DELETE");
|
||||||
|
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh the zone when the delete fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("This object does not exist", 404));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ovhClient.deleteRecord(cfg, "example.com", "4"),
|
||||||
|
).rejects.toThrow("This object does not exist");
|
||||||
|
expect(apiCalls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.testConnection", () => {
|
||||||
|
it("resolves when the zone listing succeeds", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]));
|
||||||
|
|
||||||
|
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on invalid credentials", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("Invalid signature", 403));
|
||||||
|
|
||||||
|
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
|
||||||
|
"Invalid signature",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => {
|
|||||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edits the existing record instead of creating a duplicate", async () => {
|
it("edits the existing record when content matches", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
|
.mockResolvedValueOnce(
|
||||||
|
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
||||||
|
)
|
||||||
.mockResolvedValueOnce(pbSuccess({}));
|
.mockResolvedValueOnce(pbSuccess({}));
|
||||||
|
|
||||||
const result = await porkbunClient.upsertRecord(config, {
|
const result = await porkbunClient.upsertRecord(config, {
|
||||||
@@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => {
|
|||||||
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||||
|
|
||||||
|
const result = await porkbunClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "new-2" });
|
||||||
|
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(createUrl).toContain("/dns/create/example.com");
|
||||||
|
const body = JSON.parse(createInit.body as string);
|
||||||
|
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults ttl to 600 when not provided", async () => {
|
it("defaults ttl to 600 when not provided", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import {
|
||||||
|
getServerIpCandidates,
|
||||||
|
validateDomain,
|
||||||
|
} from "@dokploy/server/services/domain";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execAsyncRemote: vi.fn(),
|
||||||
|
findServerById: vi.fn(),
|
||||||
|
getPublicIpWithFallback: vi.fn(),
|
||||||
|
getWebServerSettings: vi.fn(),
|
||||||
|
resolve4: vi.fn(),
|
||||||
|
resolve6: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:dns", () => ({
|
||||||
|
default: {
|
||||||
|
resolve4: mocks.resolve4,
|
||||||
|
resolve6: mocks.resolve6,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||||
|
execAsyncRemote: mocks.execAsyncRemote,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/server", () => ({
|
||||||
|
findServerById: mocks.findServerById,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||||
|
getWebServerSettings: mocks.getWebServerSettings,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||||
|
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getServerIpCandidates", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes every address reported by a multi-homed remote server", async () => {
|
||||||
|
mocks.findServerById.mockResolvedValue({
|
||||||
|
ipAddress: "10.0.0.10",
|
||||||
|
});
|
||||||
|
mocks.execAsyncRemote.mockResolvedValue({
|
||||||
|
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
|
||||||
|
"10.0.0.10",
|
||||||
|
"192.0.2.10",
|
||||||
|
"2001:db8::10",
|
||||||
|
]);
|
||||||
|
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||||
|
"server-id",
|
||||||
|
expect.stringContaining("ip -o addr show scope global"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes every address assigned to the local Dokploy host", async () => {
|
||||||
|
mocks.getWebServerSettings.mockResolvedValue({
|
||||||
|
serverIp: "10.0.0.10",
|
||||||
|
});
|
||||||
|
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
|
||||||
|
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||||
|
eth0: [
|
||||||
|
{
|
||||||
|
address: "192.0.2.10",
|
||||||
|
netmask: "255.255.255.0",
|
||||||
|
family: "IPv4",
|
||||||
|
mac: "00:00:00:00:00:00",
|
||||||
|
internal: false,
|
||||||
|
cidr: "192.0.2.10/24",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getServerIpCandidates()).resolves.toEqual([
|
||||||
|
"10.0.0.10",
|
||||||
|
"192.0.2.10",
|
||||||
|
"2001:db8::10",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains remote interface addresses when public IP detection times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mocks.findServerById.mockResolvedValue({
|
||||||
|
ipAddress: "10.0.0.10",
|
||||||
|
});
|
||||||
|
mocks.execAsyncRemote.mockImplementation(
|
||||||
|
(_serverId: string, command: string) => {
|
||||||
|
if (command.includes("curl")) {
|
||||||
|
return new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
stdout: "192.0.2.10\n",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidatesPromise = getServerIpCandidates("server-id");
|
||||||
|
await vi.advanceTimersByTimeAsync(7000);
|
||||||
|
|
||||||
|
await expect(candidatesPromise).resolves.toEqual([
|
||||||
|
"10.0.0.10",
|
||||||
|
"192.0.2.10",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateDomain", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates an IPv6-only domain against an IPv6 server address", async () => {
|
||||||
|
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
|
||||||
|
code: "ENODATA",
|
||||||
|
});
|
||||||
|
mocks.resolve4.mockImplementation(
|
||||||
|
(_domain: string, callback: (error: Error | null) => void) =>
|
||||||
|
callback(noIpv4),
|
||||||
|
);
|
||||||
|
mocks.resolve6.mockImplementation(
|
||||||
|
(
|
||||||
|
_domain: string,
|
||||||
|
callback: (error: Error | null, addresses?: string[]) => void,
|
||||||
|
) => callback(null, ["2001:db8::10"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
validateDomain("example.com", ["2001:db8::10"]),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
isValid: true,
|
||||||
|
resolvedIp: "2001:db8::10",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type HasInput = { input: Record<string, unknown> };
|
||||||
|
|
||||||
|
const findMany = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
const {
|
||||||
|
send,
|
||||||
|
paginate,
|
||||||
|
SSMClient,
|
||||||
|
GetParametersCommand,
|
||||||
|
DescribeParametersCommand,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
class FakeCommand {
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
constructor(input: Record<string, unknown>) {
|
||||||
|
this.input = input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const send = vi.fn();
|
||||||
|
const paginate = vi.fn();
|
||||||
|
class SSMClient {
|
||||||
|
send(command: unknown) {
|
||||||
|
return send(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
send,
|
||||||
|
paginate,
|
||||||
|
SSMClient,
|
||||||
|
GetParametersCommand: class extends FakeCommand {},
|
||||||
|
DescribeParametersCommand: class extends FakeCommand {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@aws-sdk/client-ssm", () => ({
|
||||||
|
SSMClient,
|
||||||
|
GetParametersCommand,
|
||||||
|
DescribeParametersCommand,
|
||||||
|
paginateDescribeParameters: paginate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
vaultProvider: {
|
||||||
|
findMany: (...args: unknown[]) => findMany(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
|
||||||
|
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "aws-parameter-store" as const,
|
||||||
|
region: "eu-central-1",
|
||||||
|
accessKeyId: "AKIA_TEST",
|
||||||
|
secretAccessKey: "secret",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
send.mockReset();
|
||||||
|
paginate.mockReset();
|
||||||
|
findMany.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("awsParameterStoreClient", () => {
|
||||||
|
it("decrypts parameters in batches of ten and preserves selectors", async () => {
|
||||||
|
const refs = [
|
||||||
|
"/prod/database:CURRENT",
|
||||||
|
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
|
||||||
|
];
|
||||||
|
send.mockImplementation(async (command: HasInput) => ({
|
||||||
|
Parameters: (command.input.Names as string[]).map((ref) => {
|
||||||
|
if (ref === "/prod/database:CURRENT") {
|
||||||
|
return {
|
||||||
|
Name: "/prod/database",
|
||||||
|
Selector: ":CURRENT",
|
||||||
|
Value: "selected-value",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { Name: ref, Value: `value-for-${ref}` };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await awsParameterStoreClient.getSecrets(config, refs);
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledTimes(2);
|
||||||
|
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
|
||||||
|
WithDecryption: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
|
||||||
|
).toBe(10);
|
||||||
|
expect(
|
||||||
|
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
|
||||||
|
).toBe(1);
|
||||||
|
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
|
||||||
|
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing parameter without exposing other values", async () => {
|
||||||
|
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
awsParameterStoreClient.getSecrets(config, ["/missing"]),
|
||||||
|
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests the connection within the configured hierarchy", async () => {
|
||||||
|
send.mockResolvedValue({ Parameters: [] });
|
||||||
|
|
||||||
|
await awsParameterStoreClient.testConnection({
|
||||||
|
...config,
|
||||||
|
parameterPath: "/production/my-app/",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
|
||||||
|
ParameterFilters: [
|
||||||
|
{
|
||||||
|
Key: "Path",
|
||||||
|
Option: "Recursive",
|
||||||
|
Values: ["/production/my-app"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
MaxResults: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains the discovery permission when connection testing is denied", async () => {
|
||||||
|
const error = new Error("not authorized");
|
||||||
|
error.name = "AccessDeniedException";
|
||||||
|
send.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
awsParameterStoreClient.testConnection(config),
|
||||||
|
).rejects.toThrow("ssm:DescribeParameters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists parameter names across pages within the configured hierarchy", async () => {
|
||||||
|
paginate.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { Parameters: [{ Name: "/prod/db" }] };
|
||||||
|
yield { Parameters: [{ Name: "/prod/api" }] };
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = await awsParameterStoreClient.listSecretNames?.({
|
||||||
|
...config,
|
||||||
|
parameterPath: " /production/my-app/ ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(names).toEqual(["/prod/db", "/prod/api"]);
|
||||||
|
expect(paginate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ pageSize: 50 }),
|
||||||
|
{
|
||||||
|
ParameterFilters: [
|
||||||
|
{
|
||||||
|
Key: "Path",
|
||||||
|
Option: "Recursive",
|
||||||
|
Values: ["/production/my-app"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a vault reference through the registered provider", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "ssm-prod",
|
||||||
|
providerType: "aws-parameter-store",
|
||||||
|
config,
|
||||||
|
assignments: [{ projectId: "project-1", environmentIds: [] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
send.mockResolvedValue({
|
||||||
|
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveVaultReferences(
|
||||||
|
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
|
||||||
|
{
|
||||||
|
organizationId: "organization-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
environmentId: "environment-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("DB_PASSWORD=resolved");
|
||||||
|
});
|
||||||
|
});
|
||||||
+147
@@ -20,6 +20,7 @@ import {
|
|||||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||||
|
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
||||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||||
|
|
||||||
@@ -431,6 +432,152 @@ describe("azure client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("infisical client", () => {
|
||||||
|
const config = {
|
||||||
|
providerType: "infisical" as const,
|
||||||
|
siteUrl: "https://app.infisical.com",
|
||||||
|
clientId: "client-1",
|
||||||
|
clientSecret: "client-secret",
|
||||||
|
projectId: "workspace-1",
|
||||||
|
environmentSlug: "prod",
|
||||||
|
secretPath: "/frontend",
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||||
|
const list = (secrets: Record<string, string>) =>
|
||||||
|
jsonResponse({
|
||||||
|
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||||
|
secretKey,
|
||||||
|
secretValue,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const listPathOf = (callIndex: number) => {
|
||||||
|
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||||
|
return new URL(url).searchParams.get("secretPath");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("asks the list endpoint to expand secret references", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://real" });
|
||||||
|
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||||
|
const params = new URL(listUrl).searchParams;
|
||||||
|
expect(params.get("expandSecretReferences")).toBe("true");
|
||||||
|
expect(params.get("workspaceId")).toBe("workspace-1");
|
||||||
|
expect(params.get("environment")).toBe("prod");
|
||||||
|
expect(params.get("secretPath")).toBe("/frontend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error for a missing secret", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["ABSENT"]),
|
||||||
|
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates authentication failures with the status code", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||||
|
).rejects.toThrow("authentication failed (status 401)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, [
|
||||||
|
"shared/sentry:SENTRY_DSN",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||||
|
});
|
||||||
|
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a leading slash as an absolute path", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||||
|
|
||||||
|
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||||
|
|
||||||
|
expect(listPathOf(1)).toBe("/external/sentry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the root path clean when the provider sits at /", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||||
|
|
||||||
|
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||||
|
"external/sentry:KEY",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(listPathOf(1)).toBe("/external/sentry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in once and fetches each path once", async () => {
|
||||||
|
const byPath: Record<string, Record<string, string>> = {
|
||||||
|
"/frontend": { A: "a", B: "b" },
|
||||||
|
"/frontend/other": { C: "c" },
|
||||||
|
};
|
||||||
|
mockFetch.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||||
|
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||||
|
return list(byPath[path] ?? {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, [
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"other:C",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||||
|
|
||||||
|
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||||
|
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
urls
|
||||||
|
.filter((u) => u.includes("/secrets/raw?"))
|
||||||
|
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["/frontend", "/frontend/other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the path when a secret is missing from an explicit one", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ref with an empty path or key", async () => {
|
||||||
|
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||||
|
"expected format <path>:<KEY>",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||||
|
).rejects.toThrow("expected format <path>:<KEY>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("doppler client", () => {
|
describe("doppler client", () => {
|
||||||
it("propagates auth errors with the status code", async () => {
|
it("propagates auth errors with the status code", async () => {
|
||||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||||
|
|||||||
@@ -154,7 +154,16 @@ const hasRealMonitoring = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe.skipIf(hasRealMonitoring())(
|
const hasDocker = () => {
|
||||||
|
try {
|
||||||
|
execSync("docker info", { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
||||||
"setupMonitoring - legacy container cleanup (real docker)",
|
"setupMonitoring - legacy container cleanup (real docker)",
|
||||||
() => {
|
() => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
|||||||
docsUrl: null,
|
docsUrl: null,
|
||||||
errorPageTitle: null,
|
errorPageTitle: null,
|
||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
metaTitle: null,
|
ogImageUrl: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
},
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
|||||||
export { RollbackConfigForm } from "./rollback-config-form";
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
export { UpdateConfigForm } from "./update-config-form";
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
export { filterEmptyValues, hasValues } from "./utils";
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filters out undefined, null, and empty string values from form data
|
|
||||||
* Only returns fields that have actual values
|
|
||||||
*/
|
|
||||||
export const filterEmptyValues = (
|
|
||||||
formData: Record<string, any>,
|
|
||||||
): Record<string, any> => {
|
|
||||||
return Object.entries(formData).reduce(
|
|
||||||
(acc, [key, value]) => {
|
|
||||||
// Keep arrays even if empty (they might be intentionally cleared)
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.length > 0) {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For other values, filter out undefined, null, and empty strings
|
|
||||||
else if (value !== undefined && value !== null && value !== "") {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, any>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if filtered data has any values to save
|
|
||||||
*/
|
|
||||||
export const hasValues = (data: Record<string, any>): boolean => {
|
|
||||||
return Object.keys(data).length > 0;
|
|
||||||
};
|
|
||||||
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
domainId,
|
domainId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!domainId,
|
enabled: isOpen && !!domainId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
applicationId: id,
|
applicationId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!id,
|
enabled: isOpen && !!id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: api.compose.one.useQuery(
|
: api.compose.one.useQuery(
|
||||||
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!id,
|
enabled: isOpen && !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
const { data: canGenerateTraefikMeDomains } =
|
||||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
api.domain.canGenerateTraefikMeDomains.useQuery(
|
||||||
serverId: application?.serverId || "",
|
{
|
||||||
});
|
serverId: application?.serverId || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: services,
|
data: services,
|
||||||
@@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
enabled: type === "compose" && !!id,
|
enabled: isOpen && type === "compose" && !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import DOMPurify from "dompurify";
|
|
||||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||||
|
|
||||||
interface ShowIconSettingsProps {
|
interface ShowIconSettingsProps {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -89,15 +89,6 @@ export const ShowIconSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeSvg = (svgContent: string): string | null => {
|
|
||||||
const clean = DOMPurify.sanitize(svgContent, {
|
|
||||||
USE_PROFILES: { svg: true, svgFilters: true },
|
|
||||||
ADD_TAGS: ["use"],
|
|
||||||
});
|
|
||||||
if (!clean) return null;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Rocket } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const TrialBanner = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||||
|
|
||||||
|
if (!billingStatus?.isOnTrial) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
{daysRemaining > 0
|
||||||
|
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
|
||||||
|
: "Your free trial ends today."}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => router.push("/dashboard/settings/billing")}
|
||||||
|
>
|
||||||
|
Upgrade now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
import {
|
||||||
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
HardDriveDownload,
|
||||||
|
RefreshCcw,
|
||||||
|
Rocket,
|
||||||
|
Terminal,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -82,6 +89,51 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
|
{canDeploy && data?.composeType === "docker-compose" && (
|
||||||
|
<DialogAction
|
||||||
|
title="Deploy with Fresh Volumes"
|
||||||
|
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deploy({
|
||||||
|
composeId: composeId,
|
||||||
|
freshVolumes: true,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose deployed with fresh volumes");
|
||||||
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deploying compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
isLoading={data?.composeStatus === "running"}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<HardDriveDownload className="size-4 mr-1" />
|
||||||
|
Fresh Volumes
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
|
<p>
|
||||||
|
Deploy with fresh volumes (removes all persistent data)
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
{canDeploy && (
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rebuild Compose"
|
title="Rebuild Compose"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Fraunces } from "next/font/google";
|
||||||
|
|
||||||
|
export const displayFont = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["600"],
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
|
||||||
|
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
|
||||||
|
|
||||||
|
interface OnboardingState {
|
||||||
|
stepId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
environmentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isOnboardingActive = () => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markOnboardingActive = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true");
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearOnboardingActive = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY);
|
||||||
|
window.localStorage.removeItem(ONBOARDING_STATE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOnboardingState = (): OnboardingState => {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setOnboardingState = (state: OnboardingState) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
ONBOARDING_STATE_KEY,
|
||||||
|
JSON.stringify({ ...getOnboardingState(), ...state }),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { defineStepper } from "@stepperize/react";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { Logo } from "@/components/shared/logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { getOnboardingState, setOnboardingState } from "./onboarding-lock";
|
||||||
|
import { CompleteStep } from "./steps/complete-step";
|
||||||
|
import { DeployStep } from "./steps/deploy-step";
|
||||||
|
import { PlanStep } from "./steps/plan-step";
|
||||||
|
import { ProjectStep } from "./steps/project-step";
|
||||||
|
import { ServerStep } from "./steps/server-step";
|
||||||
|
import { WelcomeStep } from "./steps/welcome-step";
|
||||||
|
|
||||||
|
const { useStepper, steps, Scoped } = defineStepper(
|
||||||
|
{ id: "welcome", title: "Welcome" },
|
||||||
|
{ id: "plan", title: "Pick a plan" },
|
||||||
|
{ id: "project", title: "New project" },
|
||||||
|
{ id: "server", title: "Connect server" },
|
||||||
|
{ id: "deploy", title: "Ship something" },
|
||||||
|
{ id: "complete", title: "You're live" },
|
||||||
|
);
|
||||||
|
|
||||||
|
type StepId = (typeof steps)[number]["id"];
|
||||||
|
const isStepId = (id: string | undefined): id is StepId =>
|
||||||
|
!!id && steps.some((step) => step.id === id);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnboardingWizard = ({ onClose }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const persisted = getOnboardingState();
|
||||||
|
const stepper = useStepper(
|
||||||
|
isStepId(persisted.stepId) ? persisted.stepId : undefined,
|
||||||
|
);
|
||||||
|
const [projectId, setProjectId] = useState<string | undefined>(
|
||||||
|
persisted.projectId,
|
||||||
|
);
|
||||||
|
const [environmentId, setEnvironmentId] = useState<string | undefined>(
|
||||||
|
persisted.environmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||||
|
const visibleStepIds = isCloud
|
||||||
|
? steps.map((step) => step.id)
|
||||||
|
: steps
|
||||||
|
.filter((step) => step.id !== "plan" && step.id !== "server")
|
||||||
|
.map((step) => step.id);
|
||||||
|
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||||
|
const isLastVisible = visibleIndex === visibleStepIds.length - 1;
|
||||||
|
const goToNextVisible = () => {
|
||||||
|
const nextId = visibleStepIds[visibleIndex + 1];
|
||||||
|
if (nextId) stepper.goTo(nextId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [skipAllOpen, setSkipAllOpen] = useState(false);
|
||||||
|
const handleSkipAll = async () => {
|
||||||
|
await onClose();
|
||||||
|
router.push(
|
||||||
|
projectId && environmentId
|
||||||
|
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||||
|
: "/dashboard/projects",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnboardingState({ stepId: stepper.current.id });
|
||||||
|
}, [stepper.current.id]);
|
||||||
|
|
||||||
|
const { error: projectCheckError } = api.project.one.useQuery(
|
||||||
|
{ projectId: projectId ?? "" },
|
||||||
|
{ enabled: !!projectId, retry: false },
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectCheckError || !projectId) return;
|
||||||
|
setProjectId(undefined);
|
||||||
|
setEnvironmentId(undefined);
|
||||||
|
setOnboardingState({ projectId: undefined, environmentId: undefined });
|
||||||
|
stepper.goTo("project");
|
||||||
|
}, [projectCheckError, projectId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
|
||||||
|
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
||||||
|
backgroundSize: "28px 28px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
|
||||||
|
|
||||||
|
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
|
||||||
|
<div className="flex items-center justify-between md:block">
|
||||||
|
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
|
||||||
|
<Logo className="size-7" />
|
||||||
|
<span className="text-lg font-semibold tracking-tight">
|
||||||
|
Dokploy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isLastVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSkipAllOpen(true)}
|
||||||
|
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
|
||||||
|
>
|
||||||
|
Skip all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Scoped>
|
||||||
|
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
|
||||||
|
{visibleStepIds.map((id, index) => {
|
||||||
|
const step = steps.find((s) => s.id === id)!;
|
||||||
|
const isDone = index < visibleIndex;
|
||||||
|
const isCurrent = stepper.current.id === step.id;
|
||||||
|
return (
|
||||||
|
<li key={step.id} className="flex gap-3.5">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span
|
||||||
|
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
|
||||||
|
isCurrent
|
||||||
|
? "bg-white text-zinc-950 font-semibold"
|
||||||
|
: isDone
|
||||||
|
? "text-zinc-100"
|
||||||
|
: "text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckIcon className="size-3" />
|
||||||
|
) : (
|
||||||
|
String(index + 1).padStart(2, "0")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{index < visibleStepIds.length - 1 && (
|
||||||
|
<span
|
||||||
|
className={`w-px flex-1 min-h-7 my-1.5 ${
|
||||||
|
isDone ? "bg-zinc-400" : "bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isDone ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stepper.goTo(step.id)}
|
||||||
|
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
|
||||||
|
isCurrent ? "text-white" : "text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</Scoped>
|
||||||
|
|
||||||
|
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
|
||||||
|
{!isLastVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSkipAllOpen(true)}
|
||||||
|
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
Skip all →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-zinc-500">
|
||||||
|
<Link
|
||||||
|
href="https://github.com/dokploy/dokploy"
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<GithubIcon />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://x.com/getdokploy"
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
strokeWidth="0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="size-4"
|
||||||
|
>
|
||||||
|
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
className="size-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
|
||||||
|
{stepper.switch({
|
||||||
|
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
|
||||||
|
plan: () => <PlanStep onNext={goToNextVisible} />,
|
||||||
|
project: () => (
|
||||||
|
<ProjectStep
|
||||||
|
onNext={(project) => {
|
||||||
|
setProjectId(project.projectId);
|
||||||
|
setEnvironmentId(project.environmentId);
|
||||||
|
setOnboardingState({
|
||||||
|
projectId: project.projectId,
|
||||||
|
environmentId: project.environmentId,
|
||||||
|
});
|
||||||
|
goToNextVisible();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
server: () => <ServerStep onNext={goToNextVisible} />,
|
||||||
|
deploy: () => (
|
||||||
|
<DeployStep
|
||||||
|
environmentId={environmentId}
|
||||||
|
onNext={goToNextVisible}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
complete: () => (
|
||||||
|
<CompleteStep
|
||||||
|
projectId={projectId}
|
||||||
|
environmentId={environmentId}
|
||||||
|
onFinish={onClose}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
If this is your first time using Dokploy, we recommend going
|
||||||
|
through these steps — it only takes a couple of minutes and gives
|
||||||
|
you a feel for how projects, servers and deployments fit together.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
|
||||||
|
Continue setup
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSkipAll}>Skip anyway</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
BookIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
GitMergeIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ConfettiExplosion from "react-confetti-explosion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId?: string;
|
||||||
|
environmentId?: string;
|
||||||
|
onFinish: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: DatabaseIcon,
|
||||||
|
title: "Databases",
|
||||||
|
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GlobeIcon,
|
||||||
|
title: "Custom domains",
|
||||||
|
description: "Attach your own domains and get automatic HTTPS.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GitMergeIcon,
|
||||||
|
title: "CI/CD",
|
||||||
|
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UsersIcon,
|
||||||
|
title: "Team collaboration",
|
||||||
|
description: "Invite teammates with fine-grained permissions.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
const [isFinishing, setIsFinishing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowConfetti(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const projectHref =
|
||||||
|
projectId && environmentId
|
||||||
|
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||||
|
: "/dashboard/projects";
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
setIsFinishing(true);
|
||||||
|
await onFinish();
|
||||||
|
router.push(projectHref);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||||
|
{showConfetti && (
|
||||||
|
<ConfettiExplosion
|
||||||
|
duration={3000}
|
||||||
|
force={0.35}
|
||||||
|
particleCount={200}
|
||||||
|
width={1200}
|
||||||
|
zIndex={60}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||||
|
>
|
||||||
|
You're all set.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
This is just the beginning — here's some of what else you can do.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="flex flex-col divide-y">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
|
||||||
|
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
<feature.icon className="size-4 text-muted-foreground" />
|
||||||
|
{feature.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{feature.description}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-fit px-8"
|
||||||
|
isLoading={isFinishing}
|
||||||
|
onClick={handleFinish}
|
||||||
|
>
|
||||||
|
Go to my project
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link
|
||||||
|
href="https://docs.dokploy.com/docs/core"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<BookIcon size={14} />
|
||||||
|
Read the docs
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
CheckCircle2,
|
||||||
|
CopyIcon,
|
||||||
|
Loader2,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
environmentId?: string;
|
||||||
|
onNext: () => void;
|
||||||
|
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||||
|
* post-checkout welcome modal) that want the app's regular typography. */
|
||||||
|
plainTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURATED_TEMPLATES = [
|
||||||
|
{ id: "wordpress", name: "WordPress", logo: "wordpress.png" },
|
||||||
|
{ id: "ghost", name: "Ghost", logo: "ghost.jpeg" },
|
||||||
|
{ id: "n8n", name: "n8n", logo: "n8n.png" },
|
||||||
|
{ id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Deploying = { kind: "app" | "template"; id: string; url: string };
|
||||||
|
|
||||||
|
export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => {
|
||||||
|
const titleClassName = plainTitle
|
||||||
|
? "text-xl font-semibold tracking-tight"
|
||||||
|
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||||
|
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
const serverId = servers?.[0]?.serverId;
|
||||||
|
|
||||||
|
const [deploying, setDeploying] = useState<Deploying | null>(null);
|
||||||
|
const [starting, setStarting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: deployNginx } =
|
||||||
|
api.application.deployNginxQuickstart.useMutation();
|
||||||
|
const { mutateAsync: deployTemplate } =
|
||||||
|
api.compose.deployTemplate.useMutation();
|
||||||
|
const { mutateAsync: deployCompose } = api.compose.deploy.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data: application } = api.application.one.useQuery(
|
||||||
|
{ applicationId: deploying?.id ?? "" },
|
||||||
|
{
|
||||||
|
enabled: deploying?.kind === "app",
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.applicationStatus;
|
||||||
|
return status === "done" || status === "error" ? false : 2500;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { data: compose } = api.compose.one.useQuery(
|
||||||
|
{ composeId: deploying?.id ?? "" },
|
||||||
|
{
|
||||||
|
enabled: deploying?.kind === "template",
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.composeStatus;
|
||||||
|
return status === "done" || status === "error" ? false : 2500;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const status =
|
||||||
|
deploying?.kind === "app"
|
||||||
|
? application?.applicationStatus
|
||||||
|
: compose?.composeStatus;
|
||||||
|
|
||||||
|
if (!environmentId || (isCloud && !serverId)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
First deploy
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Deploy something.</h1>
|
||||||
|
</div>
|
||||||
|
<AlertBlock type="info" className="max-w-md">
|
||||||
|
{isCloud
|
||||||
|
? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard."
|
||||||
|
: "You'll need a project before deploying — you can do that anytime from the dashboard."}
|
||||||
|
</AlertBlock>
|
||||||
|
<Button onClick={onNext} className="w-fit px-8">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNginx = async () => {
|
||||||
|
setStarting("nginx");
|
||||||
|
try {
|
||||||
|
const res = await deployNginx({ environmentId, serverId });
|
||||||
|
setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error deploying the app",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStarting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplate = async (id: string) => {
|
||||||
|
setStarting(id);
|
||||||
|
try {
|
||||||
|
const composeResult = await deployTemplate({
|
||||||
|
environmentId,
|
||||||
|
serverId,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
await deployCompose({ composeId: composeResult.composeId });
|
||||||
|
const domains = await utils.domain.byComposeId.fetch({
|
||||||
|
composeId: composeResult.composeId,
|
||||||
|
});
|
||||||
|
const host = domains?.[0]?.host;
|
||||||
|
setDeploying({
|
||||||
|
kind: "template",
|
||||||
|
id: composeResult.composeId,
|
||||||
|
url: host ? `http://${host}` : "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error deploying the template",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStarting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deploying) {
|
||||||
|
const isDone = status === "done";
|
||||||
|
const isError = status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
|
||||||
|
isError ? "text-destructive" : "text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckCircle2 className="size-3.5" />
|
||||||
|
) : isError ? (
|
||||||
|
<XCircleIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>
|
||||||
|
{isDone
|
||||||
|
? "It's live."
|
||||||
|
: isError
|
||||||
|
? "Something went wrong."
|
||||||
|
: "Building your app..."}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
{isDone
|
||||||
|
? "Your app is up and reachable at:"
|
||||||
|
: isError
|
||||||
|
? "The deployment failed — you can check the logs from the dashboard later."
|
||||||
|
: "This usually takes a minute or two the first time."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDone && deploying.url ? (
|
||||||
|
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
|
||||||
|
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
Reachable now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 p-5">
|
||||||
|
<span className="font-mono text-sm truncate">
|
||||||
|
{deploying.url}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
copy(deploying.url);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a href={deploying.url} target="_blank" rel="noreferrer">
|
||||||
|
<ArrowUpRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !isDone && !isError ? (
|
||||||
|
<div className="rounded-2xl border p-5 max-w-md">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||||
|
Waiting for the build to finish...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="w-fit px-8"
|
||||||
|
disabled={!isDone && !isError}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
First deploy
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Ship something.</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Pick a quickstart — we'll generate a domain and deploy it for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNginx}
|
||||||
|
disabled={!!starting}
|
||||||
|
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Simple app</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
A "Hello World" demo app, live in seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||||
|
{starting === "nginx" ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Deploy now →"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between gap-8 bg-background p-7">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Docker Compose template</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Popular open source stacks, one click away.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CURATED_TEMPLATES.map((template) => (
|
||||||
|
<Button
|
||||||
|
key={template.id}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!!starting}
|
||||||
|
isLoading={starting === template.id}
|
||||||
|
onClick={() => handleTemplate(template.id)}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
|
||||||
|
alt={template.name}
|
||||||
|
className="size-4 object-contain shrink-0"
|
||||||
|
/>
|
||||||
|
{template.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
|
import { ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
calculatePriceHobby,
|
||||||
|
calculatePriceStartup,
|
||||||
|
STARTUP_SERVERS_INCLUDED,
|
||||||
|
} from "@/components/dashboard/settings/billing/show-billing";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlanStep = ({ onNext }: Props) => {
|
||||||
|
const [loadingTier, setLoadingTier] = useState<
|
||||||
|
"hobby" | "startup" | "trial" | null
|
||||||
|
>(null);
|
||||||
|
const { data } = api.stripe.getProducts.useQuery();
|
||||||
|
const { mutateAsync: createCheckoutSession } =
|
||||||
|
api.stripe.createCheckoutSession.useMutation();
|
||||||
|
const { mutateAsync: startFreeTrial } =
|
||||||
|
api.stripe.startFreeTrial.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const handleCheckout = async (tier: "hobby" | "startup") => {
|
||||||
|
if (!data) return;
|
||||||
|
const productId =
|
||||||
|
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
||||||
|
if (!productId) return;
|
||||||
|
setLoadingTier(tier);
|
||||||
|
try {
|
||||||
|
const stripe = await stripePromise;
|
||||||
|
const session = await createCheckoutSession({
|
||||||
|
tier,
|
||||||
|
productId,
|
||||||
|
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||||
|
isAnnual: false,
|
||||||
|
});
|
||||||
|
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
||||||
|
} catch {
|
||||||
|
toast.error("Error starting checkout");
|
||||||
|
setLoadingTier(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrial = async () => {
|
||||||
|
setLoadingTier("trial");
|
||||||
|
try {
|
||||||
|
await startFreeTrial();
|
||||||
|
await utils.project.onboardingStatus.invalidate();
|
||||||
|
toast.success("Your 7-day trial has started");
|
||||||
|
onNext();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting trial",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingTier(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Billing
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||||
|
>
|
||||||
|
Start free, upgrade when ready.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
No credit card for the trial — add one only if you decide to stay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
<p className={`${displayFont.className} text-2xl mt-3`}>
|
||||||
|
7-day free trial
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
|
||||||
|
No card required — cancel anytime.
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1.5 mt-4">
|
||||||
|
{[
|
||||||
|
"Setup 1 server",
|
||||||
|
"Unlimited apps & databases",
|
||||||
|
"Community support",
|
||||||
|
].map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
|
||||||
|
isLoading={loadingTier === "trial"}
|
||||||
|
disabled={loadingTier !== null}
|
||||||
|
onClick={handleTrial}
|
||||||
|
>
|
||||||
|
Start trial
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||||
|
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Hobby</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
For individual developers
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||||
|
${calculatePriceHobby(1, false).toFixed(2)}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
/mo
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1.5 mt-4">
|
||||||
|
{[
|
||||||
|
"Setup 1 server",
|
||||||
|
"Unlimited apps & databases",
|
||||||
|
"2 environments",
|
||||||
|
"Community support",
|
||||||
|
].map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
isLoading={loadingTier === "hobby"}
|
||||||
|
disabled={loadingTier !== null || !data?.hobbyProductId}
|
||||||
|
onClick={() => handleCheckout("hobby")}
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Startup</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
For small to mid-size teams
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||||
|
$
|
||||||
|
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
/mo
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1.5 mt-4">
|
||||||
|
{[
|
||||||
|
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||||
|
"Unlimited users & environments",
|
||||||
|
"Basic RBAC + 2FA",
|
||||||
|
"Email & chat support",
|
||||||
|
].map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
isLoading={loadingTier === "startup"}
|
||||||
|
disabled={loadingTier !== null || !data?.startupProductId}
|
||||||
|
onClick={() => handleCheckout("startup")}
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
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,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: (project: { projectId: string; environmentId: string }) => void;
|
||||||
|
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||||
|
* post-checkout welcome modal) that want the app's regular typography. */
|
||||||
|
plainTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
||||||
|
const titleClassName = plainTitle
|
||||||
|
? "text-xl font-semibold tracking-tight"
|
||||||
|
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||||
|
const { mutateAsync, isPending } = api.project.create.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: { name: "My First Project", description: "" },
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: Schema) => {
|
||||||
|
try {
|
||||||
|
const result = await mutateAsync(data);
|
||||||
|
await utils.project.all.invalidate();
|
||||||
|
toast.success("Project created");
|
||||||
|
onNext({
|
||||||
|
projectId: result.project.projectId,
|
||||||
|
environmentId: result.environment?.environmentId ?? "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error creating the project",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Workspace
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Create your first project.</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Projects group your apps, databases and environments together.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-5 max-w-sm"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My First Project" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="What is this project for?"
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isPending}
|
||||||
|
className="w-fit px-8 mt-2"
|
||||||
|
>
|
||||||
|
Create project
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
ipAddress: z
|
||||||
|
.string()
|
||||||
|
.min(1, "IP address is required")
|
||||||
|
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
|
||||||
|
});
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||||
|
* post-checkout welcome modal) that want the app's regular typography. */
|
||||||
|
plainTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusRow = ({
|
||||||
|
label,
|
||||||
|
ok,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
ok?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
|
||||||
|
) : pending ? (
|
||||||
|
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||||
|
const titleClassName = plainTitle
|
||||||
|
? "text-xl font-semibold tracking-tight"
|
||||||
|
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||||
|
|
||||||
|
const { data: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
|
||||||
|
const generateSSHKey = api.sshKey.generate.useMutation();
|
||||||
|
const createSSHKey = api.sshKey.create.useMutation();
|
||||||
|
const hasCreatedKey = useRef(false);
|
||||||
|
|
||||||
|
const cloudSSHKey = sshKeys?.find(
|
||||||
|
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ensureKey = async () => {
|
||||||
|
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
|
||||||
|
hasCreatedKey.current = true;
|
||||||
|
try {
|
||||||
|
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
|
||||||
|
await createSSHKey.mutateAsync({
|
||||||
|
name: "dokploy-onboarding-ssh-key",
|
||||||
|
description: "Used during onboarding",
|
||||||
|
privateKey: keys.privateKey,
|
||||||
|
publicKey: keys.publicKey,
|
||||||
|
organizationId: "",
|
||||||
|
});
|
||||||
|
await refetchSSHKeys();
|
||||||
|
} catch {
|
||||||
|
hasCreatedKey.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensureKey();
|
||||||
|
}, [sshKeys]);
|
||||||
|
|
||||||
|
const { data: existingServers } = api.server.all.useQuery();
|
||||||
|
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createdServerId && existingServers && existingServers.length > 0) {
|
||||||
|
setCreatedServerId(existingServers[0]!.serverId);
|
||||||
|
}
|
||||||
|
}, [existingServers, createdServerId]);
|
||||||
|
|
||||||
|
const { data: canCreateMoreServers } =
|
||||||
|
api.stripe.canCreateMoreServers.useQuery();
|
||||||
|
const { mutateAsync: createServer, isPending: isCreating } =
|
||||||
|
api.server.create.useMutation();
|
||||||
|
const checkIsReady = (validation?: {
|
||||||
|
docker?: { enabled?: boolean };
|
||||||
|
isDokployNetworkInstalled?: boolean;
|
||||||
|
}) =>
|
||||||
|
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: validation,
|
||||||
|
refetch: refetchValidation,
|
||||||
|
isFetching: isValidating,
|
||||||
|
} = api.server.validate.useQuery(
|
||||||
|
{ serverId: createdServerId ?? "" },
|
||||||
|
{
|
||||||
|
enabled: !!createdServerId,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
checkIsReady(query.state.data) ? false : 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const isReady = checkIsReady(validation);
|
||||||
|
|
||||||
|
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||||
|
const hasStartedSetup = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (createdServerId && !hasStartedSetup.current && !isReady) {
|
||||||
|
hasStartedSetup.current = true;
|
||||||
|
setIsSettingUp(true);
|
||||||
|
}
|
||||||
|
}, [createdServerId, isReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSettingUp) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsSettingUp(false);
|
||||||
|
toast.error(
|
||||||
|
"Setup is taking too long — check the server is reachable, then try again.",
|
||||||
|
);
|
||||||
|
}, 120_000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [isSettingUp]);
|
||||||
|
|
||||||
|
api.server.setupWithLogs.useSubscription(
|
||||||
|
{ serverId: createdServerId ?? "" },
|
||||||
|
{
|
||||||
|
enabled: !!createdServerId && isSettingUp,
|
||||||
|
onData(log) {
|
||||||
|
if (typeof log === "string" && log.includes("Setup Server:")) {
|
||||||
|
setIsSettingUp(false);
|
||||||
|
refetchValidation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setIsSettingUp(false);
|
||||||
|
toast.error("Error setting up the server");
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: { name: "My First Server", ipAddress: "" },
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: Schema) => {
|
||||||
|
if (!cloudSSHKey) {
|
||||||
|
toast.error("Still generating your SSH key, try again in a moment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const server = await createServer({
|
||||||
|
name: data.name,
|
||||||
|
description: "",
|
||||||
|
ipAddress: data.ipAddress.trim(),
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
sshKeyId: cloudSSHKey.sshKeyId,
|
||||||
|
serverType: "deploy",
|
||||||
|
enableDockerCleanup: false,
|
||||||
|
});
|
||||||
|
setCreatedServerId(server.serverId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error creating the server",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Infrastructure
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Connect a server.</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Dokploy deploys to servers you own — buy one from any VPS provider
|
||||||
|
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!createdServerId ? (
|
||||||
|
<div className="flex flex-col gap-4 max-w-sm">
|
||||||
|
{canCreateMoreServers === false && (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You'll need a plan or trial before connecting a server — go back
|
||||||
|
to the "Pick a plan" step.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
1. Run this on your server to authorize Dokploy
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
|
||||||
|
{cloudSSHKey
|
||||||
|
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
|
||||||
|
: "Generating..."}
|
||||||
|
</code>
|
||||||
|
{cloudSSHKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copy(
|
||||||
|
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My First Server" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ipAddress"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>2. IP address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isCreating}
|
||||||
|
disabled={!cloudSSHKey || canCreateMoreServers === false}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Connect server
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 w-full max-w-sm">
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||||
|
{isSettingUp ? (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||||
|
Setting up your server — installing Docker and dependencies...
|
||||||
|
</div>
|
||||||
|
) : isValidating && !validation ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Checking your server...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StatusRow
|
||||||
|
label="Docker installed"
|
||||||
|
ok={validation?.docker?.enabled}
|
||||||
|
pending={!validation?.docker?.enabled && !isReady}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Dokploy network created"
|
||||||
|
ok={validation?.isDokployNetworkInstalled}
|
||||||
|
pending={!validation?.isDokployNetworkInstalled && !isReady}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isReady && !isSettingUp && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
Setup usually takes a minute after the server boots — this checks
|
||||||
|
automatically. You can retry setup, or use "Skip for now" above to
|
||||||
|
continue and come back to this later.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
isLoading={isValidating || isSettingUp}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isReady && !isSettingUp) {
|
||||||
|
setIsSettingUp(true);
|
||||||
|
} else {
|
||||||
|
refetchValidation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isReady || isSettingUp}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = [
|
||||||
|
{
|
||||||
|
icon: GitBranchIcon,
|
||||||
|
title: "Deploy from Git or Docker",
|
||||||
|
description:
|
||||||
|
"Push a repo, a compose file, or a container image and go live.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: PuzzleIcon,
|
||||||
|
title: "One-click templates",
|
||||||
|
description:
|
||||||
|
"WordPress, databases, and dozens of open source apps, pre-wired.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ServerIcon,
|
||||||
|
title: "Your own servers",
|
||||||
|
description: "Runs on infrastructure you control — no vendor lock-in.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WelcomeStep = ({ onNext }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-12">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Welcome
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||||
|
>
|
||||||
|
Let's get your first app <em className="not-italic">live</em>.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
A few steps — pick a plan, connect a server, ship something. You'll
|
||||||
|
have a working URL by the end.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="flex flex-col divide-y">
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
|
||||||
|
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
<point.icon className="size-4 text-muted-foreground" />
|
||||||
|
{point.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{point.description}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon, Plus } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { PenBoxIcon, Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Dropzone } from "@/components/ui/dropzone";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -24,6 +27,8 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { resizeImage } from "@/utils/image-processing";
|
||||||
|
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||||
|
|
||||||
const organizationSchema = z.object({
|
const organizationSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
@@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
|||||||
interface Props {
|
interface Props {
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddOrganization({ organizationId }: Props) {
|
export function AddOrganization({
|
||||||
const [open, setOpen] = useState(false);
|
organizationId,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
}: Props) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const uploadCounter = useRef(0);
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled
|
||||||
|
? controlledOnOpenChange || (() => {})
|
||||||
|
: setInternalOpen;
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: organization } = api.organization.one.useQuery(
|
const { data: organization } = api.organization.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organization) {
|
if (organization) {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
form.reset({
|
form.reset({
|
||||||
name: organization.name,
|
name: organization.name,
|
||||||
logo: organization.logo || "",
|
logo: organization.logo || "",
|
||||||
});
|
});
|
||||||
|
setUploadedFileName(null);
|
||||||
}
|
}
|
||||||
}, [organization, form]);
|
}, [organization, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: OrganizationFormValues) => {
|
const onSubmit = async (values: OrganizationFormValues) => {
|
||||||
|
if (isUploading) return;
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
logo: values.logo,
|
logo: values.logo,
|
||||||
@@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setUploadedFileName(null);
|
||||||
toast.success(
|
toast.success(
|
||||||
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
||||||
);
|
);
|
||||||
@@ -99,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const currentUploadId = ++uploadCounter.current;
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allowedTypes.includes(file.type) &&
|
||||||
|
!allowedExtensions.includes(fileExtension || "")
|
||||||
|
) {
|
||||||
|
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast.error("Image size must be less than 2MB");
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const sanitizedDataUrl = sanitizeSvg(text);
|
||||||
|
if (currentUploadId !== uploadCounter.current) return;
|
||||||
|
if (!sanitizedDataUrl) {
|
||||||
|
toast.error("Invalid SVG file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setValue("logo", sanitizedDataUrl);
|
||||||
|
form.trigger("logo");
|
||||||
|
setUploadedFileName(file.name);
|
||||||
|
} catch (error) {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
toast.error("Error processing SVG");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize raster images to max 256x256 and convert to WebP to save space
|
||||||
|
try {
|
||||||
|
const resizedDataUrl = await resizeImage(file, 256);
|
||||||
|
if (currentUploadId !== uploadCounter.current) return;
|
||||||
|
form.setValue("logo", resizedDataUrl);
|
||||||
|
form.trigger("logo");
|
||||||
|
setUploadedFileName(file.name);
|
||||||
|
} catch (error) {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
toast.error("Error processing image");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
if (!val) {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
setOpen(val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{organizationId ? (
|
{organizationId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -146,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="tems-center gap-4">
|
<FormItem className="items-center gap-4">
|
||||||
<FormLabel className="text-right">Name</FormLabel>
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel className="text-right">Name</FormLabel>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Organization name"
|
placeholder="Organization name"
|
||||||
@@ -162,23 +274,78 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="logo"
|
name="logo"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="gap-4">
|
const isDataUrl = field.value?.startsWith("data:");
|
||||||
<FormLabel className="text-right">Logo URL</FormLabel>
|
const displayValue = isDataUrl
|
||||||
<FormControl>
|
? uploadedFileName || "Uploaded image"
|
||||||
<Input
|
: field.value || "";
|
||||||
placeholder="https://example.com/logo.png"
|
|
||||||
{...field}
|
return (
|
||||||
value={field.value || ""}
|
<FormItem className="gap-4">
|
||||||
className="col-span-3"
|
<FormLabel className="text-right">
|
||||||
/>
|
Logo URL or Upload
|
||||||
</FormControl>
|
</FormLabel>
|
||||||
<FormMessage className="col-span-3 col-start-2" />
|
<FormControl>
|
||||||
</FormItem>
|
<div className="col-span-3 flex flex-col gap-3">
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
|
||||||
|
{field.value ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
|
||||||
|
<img
|
||||||
|
src={field.value}
|
||||||
|
alt="Logo preview"
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Logo className="size-7" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
{...field}
|
||||||
|
value={displayValue}
|
||||||
|
readOnly={isDataUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
|
field.onChange(e);
|
||||||
|
if (isDataUrl) setUploadedFileName(null);
|
||||||
|
}}
|
||||||
|
className="w-full pr-8"
|
||||||
|
/>
|
||||||
|
{field.value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
|
form.setValue("logo", "");
|
||||||
|
setUploadedFileName(null);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dropzone
|
||||||
|
dropMessage="Drag & drop a logo or click to upload"
|
||||||
|
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||||
|
classNameContent="h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="col-span-3 col-start-2" />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" isLoading={isPending}>
|
<Button type="submit" isLoading={isPending || isUploading}>
|
||||||
{organizationId ? "Update organization" : "Create organization"}
|
{organizationId ? "Update organization" : "Create organization"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
Clock,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -93,6 +94,7 @@ export const ShowBilling = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
|
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||||
const { data, isPending } = api.stripe.getProducts.useQuery();
|
const { data, isPending } = api.stripe.getProducts.useQuery();
|
||||||
const { mutateAsync: createCheckoutSession } =
|
const { mutateAsync: createCheckoutSession } =
|
||||||
api.stripe.createCheckoutSession.useMutation();
|
api.stripe.createCheckoutSession.useMutation();
|
||||||
@@ -103,8 +105,26 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
const { mutateAsync: updateInvoiceNotifications } =
|
const { mutateAsync: updateInvoiceNotifications } =
|
||||||
api.stripe.updateInvoiceNotifications.useMutation();
|
api.stripe.updateInvoiceNotifications.useMutation();
|
||||||
|
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
|
||||||
|
api.stripe.startFreeTrial.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const handleStartTrial = async () => {
|
||||||
|
try {
|
||||||
|
await startFreeTrial();
|
||||||
|
await Promise.all([
|
||||||
|
utils.stripe.getBillingStatus.invalidate(),
|
||||||
|
utils.stripe.getProducts.invalidate(),
|
||||||
|
utils.user.get.invalidate(),
|
||||||
|
]);
|
||||||
|
toast.success("Your 7-day trial has started");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting trial",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||||
STARTUP_SERVERS_INCLUDED,
|
STARTUP_SERVERS_INCLUDED,
|
||||||
@@ -249,6 +269,94 @@ export const ShowBilling = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full mt-6">
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
|
{!isEnterpriseCloud &&
|
||||||
|
billingStatus &&
|
||||||
|
(billingStatus.plan || billingStatus.isOnTrial) && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{billingStatus.isOnTrial ? (
|
||||||
|
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CreditCard className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{billingStatus.isOnTrial
|
||||||
|
? "Free trial"
|
||||||
|
: "Current plan"}
|
||||||
|
</span>
|
||||||
|
<Badge className="capitalize" variant="secondary">
|
||||||
|
{billingStatus.plan ?? "Trial"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{billingStatus.isOnTrial
|
||||||
|
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
|
||||||
|
billingStatus.trialEndsAt
|
||||||
|
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
: "You're subscribed and billed automatically."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{billingStatus.isOnTrial &&
|
||||||
|
admin?.user.stripeCustomerId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
const session = await createCustomerPortalSession();
|
||||||
|
window.open(session.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add payment method
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEnterpriseCloud &&
|
||||||
|
billingStatus &&
|
||||||
|
!billingStatus.plan &&
|
||||||
|
!billingStatus.isOnTrial &&
|
||||||
|
!billingStatus.hasUsedTrial && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
7-day free trial
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
No credit card required — cancel anytime.
|
||||||
|
</span>
|
||||||
|
<ul className="flex flex-col gap-1 mt-1">
|
||||||
|
{[
|
||||||
|
"Setup 1 server",
|
||||||
|
"Unlimited apps & databases",
|
||||||
|
"Community support",
|
||||||
|
].map((feature) => (
|
||||||
|
<li
|
||||||
|
key={feature}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={isStartingTrial}
|
||||||
|
onClick={handleStartTrial}
|
||||||
|
>
|
||||||
|
Start trial
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||||
@@ -809,7 +917,7 @@ export const ShowBilling = () => {
|
|||||||
"Unlimited Deployments",
|
"Unlimited Deployments",
|
||||||
"Unlimited Databases",
|
"Unlimited Databases",
|
||||||
"Unlimited Applications",
|
"Unlimited Applications",
|
||||||
"1 Server Included",
|
"Setup 1 Server",
|
||||||
"1 Organization",
|
"1 Organization",
|
||||||
"1 User",
|
"1 User",
|
||||||
"2 Environments",
|
"2 Environments",
|
||||||
@@ -941,7 +1049,7 @@ export const ShowBilling = () => {
|
|||||||
All the features of Hobby, plus…
|
All the features of Hobby, plus…
|
||||||
</li>
|
</li>
|
||||||
{[
|
{[
|
||||||
"3 Servers Included",
|
"Setup up to 3 Servers",
|
||||||
"3 Organizations",
|
"3 Organizations",
|
||||||
"Unlimited Users",
|
"Unlimited Users",
|
||||||
"Unlimited Environments",
|
"Unlimited Environments",
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ShowBilling } from "@/components/dashboard/settings/billing/show-billing";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
export const ShowWelcomeDokploy = () => {
|
|
||||||
const { data } = api.user.get.useQuery();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
|
|
||||||
|
|
||||||
if (!isCloud || data?.role !== "admin") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isPending &&
|
|
||||||
isCloud &&
|
|
||||||
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
|
|
||||||
data?.role === "owner"
|
|
||||||
) {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}, [isCloud, isPending]);
|
|
||||||
|
|
||||||
const handleClose = (isOpen: boolean) => {
|
|
||||||
if (data?.role === "owner") {
|
|
||||||
setOpen(isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-semibold text-center">
|
|
||||||
Welcome to Dokploy Cloud 🎉
|
|
||||||
</DialogTitle>
|
|
||||||
<p className="text-center text-sm text-muted-foreground mt-2">
|
|
||||||
Unlock powerful features to streamline your deployments and manage
|
|
||||||
projects effortlessly.
|
|
||||||
</p>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="mt-4 space-y-3 text-sm text-primary ">
|
|
||||||
<ShowBilling />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -44,6 +44,18 @@ const providerLabels = {
|
|||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
route53: "AWS Route53",
|
route53: "AWS Route53",
|
||||||
porkbun: "Porkbun",
|
porkbun: "Porkbun",
|
||||||
|
infomaniak: "Infomaniak",
|
||||||
|
ovh: "OVHcloud",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ovhEndpointLabels = {
|
||||||
|
"ovh-eu": "OVHcloud Europe",
|
||||||
|
"ovh-ca": "OVHcloud Canada",
|
||||||
|
"ovh-us": "OVHcloud US",
|
||||||
|
"kimsufi-eu": "Kimsufi Europe",
|
||||||
|
"kimsufi-ca": "Kimsufi Canada",
|
||||||
|
"soyoustart-eu": "So you Start Europe",
|
||||||
|
"soyoustart-ca": "So you Start Canada",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ProviderType = keyof typeof providerLabels;
|
type ProviderType = keyof typeof providerLabels;
|
||||||
@@ -55,12 +67,27 @@ const DnsProviderSchema = z.object({
|
|||||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||||
message: "Only letters, numbers, dashes and underscores",
|
message: "Only letters, numbers, dashes and underscores",
|
||||||
}),
|
}),
|
||||||
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
|
providerType: z.enum([
|
||||||
|
"cloudflare",
|
||||||
|
"route53",
|
||||||
|
"porkbun",
|
||||||
|
"infomaniak",
|
||||||
|
"ovh",
|
||||||
|
]),
|
||||||
apiToken: z.string(),
|
apiToken: z.string(),
|
||||||
accessKeyId: z.string(),
|
accessKeyId: z.string(),
|
||||||
secretAccessKey: z.string(),
|
secretAccessKey: z.string(),
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
secretApiKey: z.string(),
|
secretApiKey: z.string(),
|
||||||
|
endpoint: z.enum(
|
||||||
|
Object.keys(ovhEndpointLabels) as [
|
||||||
|
keyof typeof ovhEndpointLabels,
|
||||||
|
...(keyof typeof ovhEndpointLabels)[],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
applicationKey: z.string(),
|
||||||
|
applicationSecret: z.string(),
|
||||||
|
consumerKey: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||||
@@ -73,6 +100,10 @@ const defaultValues: DnsProviderForm = {
|
|||||||
secretAccessKey: "",
|
secretAccessKey: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
secretApiKey: "",
|
secretApiKey: "",
|
||||||
|
endpoint: "ovh-eu",
|
||||||
|
applicationKey: "",
|
||||||
|
applicationSecret: "",
|
||||||
|
consumerKey: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConfig = (data: DnsProviderForm) => {
|
const buildConfig = (data: DnsProviderForm) => {
|
||||||
@@ -94,6 +125,19 @@ const buildConfig = (data: DnsProviderForm) => {
|
|||||||
apiKey: data.apiKey,
|
apiKey: data.apiKey,
|
||||||
secretApiKey: data.secretApiKey,
|
secretApiKey: data.secretApiKey,
|
||||||
};
|
};
|
||||||
|
case "infomaniak":
|
||||||
|
return {
|
||||||
|
providerType: "infomaniak" as const,
|
||||||
|
apiToken: data.apiToken,
|
||||||
|
};
|
||||||
|
case "ovh":
|
||||||
|
return {
|
||||||
|
providerType: "ovh" as const,
|
||||||
|
endpoint: data.endpoint,
|
||||||
|
applicationKey: data.applicationKey,
|
||||||
|
applicationSecret: data.applicationSecret,
|
||||||
|
consumerKey: data.consumerKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,6 +199,15 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
apiKey: provider.config.apiKey,
|
apiKey: provider.config.apiKey,
|
||||||
secretApiKey: provider.config.secretApiKey,
|
secretApiKey: provider.config.secretApiKey,
|
||||||
}),
|
}),
|
||||||
|
...(provider.config.providerType === "infomaniak" && {
|
||||||
|
apiToken: provider.config.apiToken,
|
||||||
|
}),
|
||||||
|
...(provider.config.providerType === "ovh" && {
|
||||||
|
endpoint: provider.config.endpoint,
|
||||||
|
applicationKey: provider.config.applicationKey,
|
||||||
|
applicationSecret: provider.config.applicationSecret,
|
||||||
|
consumerKey: provider.config.consumerKey,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (!dnsProviderId) {
|
} else if (!dnsProviderId) {
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
@@ -383,6 +436,118 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{providerType === "infomaniak" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="apiToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Create a token at manager.infomaniak.com with the{" "}
|
||||||
|
<code>domain:read</code>, <code>dns:read</code> and{" "}
|
||||||
|
<code>dns:write</code> scopes.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providerType === "ovh" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpoint"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Endpoint</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an endpoint" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(ovhEndpointLabels).map(
|
||||||
|
([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="applicationKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Application Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="applicationSecret"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Application Secret</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="consumerKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Consumer Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Create the three keys at once on
|
||||||
|
api.ovh.com/createToken, with exactly these five rights:
|
||||||
|
<br />
|
||||||
|
<code>GET /domain/zone</code>
|
||||||
|
<br />
|
||||||
|
<code>GET /domain/zone/*</code>
|
||||||
|
<br />
|
||||||
|
<code>POST /domain/zone/*</code>
|
||||||
|
<br />
|
||||||
|
<code>PUT /domain/zone/*</code>
|
||||||
|
<br />
|
||||||
|
<code>DELETE /domain/zone/*</code>
|
||||||
|
<br />
|
||||||
|
The first one lists your zones and has to be granted on
|
||||||
|
its own: OVH matches rights per exact path, so{" "}
|
||||||
|
<code>/domain/zone/*</code> does not cover it.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { HandleDnsProvider } from "./handle-dns-provider";
|
|||||||
const providerLabels: Record<string, string> = {
|
const providerLabels: Record<string, string> = {
|
||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
route53: "AWS Route53",
|
route53: "AWS Route53",
|
||||||
|
porkbun: "Porkbun",
|
||||||
|
infomaniak: "Infomaniak",
|
||||||
|
ovh: "OVHcloud",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ShowDnsProviders = () => {
|
export const ShowDnsProviders = () => {
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Stethoscope } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowHealthModal = ({ serverId }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
|
||||||
<Stethoscope className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
|
|
||||||
<div className="flex gap-4 py-4 w-full">
|
|
||||||
<ShowHealth serverId={serverId} />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -33,7 +33,6 @@ import { ShowServerActions } from "./actions/show-server-actions";
|
|||||||
import { DeleteServerModal } from "./delete-server-modal";
|
import { DeleteServerModal } from "./delete-server-modal";
|
||||||
import { HandleServers } from "./handle-servers";
|
import { HandleServers } from "./handle-servers";
|
||||||
import { SetupServer } from "./setup-server";
|
import { SetupServer } from "./setup-server";
|
||||||
import { ShowHealthModal } from "./show-health-modal";
|
|
||||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||||
|
|
||||||
@@ -116,10 +115,13 @@ export const ShowServers = () => {
|
|||||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<CardTitle className="text-lg wrap-break-word min-w-0">
|
<CardTitle
|
||||||
|
className="text-lg truncate min-w-0"
|
||||||
|
title={server.name}
|
||||||
|
>
|
||||||
{server.name}
|
{server.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,24 +324,6 @@ export const ShowServers = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{permissions?.docker.read &&
|
|
||||||
permissions?.server.read &&
|
|
||||||
server.sshKeyId &&
|
|
||||||
!isBuildServer && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<ShowHealthModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Health</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{permissions?.server.delete && (
|
{permissions?.server.delete && (
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const Schema = z.object({
|
|
||||||
name: z.string().min(1, {
|
|
||||||
message: "Name is required",
|
|
||||||
}),
|
|
||||||
description: z.string().optional(),
|
|
||||||
ipAddress: z.string().min(1, {
|
|
||||||
message: "IP Address is required",
|
|
||||||
}),
|
|
||||||
port: z.number().optional(),
|
|
||||||
username: z.string().optional(),
|
|
||||||
sshKeyId: z.string().min(1, {
|
|
||||||
message: "SSH Key is required",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type Schema = z.infer<typeof Schema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
stepper: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateServer = ({ stepper }: Props) => {
|
|
||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
|
||||||
const [isOpen, _setIsOpen] = useState(false);
|
|
||||||
const { data: canCreateMoreServers, refetch } =
|
|
||||||
api.stripe.canCreateMoreServers.useQuery();
|
|
||||||
const { mutateAsync } = api.server.create.useMutation();
|
|
||||||
const cloudSSHKey = sshKeys?.find(
|
|
||||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
|
||||||
defaultValues: {
|
|
||||||
description: "Dokploy Cloud Server",
|
|
||||||
name: "My First Server",
|
|
||||||
ipAddress: "",
|
|
||||||
port: 22,
|
|
||||||
username: "root",
|
|
||||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
|
||||||
},
|
|
||||||
resolver: zodResolver(Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
description: "Dokploy Cloud Server",
|
|
||||||
name: "My First Server",
|
|
||||||
ipAddress: "",
|
|
||||||
port: 22,
|
|
||||||
username: "root",
|
|
||||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
|
||||||
});
|
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refetch();
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: Schema) => {
|
|
||||||
await mutateAsync({
|
|
||||||
name: data.name,
|
|
||||||
description: data.description || "",
|
|
||||||
ipAddress: data.ipAddress?.trim() || "",
|
|
||||||
port: data.port || 22,
|
|
||||||
username: data.username || "root",
|
|
||||||
sshKeyId: data.sshKeyId || "",
|
|
||||||
serverType: "deploy",
|
|
||||||
})
|
|
||||||
.then(async (_data) => {
|
|
||||||
toast.success("Server Created");
|
|
||||||
stepper.next();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error creating a server");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Card className="bg-background flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2 pt-5 px-4">
|
|
||||||
{!canCreateMoreServers && (
|
|
||||||
<AlertBlock type="warning" className="mt-2">
|
|
||||||
You cannot create more servers,{" "}
|
|
||||||
<Link href="/dashboard/settings/billing" className="text-primary">
|
|
||||||
Please upgrade your plan
|
|
||||||
</Link>
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-col">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-add-server"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 ">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Hostinger Server" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="This server is for databases..."
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sshKeyId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Select a SSH Key</FormLabel>
|
|
||||||
{!cloudSSHKey && (
|
|
||||||
<AlertBlock>
|
|
||||||
Looks like you didn't have the SSH Key yet, you can create
|
|
||||||
one{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/ssh-keys"
|
|
||||||
className="text-primary"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</Link>
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a SSH Key" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{sshKeys?.map((sshKey) => (
|
|
||||||
<SelectItem
|
|
||||||
key={sshKey.sshKeyId}
|
|
||||||
value={sshKey.sshKeyId}
|
|
||||||
>
|
|
||||||
{sshKey.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="ipAddress"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>IP Address</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="192.168.1.100" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="port"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Port</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="22"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value === "") {
|
|
||||||
field.onChange(0);
|
|
||||||
} else {
|
|
||||||
const number = Number.parseInt(value, 10);
|
|
||||||
if (!Number.isNaN(number)) {
|
|
||||||
field.onChange(number);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="root" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={form.formState.isSubmitting}
|
|
||||||
disabled={!canCreateMoreServers}
|
|
||||||
form="hook-form-add-server"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import { CopyIcon, ExternalLinkIcon, Loader2 } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
export const CreateSSHKey = () => {
|
|
||||||
const { data, refetch } = api.sshKey.all.useQuery();
|
|
||||||
const generateMutation = api.sshKey.generate.useMutation();
|
|
||||||
const { mutateAsync, isPending } = api.sshKey.create.useMutation();
|
|
||||||
const hasCreatedKey = useRef(false);
|
|
||||||
const [selectedOption, setSelectedOption] = useState<"manual" | "provider">(
|
|
||||||
"manual",
|
|
||||||
);
|
|
||||||
|
|
||||||
const cloudSSHKey = data?.find(
|
|
||||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const createKey = async () => {
|
|
||||||
if (!data || cloudSSHKey || hasCreatedKey.current || isPending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCreatedKey.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const keys = await generateMutation.mutateAsync({
|
|
||||||
type: "rsa",
|
|
||||||
});
|
|
||||||
await mutateAsync({
|
|
||||||
name: "dokploy-cloud-ssh-key",
|
|
||||||
description: "Used on Dokploy Cloud",
|
|
||||||
privateKey: keys.privateKey,
|
|
||||||
publicKey: keys.publicKey,
|
|
||||||
organizationId: "",
|
|
||||||
});
|
|
||||||
await refetch();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating SSH key:", error);
|
|
||||||
hasCreatedKey.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createKey();
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="h-full bg-transparent">
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid w-full gap-4 pt-4">
|
|
||||||
{isPending || !cloudSSHKey ? (
|
|
||||||
<div className="min-h-[25vh] justify-center flex items-center gap-4">
|
|
||||||
<Loader2
|
|
||||||
className="animate-spin text-muted-foreground"
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4 text-sm text-muted-foreground">
|
|
||||||
<p className="text-primary text-base font-semibold">
|
|
||||||
Choose how to add SSH Keys to your server:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Radio button options */}
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<RadioGroup
|
|
||||||
value={selectedOption}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedOption(value as "manual" | "provider");
|
|
||||||
}}
|
|
||||||
className="grid gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="manual" id="manual" />
|
|
||||||
<Label
|
|
||||||
htmlFor="manual"
|
|
||||||
className="text-primary font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
Add SSH Key to Server Manually
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="provider" id="provider" />
|
|
||||||
<Label
|
|
||||||
htmlFor="provider"
|
|
||||||
className="text-primary font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
Add SSH Key when creating server in your provider
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content based on selected option */}
|
|
||||||
{selectedOption === "manual" && (
|
|
||||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
|
||||||
<span className="text-base font-semibold text-primary">
|
|
||||||
Manual Setup Instructions
|
|
||||||
</span>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
<li className="items-center flex gap-1">
|
|
||||||
1. Login to your server
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
2. When you are logged in run the following command
|
|
||||||
<div className="flex relative flex-col gap-4 w-full mt-2">
|
|
||||||
<CodeEditor
|
|
||||||
lineWrapping
|
|
||||||
language="properties"
|
|
||||||
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
|
|
||||||
readOnly
|
|
||||||
className="font-mono opacity-60"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-2 top-2"
|
|
||||||
onClick={() => {
|
|
||||||
copy(
|
|
||||||
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
|
|
||||||
);
|
|
||||||
toast.success("Copied to clipboard");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="mt-1">
|
|
||||||
3. You're done, follow the next step to insert the
|
|
||||||
details of your server.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedOption === "provider" && (
|
|
||||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
|
||||||
<span className="text-base font-semibold text-primary">
|
|
||||||
Provider Setup Instructions
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
|
||||||
<div className="flex relative flex-col gap-2 overflow-y-auto">
|
|
||||||
<div className="text-sm text-primary flex flex-row gap-2 items-center">
|
|
||||||
Copy Public Key
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="right-2 top-8"
|
|
||||||
onClick={() => {
|
|
||||||
copy(
|
|
||||||
cloudSSHKey?.publicKey || "Generate a SSH Key",
|
|
||||||
);
|
|
||||||
toast.success("SSH Copied to clipboard");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-2">
|
|
||||||
Use this public key when creating a server in your
|
|
||||||
preferred provider (Hostinger, Digital Ocean, Hetzner,
|
|
||||||
etc.)
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
|
|
||||||
target="_blank"
|
|
||||||
className="text-primary flex flex-row gap-2 mt-2"
|
|
||||||
>
|
|
||||||
View Tutorial <ExternalLinkIcon className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
type LogLine,
|
|
||||||
parseLogs,
|
|
||||||
} from "@/components/dashboard/docker/logs/utils";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { EditScript } from "../edit-script";
|
|
||||||
|
|
||||||
export const Setup = () => {
|
|
||||||
const { data: servers } = api.server.all.useQuery();
|
|
||||||
const [serverId, setServerId] = useState<string>(
|
|
||||||
servers?.[0]?.serverId || "",
|
|
||||||
);
|
|
||||||
const { data: server } = api.server.one.useQuery(
|
|
||||||
{
|
|
||||||
serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!serverId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
|
||||||
api.server.setupWithLogs.useSubscription(
|
|
||||||
{
|
|
||||||
serverId: serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isDeploying,
|
|
||||||
onData(log) {
|
|
||||||
if (!isDrawerOpen) {
|
|
||||||
setIsDrawerOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (log === "Deployment completed successfully!") {
|
|
||||||
setIsDeploying(false);
|
|
||||||
}
|
|
||||||
const parsedLogs = parseLogs(log);
|
|
||||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
console.error("Deployment logs error:", error);
|
|
||||||
setIsDeploying(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<Label>Select the server and click on setup server</Label>
|
|
||||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a server" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{servers?.map((server) => (
|
|
||||||
<SelectItem key={server.serverId} value={server.serverId}>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<CardTitle className="text-xl">Setup Server</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
To setup a server, please click on the button below.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
|
|
||||||
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
|
|
||||||
<span className="text-sm text-muted-foreground text-center">
|
|
||||||
When your server is ready, you can click on the button below, to
|
|
||||||
directly run the script we use for setup the server or directly
|
|
||||||
modify the script
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row gap-2">
|
|
||||||
<EditScript serverId={server?.serverId || ""} />
|
|
||||||
<DialogAction
|
|
||||||
title={"Setup Server?"}
|
|
||||||
type="default"
|
|
||||||
description="This will setup the server and all associated data"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsDeploying(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button>Setup Server</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DrawerLogs
|
|
||||||
isOpen={isDrawerOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDrawerOpen(false);
|
|
||||||
setFilteredLogs([]);
|
|
||||||
setIsDeploying(false);
|
|
||||||
}}
|
|
||||||
filteredLogs={filteredLogs}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { StatusRow } from "../gpu-support";
|
|
||||||
|
|
||||||
export const Verify = () => {
|
|
||||||
const { data: servers } = api.server.all.useQuery();
|
|
||||||
const [serverId, setServerId] = useState<string>(
|
|
||||||
servers?.[0]?.serverId || "",
|
|
||||||
);
|
|
||||||
const { data, refetch, error, isPending, isError } =
|
|
||||||
api.server.validate.useQuery(
|
|
||||||
{ serverId },
|
|
||||||
{
|
|
||||||
enabled: !!serverId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<Label>Select a server</Label>
|
|
||||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a server" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{servers?.map((server) => (
|
|
||||||
<SelectItem key={server.serverId} value={server.serverId}>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<PcCase className="size-5" />
|
|
||||||
<CardTitle className="text-xl">Setup Validation</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
Check if your server is ready for deployment
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
isLoading={isRefreshing}
|
|
||||||
onClick={async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
await refetch();
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 w-full">
|
|
||||||
{isError && (
|
|
||||||
<AlertBlock type="error" className="w-full">
|
|
||||||
{error.message}
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
|
|
||||||
{isPending ? (
|
|
||||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<span>Checking Server configuration</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid w-full gap-4">
|
|
||||||
<div className="border rounded-lg p-4">
|
|
||||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Shows the server configuration status
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-2.5">
|
|
||||||
<StatusRow
|
|
||||||
label="Docker Installed"
|
|
||||||
isEnabled={data?.docker?.enabled}
|
|
||||||
description={
|
|
||||||
data?.docker?.enabled
|
|
||||||
? `Installed: ${data?.docker?.version}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="RClone Installed"
|
|
||||||
isEnabled={data?.rclone?.enabled}
|
|
||||||
description={
|
|
||||||
data?.rclone?.enabled
|
|
||||||
? `Installed: ${data?.rclone?.version}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Nixpacks Installed"
|
|
||||||
isEnabled={data?.nixpacks?.enabled}
|
|
||||||
description={
|
|
||||||
data?.nixpacks?.enabled
|
|
||||||
? `Installed: ${data?.nixpacks?.version}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Buildpacks Installed"
|
|
||||||
isEnabled={data?.buildpacks?.enabled}
|
|
||||||
description={
|
|
||||||
data?.buildpacks?.enabled
|
|
||||||
? `Installed: ${data?.buildpacks?.version}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Docker Swarm Initialized"
|
|
||||||
isEnabled={data?.isSwarmInstalled}
|
|
||||||
description={
|
|
||||||
data?.isSwarmInstalled
|
|
||||||
? "Initialized"
|
|
||||||
: "Not Initialized"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Dokploy Network Created"
|
|
||||||
isEnabled={data?.isDokployNetworkInstalled}
|
|
||||||
description={
|
|
||||||
data?.isDokployNetworkInstalled
|
|
||||||
? "Created"
|
|
||||||
: "Not Created"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Main Directory Created"
|
|
||||||
isEnabled={data?.isMainDirectoryInstalled}
|
|
||||||
description={
|
|
||||||
data?.isMainDirectoryInstalled
|
|
||||||
? "Created"
|
|
||||||
: "Not Created"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+233
-413
@@ -1,441 +1,261 @@
|
|||||||
import { defineStepper } from "@stepperize/react";
|
import { defineStepper } from "@stepperize/react";
|
||||||
import {
|
import {
|
||||||
BookIcon,
|
CheckIcon,
|
||||||
Code2,
|
DatabaseIcon,
|
||||||
Database,
|
GitMergeIcon,
|
||||||
GitMerge,
|
GlobeIcon,
|
||||||
Globe,
|
UsersIcon,
|
||||||
Plug,
|
|
||||||
Puzzle,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import ConfettiExplosion from "react-confetti-explosion";
|
import ConfettiExplosion from "react-confetti-explosion";
|
||||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
import { DeployStep } from "@/components/dashboard/onboarding/steps/deploy-step";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { ProjectStep } from "@/components/dashboard/onboarding/steps/project-step";
|
||||||
|
import { ServerStep } from "@/components/dashboard/onboarding/steps/server-step";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
import { cn } from "@/lib/utils";
|
||||||
DialogContent,
|
import { api } from "@/utils/api";
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { CreateServer } from "./create-server";
|
|
||||||
import { CreateSSHKey } from "./create-ssh-key";
|
|
||||||
import { Setup } from "./setup";
|
|
||||||
import { Verify } from "./verify";
|
|
||||||
|
|
||||||
export const { useStepper, steps, Scoped } = defineStepper(
|
const { useStepper, steps, Scoped } = defineStepper(
|
||||||
{
|
{ id: "welcome", title: "Welcome" },
|
||||||
id: "requisites",
|
{ id: "project", title: "New project" },
|
||||||
title: "Requisites",
|
{ id: "server", title: "Connect server" },
|
||||||
description: "Check your requisites",
|
{ id: "deploy", title: "Ship something" },
|
||||||
},
|
{ id: "complete", title: "You're live" },
|
||||||
{
|
|
||||||
id: "create-ssh-key",
|
|
||||||
title: "SSH Key",
|
|
||||||
description: "Create your ssh key",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "connect-server",
|
|
||||||
title: "Connect",
|
|
||||||
description: "Connect",
|
|
||||||
},
|
|
||||||
{ id: "setup", title: "Setup", description: "Setup your server" },
|
|
||||||
{ id: "verify", title: "Verify", description: "Verify your server" },
|
|
||||||
{ id: "complete", title: "Complete", description: "Checkout complete" },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const WelcomeSubscription = () => {
|
const features = [
|
||||||
|
{
|
||||||
|
icon: DatabaseIcon,
|
||||||
|
title: "Databases",
|
||||||
|
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GlobeIcon,
|
||||||
|
title: "Custom domains",
|
||||||
|
description: "Attach your own domains and get automatic HTTPS.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GitMergeIcon,
|
||||||
|
title: "CI/CD",
|
||||||
|
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UsersIcon,
|
||||||
|
title: "Team collaboration",
|
||||||
|
description: "Invite teammates with fine-grained permissions.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const WelcomeIntro = ({ onNext }: { onNext: () => void }) => (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Welcome
|
||||||
|
</span>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
|
Welcome to Dokploy Cloud
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Thanks for subscribing — you're all set up. Next, connect a server so
|
||||||
|
you can start deploying.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CompleteIntro = ({ onFinish }: { onFinish: () => void }) => {
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setShowConfetti(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||||
|
{showConfetti && (
|
||||||
|
<ConfettiExplosion
|
||||||
|
duration={3000}
|
||||||
|
force={0.35}
|
||||||
|
particleCount={200}
|
||||||
|
width={1200}
|
||||||
|
zIndex={60}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
|
You're all set.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Your server is connected — here's some of what you can do next.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="flex flex-col divide-y">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div key={feature.title} className="flex gap-6 py-4 first:pt-0">
|
||||||
|
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
<feature.icon className="size-4 text-muted-foreground" />
|
||||||
|
{feature.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{feature.description}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button size="lg" className="w-fit px-8" onClick={onFinish}>
|
||||||
|
Go to dashboard
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||||
|
target="_blank"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Need help? Join our Discord →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WelcomeSubscription = () => {
|
||||||
|
const router = useRouter();
|
||||||
const stepper = useStepper();
|
const stepper = useStepper();
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const router = useRouter();
|
|
||||||
const { push } = router;
|
|
||||||
|
|
||||||
useEffect(() => {
|
// This flow can also fire for an existing customer upgrading their plan
|
||||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
// (any checkout success redirects here), not just a first-time
|
||||||
if (!confettiShown) {
|
// subscriber — so "New project" only belongs in the flow when they don't
|
||||||
setShowConfetti(true);
|
// already have one. Mirrors the isCloud-based step filtering in the main
|
||||||
localStorage.setItem("hasShownConfetti", "true");
|
// onboarding wizard.
|
||||||
}
|
const { data: projects } = api.project.all.useQuery();
|
||||||
}, [showConfetti]);
|
const hasExistingProject = (projects?.length ?? 0) > 0;
|
||||||
|
const visibleStepIds = hasExistingProject
|
||||||
|
? steps.filter((step) => step.id !== "project").map((step) => step.id)
|
||||||
|
: steps.map((step) => step.id);
|
||||||
|
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||||
|
const isLast = visibleIndex === visibleStepIds.length - 1;
|
||||||
|
const goToNextVisible = () => {
|
||||||
|
const nextId = visibleStepIds[visibleIndex + 1];
|
||||||
|
if (nextId) stepper.goTo(nextId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [projectId, setProjectId] = useState<string | undefined>();
|
||||||
|
const [environmentId, setEnvironmentId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const firstExistingProjectId = projects?.[0]?.projectId;
|
||||||
|
const { data: existingEnvironments } = api.environment.byProjectId.useQuery(
|
||||||
|
{ projectId: firstExistingProjectId ?? "" },
|
||||||
|
{ enabled: !!firstExistingProjectId && !environmentId },
|
||||||
|
);
|
||||||
|
const resolvedEnvironmentId =
|
||||||
|
environmentId ?? existingEnvironments?.[0]?.environmentId;
|
||||||
|
|
||||||
|
const close = (destination: string) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
router.push(destination);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setIsOpen(open);
|
if (!open) close("/dashboard/settings/servers");
|
||||||
if (!open) {
|
|
||||||
const { success, ...rest } = router.query;
|
|
||||||
router.replace(
|
|
||||||
{ pathname: router.pathname, query: rest },
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
shallow: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
{showConfetti ?? "Flaso"}
|
<Scoped>
|
||||||
<div className="flex justify-center items-center w-full">
|
<nav aria-label="Steps" className="flex items-center gap-2 pt-2">
|
||||||
{showConfetti && (
|
{visibleStepIds.map((id, index) => {
|
||||||
<ConfettiExplosion
|
const isDone = index < visibleIndex;
|
||||||
duration={3000}
|
const isCurrent = stepper.current.id === id;
|
||||||
force={0.3}
|
return (
|
||||||
particleSize={12}
|
<Fragment key={id}>
|
||||||
particleCount={300}
|
<button
|
||||||
className="z-9999"
|
type="button"
|
||||||
zIndex={9999}
|
disabled={!isDone}
|
||||||
width={1500}
|
onClick={() => stepper.goTo(id)}
|
||||||
/>
|
aria-current={isCurrent ? "step" : undefined}
|
||||||
)}
|
className={cn(
|
||||||
</div>
|
"flex size-7 shrink-0 items-center justify-center rounded-full font-mono text-[11px] transition-colors",
|
||||||
|
isCurrent
|
||||||
<DialogHeader>
|
? "border-2 border-primary text-primary font-semibold"
|
||||||
<DialogTitle className="text-2xl text-center">
|
: isDone
|
||||||
Welcome To Dokploy Cloud 🎉
|
? "bg-primary text-primary-foreground cursor-pointer"
|
||||||
</DialogTitle>
|
: "border border-border text-muted-foreground",
|
||||||
<DialogDescription className="text-center max-w-xl mx-auto">
|
|
||||||
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
|
|
||||||
onboard. Before you dive in, you'll need to configure your remote
|
|
||||||
server to unlock all the features we offer.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Steps</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Step {stepper.current.index + 1} of {steps.length}
|
|
||||||
</span>
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Scoped>
|
|
||||||
<nav aria-label="Checkout Steps" className="group my-4">
|
|
||||||
<ol
|
|
||||||
className="flex items-center justify-between gap-2"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
>
|
|
||||||
{stepper.all.map((step, index, array) => (
|
|
||||||
<React.Fragment key={step.id}>
|
|
||||||
<li className="flex items-center gap-4 shrink-0">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
variant={
|
|
||||||
index <= stepper.current.index ? "secondary" : "ghost"
|
|
||||||
}
|
|
||||||
aria-current={
|
|
||||||
stepper.current.id === step.id ? "step" : undefined
|
|
||||||
}
|
|
||||||
aria-posinset={index + 1}
|
|
||||||
aria-setsize={steps.length}
|
|
||||||
aria-selected={stepper.current.id === step.id}
|
|
||||||
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
|
||||||
onClick={() => stepper.goTo(step.id)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm font-medium">{step.title}</span>
|
|
||||||
</li>
|
|
||||||
{index < array.length - 1 && (
|
|
||||||
<Separator
|
|
||||||
className={`flex-1 ${
|
|
||||||
index < stepper.current.index
|
|
||||||
? "bg-primary"
|
|
||||||
: "bg-muted"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
>
|
||||||
))}
|
{isDone ? <CheckIcon className="size-3.5" /> : index + 1}
|
||||||
</ol>
|
</button>
|
||||||
</nav>
|
{index < visibleStepIds.length - 1 && (
|
||||||
{stepper.switch({
|
<span
|
||||||
requisites: () => (
|
className={cn(
|
||||||
<div className="flex flex-col gap-2 border p-4 rounded-lg">
|
"h-px flex-1",
|
||||||
<span className="text-primary text-base font-bold">
|
isDone ? "bg-primary" : "bg-border",
|
||||||
Before getting started, please follow the steps below to
|
)}
|
||||||
ensure the best experience:
|
/>
|
||||||
</span>
|
)}
|
||||||
<div>
|
</Fragment>
|
||||||
<p className="text-primary text-sm font-medium">
|
);
|
||||||
Supported Distributions:
|
|
||||||
</p>
|
|
||||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
|
||||||
<li>Ubuntu 24.04 LTS</li>
|
|
||||||
<li>Ubuntu 23.10</li>
|
|
||||||
<li>Ubuntu 22.04 LTS</li>
|
|
||||||
<li>Ubuntu 20.04 LTS</li>
|
|
||||||
<li>Ubuntu 18.04 LTS</li>
|
|
||||||
<li>Debian 12</li>
|
|
||||||
<li>Debian 11</li>
|
|
||||||
<li>Debian 10</li>
|
|
||||||
<li>Fedora 40</li>
|
|
||||||
<li>CentOS 9</li>
|
|
||||||
<li>CentOS 8</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-primary text-sm font-medium">
|
|
||||||
You will need to purchase or rent a Virtual Private Server
|
|
||||||
(VPS) to proceed, we recommend to use one of these
|
|
||||||
providers since has been heavily tested.
|
|
||||||
</p>
|
|
||||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
|
|
||||||
className="text-link underline"
|
|
||||||
>
|
|
||||||
Hostinger - Get 20% Discount
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href=" https://app.americancloud.com/register?ref=dokploy"
|
|
||||||
className="text-link underline"
|
|
||||||
>
|
|
||||||
American Cloud - Get $20 Credits
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://m.do.co/c/db24efd43f35"
|
|
||||||
className="text-link underline"
|
|
||||||
>
|
|
||||||
DigitalOcean - Get $200 Credits
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
|
|
||||||
className="text-link underline"
|
|
||||||
>
|
|
||||||
Hetzner - Get €20 Credits
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://www.vultr.com/?ref=9679828"
|
|
||||||
className="text-link underline"
|
|
||||||
>
|
|
||||||
Vultr
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://www.linode.com/es/pricing/#compute-shared"
|
|
||||||
className="text-link underline"
|
|
||||||
>
|
|
||||||
Linode
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<AlertBlock className="mt-4 px-4">
|
|
||||||
You are free to use whatever provider, but we recommend to
|
|
||||||
use one of the above, to avoid issues.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
"create-ssh-key": () => <CreateSSHKey />,
|
|
||||||
"connect-server": () => <CreateServer stepper={stepper} />,
|
|
||||||
setup: () => <Setup />,
|
|
||||||
verify: () => <Verify />,
|
|
||||||
complete: () => {
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
title: "Scalable Deployments",
|
|
||||||
description:
|
|
||||||
"Deploy and scale your applications effortlessly to handle any workload.",
|
|
||||||
icon: <Database className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Automated Backups",
|
|
||||||
description: "Protect your data with automatic backups",
|
|
||||||
icon: <Database className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Open Source Templates",
|
|
||||||
description:
|
|
||||||
"Big list of common open source templates in one-click",
|
|
||||||
icon: <Puzzle className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Custom Domains",
|
|
||||||
description:
|
|
||||||
"Link your own domains to your applications for a professional presence.",
|
|
||||||
icon: <Globe className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "CI/CD Integration",
|
|
||||||
description:
|
|
||||||
"Implement continuous integration and deployment workflows to streamline development.",
|
|
||||||
icon: <GitMerge className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Database Management",
|
|
||||||
description:
|
|
||||||
"Efficiently manage your databases with intuitive tools.",
|
|
||||||
icon: <Database className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Team Collaboration",
|
|
||||||
description:
|
|
||||||
"Collaborate with your team on shared projects with customizable permissions.",
|
|
||||||
icon: <Users className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Multi-language Support",
|
|
||||||
description:
|
|
||||||
"Deploy applications in multiple programming languages to suit your needs.",
|
|
||||||
icon: <Code2 className="text-primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "API Access",
|
|
||||||
description:
|
|
||||||
"Integrate and manage your applications via robust and well-documented APIs.",
|
|
||||||
icon: <Plug className="text-primary" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h2 className="text-lg font-semibold">You're All Set!</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Did you know you can deploy any number of applications
|
|
||||||
that your server can handle?
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Here are some of the things you can do with Dokploy
|
|
||||||
Cloud:
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
|
||||||
{features.map((feature) => (
|
|
||||||
<div
|
|
||||||
key={feature.title}
|
|
||||||
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="text-3xl mb-2">{feature.icon}</div>
|
|
||||||
<h3 className="text-lg font-medium mb-1">
|
|
||||||
{feature.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
|
||||||
<span className="text-base text-primary">
|
|
||||||
Need Help? We are here to help you.
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Join to our Discord server and we will help you.
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
|
|
||||||
<Link
|
|
||||||
href="https://discord.gg/2tBnJ3jDJc"
|
|
||||||
aria-label="Dokploy on GitHub"
|
|
||||||
target="_blank"
|
|
||||||
className="flex flex-row items-center gap-2 text-white"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
role="img"
|
|
||||||
className="h-6 w-6 fill-white"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
|
||||||
</svg>
|
|
||||||
Join Discord
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button className="rounded-full w-fit">
|
|
||||||
<Link
|
|
||||||
href="https://github.com/Dokploy/dokploy"
|
|
||||||
aria-label="Dokploy on GitHub"
|
|
||||||
target="_blank"
|
|
||||||
className="flex flex-row items-center gap-2 "
|
|
||||||
>
|
|
||||||
<GithubIcon />
|
|
||||||
Github
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="rounded-full w-fit"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="https://docs.dokploy.com/docs/core"
|
|
||||||
aria-label="Dokploy Docs"
|
|
||||||
target="_blank"
|
|
||||||
className="flex flex-row items-center gap-2 "
|
|
||||||
>
|
|
||||||
<BookIcon size={16} />
|
|
||||||
Docs
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
</Scoped>
|
</nav>
|
||||||
</div>
|
</Scoped>
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
{!stepper.isLast && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
push("/dashboard/settings/servers");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 w-full justify-end">
|
<div className="py-2">
|
||||||
<Button
|
{stepper.switch({
|
||||||
onClick={stepper.prev}
|
welcome: () => <WelcomeIntro onNext={goToNextVisible} />,
|
||||||
disabled={stepper.isFirst}
|
project: () => (
|
||||||
variant="secondary"
|
<ProjectStep
|
||||||
>
|
plainTitle
|
||||||
Back
|
onNext={(project) => {
|
||||||
</Button>
|
setProjectId(project.projectId);
|
||||||
<Button
|
setEnvironmentId(project.environmentId);
|
||||||
onClick={() => {
|
goToNextVisible();
|
||||||
if (stepper.isLast) {
|
|
||||||
setIsOpen(false);
|
|
||||||
push("/dashboard/home");
|
|
||||||
} else {
|
|
||||||
stepper.next();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{stepper.isLast ? "Complete" : "Next"}
|
),
|
||||||
</Button>
|
server: () => <ServerStep plainTitle onNext={goToNextVisible} />,
|
||||||
</div>
|
deploy: () => (
|
||||||
</div>
|
<DeployStep
|
||||||
</DialogFooter>
|
plainTitle
|
||||||
|
environmentId={resolvedEnvironmentId}
|
||||||
|
onNext={goToNextVisible}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
complete: () => (
|
||||||
|
<CompleteIntro onFinish={() => close("/dashboard/home")} />
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLast && (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => close("/dashboard/settings/servers")}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const providerLabels = {
|
|||||||
hashicorp: "HashiCorp Vault / OpenBao",
|
hashicorp: "HashiCorp Vault / OpenBao",
|
||||||
infisical: "Infisical",
|
infisical: "Infisical",
|
||||||
aws: "AWS Secrets Manager",
|
aws: "AWS Secrets Manager",
|
||||||
|
"aws-parameter-store": "AWS Parameter Store",
|
||||||
doppler: "Doppler",
|
doppler: "Doppler",
|
||||||
azure: "Azure Key Vault",
|
azure: "Azure Key Vault",
|
||||||
scaleway: "Scaleway Secret Manager",
|
scaleway: "Scaleway Secret Manager",
|
||||||
@@ -61,6 +62,7 @@ const VaultProviderSchema = z
|
|||||||
"hashicorp",
|
"hashicorp",
|
||||||
"infisical",
|
"infisical",
|
||||||
"aws",
|
"aws",
|
||||||
|
"aws-parameter-store",
|
||||||
"doppler",
|
"doppler",
|
||||||
"azure",
|
"azure",
|
||||||
"scaleway",
|
"scaleway",
|
||||||
@@ -79,6 +81,7 @@ const VaultProviderSchema = z
|
|||||||
region: z.string(),
|
region: z.string(),
|
||||||
accessKeyId: z.string(),
|
accessKeyId: z.string(),
|
||||||
secretAccessKey: z.string(),
|
secretAccessKey: z.string(),
|
||||||
|
parameterPath: z.string(),
|
||||||
serviceToken: z.string(),
|
serviceToken: z.string(),
|
||||||
awsEndpoint: z.string(),
|
awsEndpoint: z.string(),
|
||||||
vaultUri: z.string(),
|
vaultUri: z.string(),
|
||||||
@@ -136,7 +139,8 @@ const VaultProviderSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
data.providerType === "aws" &&
|
(data.providerType === "aws" ||
|
||||||
|
data.providerType === "aws-parameter-store") &&
|
||||||
data.awsEndpoint &&
|
data.awsEndpoint &&
|
||||||
!isValidUrl(data.awsEndpoint)
|
!isValidUrl(data.awsEndpoint)
|
||||||
) {
|
) {
|
||||||
@@ -146,6 +150,17 @@ const VaultProviderSchema = z
|
|||||||
path: ["awsEndpoint"],
|
path: ["awsEndpoint"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
data.providerType === "aws-parameter-store" &&
|
||||||
|
data.parameterPath &&
|
||||||
|
!data.parameterPath.trim().startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Parameter discovery path must start with /",
|
||||||
|
path: ["parameterPath"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
data.providerType === "scaleway" &&
|
data.providerType === "scaleway" &&
|
||||||
data.scalewayApiUrl &&
|
data.scalewayApiUrl &&
|
||||||
@@ -200,6 +215,11 @@ const VaultProviderSchema = z
|
|||||||
["accessKeyId", "Access Key ID is required"],
|
["accessKeyId", "Access Key ID is required"],
|
||||||
["secretAccessKey", "Secret Access Key is required"],
|
["secretAccessKey", "Secret Access Key is required"],
|
||||||
],
|
],
|
||||||
|
"aws-parameter-store": [
|
||||||
|
["region", "Region is required"],
|
||||||
|
["accessKeyId", "Access Key ID is required"],
|
||||||
|
["secretAccessKey", "Secret Access Key is required"],
|
||||||
|
],
|
||||||
doppler: [["serviceToken", "Service Token is required"]],
|
doppler: [["serviceToken", "Service Token is required"]],
|
||||||
azure: [
|
azure: [
|
||||||
["vaultUri", "Vault URI is required"],
|
["vaultUri", "Vault URI is required"],
|
||||||
@@ -265,6 +285,7 @@ const defaultValues: VaultProviderForm = {
|
|||||||
region: "",
|
region: "",
|
||||||
accessKeyId: "",
|
accessKeyId: "",
|
||||||
secretAccessKey: "",
|
secretAccessKey: "",
|
||||||
|
parameterPath: "",
|
||||||
serviceToken: "",
|
serviceToken: "",
|
||||||
awsEndpoint: "",
|
awsEndpoint: "",
|
||||||
vaultUri: "",
|
vaultUri: "",
|
||||||
@@ -313,6 +334,15 @@ const buildConfig = (data: VaultProviderForm) => {
|
|||||||
secretAccessKey: data.secretAccessKey,
|
secretAccessKey: data.secretAccessKey,
|
||||||
endpoint: data.awsEndpoint || undefined,
|
endpoint: data.awsEndpoint || undefined,
|
||||||
};
|
};
|
||||||
|
case "aws-parameter-store":
|
||||||
|
return {
|
||||||
|
providerType: "aws-parameter-store" as const,
|
||||||
|
region: data.region,
|
||||||
|
accessKeyId: data.accessKeyId,
|
||||||
|
secretAccessKey: data.secretAccessKey,
|
||||||
|
endpoint: data.awsEndpoint || undefined,
|
||||||
|
parameterPath: data.parameterPath.trim() || undefined,
|
||||||
|
};
|
||||||
case "doppler":
|
case "doppler":
|
||||||
return {
|
return {
|
||||||
providerType: "doppler" as const,
|
providerType: "doppler" as const,
|
||||||
@@ -443,12 +473,16 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
|||||||
environmentSlug: provider.config.environmentSlug,
|
environmentSlug: provider.config.environmentSlug,
|
||||||
secretPath: provider.config.secretPath,
|
secretPath: provider.config.secretPath,
|
||||||
}),
|
}),
|
||||||
...(provider.config.providerType === "aws" && {
|
...((provider.config.providerType === "aws" ||
|
||||||
|
provider.config.providerType === "aws-parameter-store") && {
|
||||||
region: provider.config.region,
|
region: provider.config.region,
|
||||||
accessKeyId: provider.config.accessKeyId,
|
accessKeyId: provider.config.accessKeyId,
|
||||||
secretAccessKey: provider.config.secretAccessKey,
|
secretAccessKey: provider.config.secretAccessKey,
|
||||||
awsEndpoint: provider.config.endpoint ?? "",
|
awsEndpoint: provider.config.endpoint ?? "",
|
||||||
}),
|
}),
|
||||||
|
...(provider.config.providerType === "aws-parameter-store" && {
|
||||||
|
parameterPath: provider.config.parameterPath ?? "",
|
||||||
|
}),
|
||||||
...(provider.config.providerType === "doppler" && {
|
...(provider.config.providerType === "doppler" && {
|
||||||
serviceToken: provider.config.serviceToken,
|
serviceToken: provider.config.serviceToken,
|
||||||
dopplerProject: provider.config.project ?? "",
|
dopplerProject: provider.config.project ?? "",
|
||||||
@@ -757,7 +791,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{providerType === "aws" && (
|
{(providerType === "aws" ||
|
||||||
|
providerType === "aws-parameter-store") && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -815,12 +850,44 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormDescription>
|
{providerType === "aws-parameter-store" && (
|
||||||
Reference format:{" "}
|
<FormField
|
||||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
control={form.control}
|
||||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
name="parameterPath"
|
||||||
secrets
|
render={({ field }) => (
|
||||||
</FormDescription>
|
<FormItem>
|
||||||
|
<FormLabel>Discovery path (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="/production/my-app" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Limits parameter browsing and autocomplete to this
|
||||||
|
hierarchy. IAM permissions remain the security
|
||||||
|
boundary.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{providerType === "aws" ? (
|
||||||
|
<FormDescription>
|
||||||
|
Reference format:{" "}
|
||||||
|
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||||
|
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
|
||||||
|
JSON secrets
|
||||||
|
</FormDescription>
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
Reference format:{" "}
|
||||||
|
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
|
||||||
|
SecureString parameters are decrypted automatically.
|
||||||
|
Resolution requires <code>ssm:GetParameters</code>.
|
||||||
|
Discovery requires <code>ssm:DescribeParameters</code>.
|
||||||
|
Customer-managed KMS keys also require{" "}
|
||||||
|
<code>kms:Decrypt</code>.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const providerLabels: Record<string, string> = {
|
|||||||
hashicorp: "HashiCorp Vault",
|
hashicorp: "HashiCorp Vault",
|
||||||
infisical: "Infisical",
|
infisical: "Infisical",
|
||||||
aws: "AWS Secrets Manager",
|
aws: "AWS Secrets Manager",
|
||||||
|
"aws-parameter-store": "AWS Parameter Store",
|
||||||
doppler: "Doppler",
|
doppler: "Doppler",
|
||||||
azure: "Azure Key Vault",
|
azure: "Azure Key Vault",
|
||||||
scaleway: "Scaleway Secret Manager",
|
scaleway: "Scaleway Secret Manager",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Stars,
|
Stars,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -14,6 +15,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -126,13 +128,13 @@ export const UpdateServer = ({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg" showCloseButton={false}>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center gap-2 mb-8">
|
||||||
<DialogTitle className="text-2xl font-semibold">
|
<DialogTitle className="text-2xl font-semibold mr-auto">
|
||||||
Web Server Update
|
Web Server Update
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{dokployVersion && (
|
{dokployVersion && (
|
||||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{dokployVersion}{" "}
|
{dokployVersion}{" "}
|
||||||
@@ -141,6 +143,12 @@ export const UpdateServer = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" className="shrink-0">
|
||||||
|
<X />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Initial state */}
|
{/* Initial state */}
|
||||||
|
|||||||
@@ -91,8 +91,32 @@ export const PorkbunIcon = ({ className }: Props) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const InfomaniakIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OvhIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const dnsProviderIcons = {
|
export const dnsProviderIcons = {
|
||||||
cloudflare: CloudflareIcon,
|
cloudflare: CloudflareIcon,
|
||||||
route53: Route53Icon,
|
route53: Route53Icon,
|
||||||
porkbun: PorkbunIcon,
|
porkbun: PorkbunIcon,
|
||||||
|
infomaniak: InfomaniakIcon,
|
||||||
|
ovh: OvhIcon,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -608,6 +608,7 @@ export const vaultProviderIcons = {
|
|||||||
hashicorp: HashicorpVaultIcon,
|
hashicorp: HashicorpVaultIcon,
|
||||||
infisical: InfisicalIcon,
|
infisical: InfisicalIcon,
|
||||||
aws: AwsIcon,
|
aws: AwsIcon,
|
||||||
|
"aws-parameter-store": AwsIcon,
|
||||||
doppler: DopplerIcon,
|
doppler: DopplerIcon,
|
||||||
azure: AzureIcon,
|
azure: AzureIcon,
|
||||||
scaleway: ScalewayIcon,
|
scaleway: ScalewayIcon,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
@@ -11,6 +13,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardLayout = ({ children, metaName }: Props) => {
|
export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { config: whitelabeling } = useWhitelabeling();
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
@@ -24,6 +27,21 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
|||||||
|
|
||||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||||
|
|
||||||
|
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
||||||
|
const shouldRedirectToOnboarding =
|
||||||
|
router.pathname !== "/dashboard/home" &&
|
||||||
|
onboardingStatus?.shouldShowOnboarding === true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldRedirectToOnboarding) {
|
||||||
|
router.replace("/dashboard/home");
|
||||||
|
}
|
||||||
|
}, [shouldRedirectToOnboarding, router]);
|
||||||
|
|
||||||
|
if (shouldRedirectToOnboarding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{metaName && (
|
{metaName && (
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -100,6 +101,7 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AppRouter } from "@/server/api/root";
|
import type { AppRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { TrialBanner } from "../dashboard/billing/trial-banner";
|
||||||
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
||||||
import { DialogAction } from "../shared/dialog-action";
|
import { DialogAction } from "../shared/dialog-action";
|
||||||
import { Logo } from "../shared/logo";
|
import { Logo } from "../shared/logo";
|
||||||
@@ -616,7 +618,7 @@ function SidebarLogo() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Organization Logo and Selector */}
|
{/* Organization Logo and Selector */}
|
||||||
<SidebarMenuItem className={"w-full"}>
|
<SidebarMenuItem className={"w-full min-w-0"}>
|
||||||
<Popover
|
<Popover
|
||||||
open={organizationSelectorOpen}
|
open={organizationSelectorOpen}
|
||||||
onOpenChange={setOrganizationSelectorOpen}
|
onOpenChange={setOrganizationSelectorOpen}
|
||||||
@@ -632,14 +634,13 @@ function SidebarLogo() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2",
|
"flex min-w-0 flex-1 items-center gap-2",
|
||||||
isCollapsed && "justify-center",
|
isCollapsed && "justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center rounded-sm border",
|
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
|
||||||
"size-6",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Logo
|
<Logo
|
||||||
@@ -652,22 +653,27 @@ function SidebarLogo() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-start",
|
"flex flex-col items-start min-w-0 flex-1",
|
||||||
isCollapsed && "hidden",
|
isCollapsed && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0 w-full">
|
||||||
<p className="text-sm font-medium leading-none">
|
<TruncateTooltip
|
||||||
{activeOrganization?.name ?? "Select Organization"}
|
text={
|
||||||
</p>
|
activeOrganization?.name ?? "Select Organization"
|
||||||
|
}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
/>
|
||||||
{haveValidLicense && (
|
{haveValidLicense && (
|
||||||
<Badge variant="blue">Enterprise</Badge>
|
<Badge variant="blue" className="shrink-0">
|
||||||
|
Enterprise
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown
|
<ChevronsUpDown
|
||||||
className={cn("ml-auto", isCollapsed && "hidden")}
|
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
|
||||||
/>
|
/>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -1224,6 +1230,7 @@ export default function Page({ children }: Props) {
|
|||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
|
{isCloud === true && <TrialBanner />}
|
||||||
{!includesProjects && (
|
{!includesProjects && (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
<div className="flex items-center justify-between w-full px-4">
|
<div className="flex items-center justify-between w-full px-4">
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ const azureMapping: ClaimMapping = {
|
|||||||
image: "",
|
image: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// id: "sub",
|
||||||
|
// email: "preferred_username",
|
||||||
|
// emailVerified: "email_verified",
|
||||||
|
// name: "name",
|
||||||
|
|
||||||
const genericMapping: ClaimMapping = {
|
const genericMapping: ClaimMapping = {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -228,10 +233,9 @@ export function RegisterOidcDialog({
|
|||||||
mapping: {
|
mapping: {
|
||||||
id: oidc?.mapping?.id ?? baseMapping.id,
|
id: oidc?.mapping?.id ?? baseMapping.id,
|
||||||
email: oidc?.mapping?.email ?? baseMapping.email,
|
email: oidc?.mapping?.email ?? baseMapping.email,
|
||||||
emailVerified:
|
emailVerified: oidc?.mapping?.emailVerified ?? "",
|
||||||
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
|
|
||||||
name: oidc?.mapping?.name ?? baseMapping.name,
|
name: oidc?.mapping?.name ?? baseMapping.name,
|
||||||
image: oidc?.mapping?.image ?? baseMapping.image,
|
image: oidc?.mapping?.image ?? "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [data, open, form]);
|
}, [data, open, form]);
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Head from "next/head";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
export function WhitelabelingProvider() {
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const faviconHref =
|
|
||||||
config?.faviconUrl ??
|
|
||||||
(resolvedTheme === "dark"
|
|
||||||
? "/icon-dark.svg"
|
|
||||||
: resolvedTheme === "light"
|
|
||||||
? "/icon-light.svg"
|
|
||||||
: "/icon.svg");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
{config?.metaTitle && <title>{config.metaTitle}</title>}
|
|
||||||
<link rel="icon" href={faviconHref} key="app-favicon" />
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
{config?.customCss && (
|
|
||||||
<style
|
|
||||||
id="whitelabeling-styles"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: config.customCss,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -47,8 +47,8 @@ const formSchema = z.object({
|
|||||||
docsUrl: safeUrlField,
|
docsUrl: safeUrlField,
|
||||||
errorPageTitle: z.string(),
|
errorPageTitle: z.string(),
|
||||||
errorPageDescription: z.string(),
|
errorPageDescription: z.string(),
|
||||||
metaTitle: z.string(),
|
|
||||||
footerText: z.string(),
|
footerText: z.string(),
|
||||||
|
ogImageUrl: safeUrlField,
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = z.infer<typeof formSchema>;
|
type FormSchema = z.infer<typeof formSchema>;
|
||||||
@@ -193,8 +193,8 @@ export function WhitelabelingSettings() {
|
|||||||
docsUrl: "",
|
docsUrl: "",
|
||||||
errorPageTitle: "",
|
errorPageTitle: "",
|
||||||
errorPageDescription: "",
|
errorPageDescription: "",
|
||||||
metaTitle: "",
|
|
||||||
footerText: "",
|
footerText: "",
|
||||||
|
ogImageUrl: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
});
|
});
|
||||||
@@ -212,8 +212,8 @@ export function WhitelabelingSettings() {
|
|||||||
docsUrl: data.docsUrl ?? "",
|
docsUrl: data.docsUrl ?? "",
|
||||||
errorPageTitle: data.errorPageTitle ?? "",
|
errorPageTitle: data.errorPageTitle ?? "",
|
||||||
errorPageDescription: data.errorPageDescription ?? "",
|
errorPageDescription: data.errorPageDescription ?? "",
|
||||||
metaTitle: data.metaTitle ?? "",
|
|
||||||
footerText: data.footerText ?? "",
|
footerText: data.footerText ?? "",
|
||||||
|
ogImageUrl: data.ogImageUrl ?? "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -242,8 +242,8 @@ export function WhitelabelingSettings() {
|
|||||||
docsUrl: values.docsUrl || null,
|
docsUrl: values.docsUrl || null,
|
||||||
errorPageTitle: values.errorPageTitle || null,
|
errorPageTitle: values.errorPageTitle || null,
|
||||||
errorPageDescription: values.errorPageDescription || null,
|
errorPageDescription: values.errorPageDescription || null,
|
||||||
metaTitle: values.metaTitle || null,
|
|
||||||
footerText: values.footerText || null,
|
footerText: values.footerText || null,
|
||||||
|
ogImageUrl: values.ogImageUrl || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -388,6 +388,27 @@ export function WhitelabelingSettings() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ogImageUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>OG Image URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/og.png"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Open Graph image used for link previews on social media
|
||||||
|
and messaging platforms. Recommended size: 1200x630px.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -441,32 +462,15 @@ export function WhitelabelingSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Metadata & Links Section */}
|
{/* Links Section */}
|
||||||
<Card className="bg-transparent">
|
<Card className="bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Metadata & Links</CardTitle>
|
<CardTitle>Links</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Customize the page title, footer text, and sidebar links.
|
Customize the footer text and sidebar links.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="metaTitle"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Page Title</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Dokploy" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Browser tab title. Defaults to "Dokploy" if empty.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="footerText"
|
name="footerText"
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Props extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TruncateTooltip = ({ text, className, ...props }: Props) => {
|
||||||
|
const textRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = textRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const checkTruncation = () => {
|
||||||
|
const truncated = element.scrollWidth > element.clientWidth;
|
||||||
|
setIsTruncated(truncated);
|
||||||
|
if (!truncated) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkTruncation();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
checkTruncation();
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<p ref={textRef} className={cn("truncate", className)} {...props}>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip
|
||||||
|
delayDuration={0}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
// Only allow opening if it's actually truncated
|
||||||
|
if (isTruncated) {
|
||||||
|
setIsOpen(open);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md max-w-[280px] break-words animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||||
|
>
|
||||||
|
<p>{text}</p>
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,13 +10,24 @@ interface DropzoneProps
|
|||||||
"value" | "onChange"
|
"value" | "onChange"
|
||||||
> {
|
> {
|
||||||
classNameWrapper?: string;
|
classNameWrapper?: string;
|
||||||
|
classNameContent?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
dropMessage: string;
|
dropMessage: string;
|
||||||
onChange: (acceptedFiles: FileList | null) => void;
|
onChange: (acceptedFiles: FileList | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||||
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
classNameWrapper,
|
||||||
|
classNameContent,
|
||||||
|
dropMessage,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// Function to handle drag over event
|
// Function to handle drag over event
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
@@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent
|
<CardContent
|
||||||
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
|
||||||
|
classNameContent,
|
||||||
|
)}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "onboardingCompletedAt" timestamp;--> statement-breakpoint
|
||||||
|
-- Backfill only: existing users shouldn't see the onboarding wizard just
|
||||||
|
-- because this column is new. Not a column-level DEFAULT — that would also
|
||||||
|
-- apply to rows inserted after this migration, and newly created users
|
||||||
|
-- (self-hosted setup, cloud signups) need onboardingCompletedAt to stay NULL
|
||||||
|
-- so the wizard still shows for them.
|
||||||
|
UPDATE "user" SET "onboardingCompletedAt" = now() WHERE "onboardingCompletedAt" IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'aws-parameter-store' BEFORE 'doppler';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "sso_provider" ADD COLUMN "domain_verified" boolean DEFAULT true NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null}'::jsonb;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1331,6 +1331,48 @@
|
|||||||
"when": 1788251861403,
|
"when": 1788251861403,
|
||||||
"tag": "0189_wooden_nextwave",
|
"tag": "0189_wooden_nextwave",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 190,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788300390648,
|
||||||
|
"tag": "0190_nappy_anita_blake",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 191,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788332224024,
|
||||||
|
"tag": "0191_cool_christian_walker",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 192,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788600069255,
|
||||||
|
"tag": "0192_light_lake",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 193,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788802431830,
|
||||||
|
"tag": "0193_chemical_the_liberteens",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 194,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788858506799,
|
||||||
|
"tag": "0194_acoustic_prima",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 195,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788867370653,
|
||||||
|
"tag": "0195_classy_whirlwind",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.30.4",
|
"version": "v0.30.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -37,7 +37,8 @@
|
|||||||
"docker:push:canary": "./docker/push.sh canary",
|
"docker:push:canary": "./docker/push.sh canary",
|
||||||
"version": "echo $(node -p \"require('./package.json').version\")",
|
"version": "echo $(node -p \"require('./package.json').version\")",
|
||||||
"test": "vitest --config __test__/vitest.config.ts",
|
"test": "vitest --config __test__/vitest.config.ts",
|
||||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts",
|
||||||
|
"reset-onboarding": "tsx -r dotenv/config scripts/reset-onboarding.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.44",
|
"@ai-sdk/anthropic": "^3.0.44",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||||
|
"@aws-sdk/client-ssm": "3.1108.0",
|
||||||
"@better-auth/api-key": "1.6.23",
|
"@better-auth/api-key": "1.6.23",
|
||||||
"@better-auth/passkey": "1.6.23",
|
"@better-auth/passkey": "1.6.23",
|
||||||
"@better-auth/scim": "1.6.23",
|
"@better-auth/scim": "1.6.23",
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import "@/styles/globals.css";
|
|||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Head from "next/head";
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import NextTopLoader from "nextjs-toploader";
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||||
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
|
||||||
import { Analytics } from "@/components/shared/analytics";
|
import { Analytics } from "@/components/shared/analytics";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -40,9 +38,6 @@ const MyApp = ({
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
<Head>
|
|
||||||
<title>Dokploy</title>
|
|
||||||
</Head>
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
@@ -52,7 +47,6 @@ const MyApp = ({
|
|||||||
forcedTheme={Component.theme}
|
forcedTheme={Component.theme}
|
||||||
>
|
>
|
||||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||||
<WhitelabelingProvider />
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
<SearchCommand />
|
<SearchCommand />
|
||||||
|
|||||||
@@ -1,10 +1,56 @@
|
|||||||
import { Head, Html, Main, NextScript } from "next/document";
|
import { getPublicWhitelabelingConfig } from "@dokploy/server";
|
||||||
|
import NextDocument, {
|
||||||
|
type DocumentContext,
|
||||||
|
type DocumentInitialProps,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Main,
|
||||||
|
NextScript,
|
||||||
|
} from "next/document";
|
||||||
|
|
||||||
|
interface WhitelabelingDocumentProps {
|
||||||
|
appName: string | null;
|
||||||
|
appDescription: string | null;
|
||||||
|
ogImageUrl: string | null;
|
||||||
|
faviconHref: string | null;
|
||||||
|
customCss: string | null;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Document({
|
||||||
|
appName,
|
||||||
|
appDescription,
|
||||||
|
ogImageUrl,
|
||||||
|
faviconHref,
|
||||||
|
customCss,
|
||||||
|
baseUrl,
|
||||||
|
}: WhitelabelingDocumentProps) {
|
||||||
|
const title = appName || "Dokploy";
|
||||||
|
const description =
|
||||||
|
appDescription || "The Open Source alternative to Netlify, Vercel, Heroku.";
|
||||||
|
|
||||||
|
let ogImage = ogImageUrl || "/og.png";
|
||||||
|
if (ogImage.startsWith("/")) {
|
||||||
|
ogImage = `${baseUrl}${ogImage}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Document() {
|
|
||||||
return (
|
return (
|
||||||
<Html lang="en" className="font-sans">
|
<Html lang="en" className="font-sans">
|
||||||
<Head>
|
<Head>
|
||||||
<link rel="icon" href="/icon.svg" />
|
{/* Rendered on the server so the correct branding is present on first
|
||||||
|
paint (and for social scrapers), avoiding a flash of / fallback to
|
||||||
|
the default Dokploy branding. */}
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:image" content={ogImage} />
|
||||||
|
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||||
|
{customCss && (
|
||||||
|
<style
|
||||||
|
id="whitelabeling-styles"
|
||||||
|
dangerouslySetInnerHTML={{ __html: customCss }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Head>
|
</Head>
|
||||||
<body className="flex h-full w-full flex-col font-sans">
|
<body className="flex h-full w-full flex-col font-sans">
|
||||||
<Main />
|
<Main />
|
||||||
@@ -13,3 +59,83 @@ export default function Document() {
|
|||||||
</Html>
|
</Html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __SETTINGS_CACHE: {
|
||||||
|
data: {
|
||||||
|
appName: string | null;
|
||||||
|
appDescription: string | null;
|
||||||
|
ogImageUrl: string | null;
|
||||||
|
faviconHref: string | null;
|
||||||
|
customCss: string | null;
|
||||||
|
};
|
||||||
|
expiresAt: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Document.getInitialProps = async (
|
||||||
|
ctx: DocumentContext,
|
||||||
|
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
|
||||||
|
const initialProps = await NextDocument.getInitialProps(ctx);
|
||||||
|
|
||||||
|
let appName: string | null = null;
|
||||||
|
let appDescription: string | null = null;
|
||||||
|
let ogImageUrl: string | null = null;
|
||||||
|
let faviconHref: string | null = null;
|
||||||
|
let customCss: string | null = null;
|
||||||
|
|
||||||
|
const host = ctx.req?.headers?.host || "localhost:3000";
|
||||||
|
const protocol = ctx.req?.headers?.["x-forwarded-proto"] || "http";
|
||||||
|
const baseUrl = `${protocol}://${host}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
globalThis.__SETTINGS_CACHE &&
|
||||||
|
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
|
||||||
|
globalThis.__SETTINGS_CACHE.data
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...initialProps,
|
||||||
|
...globalThis.__SETTINGS_CACHE.data,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getPublicWhitelabelingConfig();
|
||||||
|
if (config) {
|
||||||
|
appName = config.appName;
|
||||||
|
appDescription = config.appDescription;
|
||||||
|
ogImageUrl = config.ogImageUrl;
|
||||||
|
// Remove any </style> tags to prevent XSS breakout
|
||||||
|
customCss = config.customCss
|
||||||
|
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
|
||||||
|
: null;
|
||||||
|
faviconHref = config.faviconUrl || null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to defaults if settings can't be read (e.g. DB not ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__SETTINGS_CACHE = {
|
||||||
|
data: {
|
||||||
|
appName,
|
||||||
|
appDescription,
|
||||||
|
ogImageUrl,
|
||||||
|
faviconHref,
|
||||||
|
customCss,
|
||||||
|
},
|
||||||
|
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...initialProps,
|
||||||
|
appName,
|
||||||
|
appDescription,
|
||||||
|
ogImageUrl,
|
||||||
|
faviconHref,
|
||||||
|
customCss,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,12 +2,47 @@ import { validateRequest } from "@dokploy/server/lib/auth";
|
|||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowHome } from "@/components/dashboard/home/show-home";
|
import { ShowHome } from "@/components/dashboard/home/show-home";
|
||||||
|
import {
|
||||||
|
clearOnboardingActive,
|
||||||
|
isOnboardingActive,
|
||||||
|
markOnboardingActive,
|
||||||
|
} from "@/components/dashboard/onboarding/onboarding-lock";
|
||||||
|
import { OnboardingWizard } from "@/components/dashboard/onboarding/onboarding-wizard";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
const { data } = api.project.onboardingStatus.useQuery();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: completeOnboarding } =
|
||||||
|
api.project.completeOnboarding.useMutation();
|
||||||
|
const [showWizard, setShowWizard] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showWizard === null && data) {
|
||||||
|
const show = isOnboardingActive() || data.shouldShowOnboarding;
|
||||||
|
setShowWizard(show);
|
||||||
|
if (show) markOnboardingActive();
|
||||||
|
}
|
||||||
|
}, [data, showWizard]);
|
||||||
|
|
||||||
|
if (showWizard) {
|
||||||
|
return (
|
||||||
|
<OnboardingWizard
|
||||||
|
onClose={async () => {
|
||||||
|
await completeOnboarding();
|
||||||
|
await utils.project.onboardingStatus.invalidate();
|
||||||
|
clearOnboardingActive();
|
||||||
|
setShowWizard(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <ShowHome />;
|
return <ShowHome />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,6 +79,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
await helpers.settings.isCloud.prefetch();
|
await helpers.settings.isCloud.prefetch();
|
||||||
await helpers.user.get.prefetch();
|
await helpers.user.get.prefetch();
|
||||||
|
await helpers.project.onboardingStatus.prefetch();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -1,31 +1,14 @@
|
|||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowProjects } from "@/components/dashboard/projects/show";
|
import { ShowProjects } from "@/components/dashboard/projects/show";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const ShowWelcomeDokploy = dynamic(
|
|
||||||
() =>
|
|
||||||
import("@/components/dashboard/settings/billing/show-welcome-dokploy").then(
|
|
||||||
(mod) => mod.ShowWelcomeDokploy,
|
|
||||||
),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
return <ShowProjects />;
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isCloud && <ShowWelcomeDokploy />}
|
|
||||||
|
|
||||||
<ShowProjects />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
import { Fingerprint } from "lucide-react";
|
import { Fingerprint } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
@@ -46,6 +47,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 { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
@@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
|
||||||
if (data?.twoFactorRedirect as boolean) {
|
|
||||||
setTwoFactorCode("");
|
setTwoFactorCode("");
|
||||||
setIsTwoFactor(true);
|
setIsTwoFactor(true);
|
||||||
toast.info("Please enter your 2FA code");
|
toast.info("Please enter your 2FA code");
|
||||||
@@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => {
|
|||||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||||
};
|
};
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
const helpers = generateServerSideHelper(appRouter, context);
|
||||||
|
// Prefetch the public branding so the login/onboarding logo and app name
|
||||||
|
// render correctly on the server (no flash of default branding).
|
||||||
|
await helpers.whitelabeling.getPublic.prefetch();
|
||||||
|
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
try {
|
try {
|
||||||
const { user } = await validateRequest(context.req);
|
const { user } = await validateRequest(context.req);
|
||||||
@@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
IS_CLOUD: IS_CLOUD,
|
IS_CLOUD: IS_CLOUD,
|
||||||
enforceSSO: false,
|
enforceSSO: false,
|
||||||
},
|
},
|
||||||
@@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
hasAdmin,
|
hasAdmin,
|
||||||
enforceSSO: webServerSettings?.enforceSSO ?? false,
|
enforceSSO: webServerSettings?.enforceSSO ?? false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { pushToDataLayer } from "@/lib/analytics";
|
import { pushToDataLayer } from "@/lib/analytics";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
@@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => {
|
|||||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||||
};
|
};
|
||||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
|
const helpers = generateServerSideHelper(appRouter, ctx);
|
||||||
|
// Prefetch the public branding so the invitation logo and app name render
|
||||||
|
// correctly on the server (no flash of default branding).
|
||||||
|
await helpers.whitelabeling.getPublic.prefetch();
|
||||||
|
|
||||||
const { query } = ctx;
|
const { query } = ctx;
|
||||||
|
|
||||||
const token = query.token;
|
const token = query.token;
|
||||||
@@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
if (invitation.userAlreadyExists) {
|
if (invitation.userAlreadyExists) {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
isCloud: IS_CLOUD,
|
isCloud: IS_CLOUD,
|
||||||
token: token,
|
token: token,
|
||||||
invitation: invitation,
|
invitation: invitation,
|
||||||
@@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
isCloud: IS_CLOUD,
|
isCloud: IS_CLOUD,
|
||||||
token: token,
|
token: token,
|
||||||
invitation: invitation,
|
invitation: invitation,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { pushToDataLayer } from "@/lib/analytics";
|
import { pushToDataLayer } from "@/lib/analytics";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
@@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
const helpers = generateServerSideHelper(appRouter, context);
|
||||||
|
// Prefetch the public branding so the onboarding logo and app name render
|
||||||
|
// correctly on the server (no flash of default branding).
|
||||||
|
await helpers.whitelabeling.getPublic.prefetch();
|
||||||
|
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
const { user } = await validateRequest(context.req);
|
const { user } = await validateRequest(context.req);
|
||||||
|
|
||||||
@@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
isCloud: true,
|
isCloud: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
isCloud: false,
|
isCloud: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IS_CLOUD } from "@dokploy/server";
|
import { IS_CLOUD } from "@dokploy/server";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -165,7 +167,7 @@ export default function Home() {
|
|||||||
Home.getLayout = (page: ReactElement) => {
|
Home.getLayout = (page: ReactElement) => {
|
||||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||||
};
|
};
|
||||||
export async function getServerSideProps(_context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
@@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const helpers = generateServerSideHelper(appRouter, context);
|
||||||
|
// Prefetch the public branding so the logo and app name render
|
||||||
|
// correctly on the server (no flash of default branding).
|
||||||
|
await helpers.whitelabeling.getPublic.prefetch();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {},
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,119 @@
|
|||||||
|
import { findUserById, updateUser } from "@dokploy/server";
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { member, projects, server, user } from "@dokploy/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only utility: resets a test account back to "never onboarded" so the
|
||||||
|
* onboarding wizard shows up again (see server/api/routers/project.ts
|
||||||
|
* `onboardingStatus` for the exact gate). Not part of the build — never
|
||||||
|
* ships in dist, this is strictly a local testing tool.
|
||||||
|
*
|
||||||
|
* Usage: pnpm reset-onboarding <email>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|
||||||
|
type StripeSubscription = { id: string; status: string };
|
||||||
|
|
||||||
|
const cancelActiveSubscriptions = async (stripeCustomerId: string) => {
|
||||||
|
if (!STRIPE_SECRET_KEY) {
|
||||||
|
console.log(" (STRIPE_SECRET_KEY not set, skipping subscription cleanup)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRes = await fetch(
|
||||||
|
`https://api.stripe.com/v1/subscriptions?customer=${stripeCustomerId}&status=all`,
|
||||||
|
{ headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` } },
|
||||||
|
);
|
||||||
|
const listBody = (await listRes.json()) as { data?: StripeSubscription[] };
|
||||||
|
const cancelable = (listBody.data ?? []).filter(
|
||||||
|
(sub) => sub.status !== "canceled",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelable.length === 0) {
|
||||||
|
console.log(" (no active/trialing subscriptions found)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub of cancelable) {
|
||||||
|
const cancelRes = await fetch(
|
||||||
|
`https://api.stripe.com/v1/subscriptions/${sub.id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cancelled = (await cancelRes.json()) as StripeSubscription;
|
||||||
|
console.log(` - ${cancelled.id} -> ${cancelled.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const email = process.argv[2]?.trim().toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
console.log("Usage: pnpm reset-onboarding <email>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.email, email),
|
||||||
|
});
|
||||||
|
if (!foundUser) {
|
||||||
|
console.log(`User not found for email: ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Resetting onboarding for ${email} (${foundUser.id})`);
|
||||||
|
|
||||||
|
if (foundUser.stripeCustomerId) {
|
||||||
|
console.log(
|
||||||
|
`Cancelling Stripe subscriptions for ${foundUser.stripeCustomerId}...`,
|
||||||
|
);
|
||||||
|
await cancelActiveSubscriptions(foundUser.stripeCustomerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await db.query.member.findMany({
|
||||||
|
where: eq(member.userId, foundUser.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const membership of memberships) {
|
||||||
|
const deletedProjects = await db
|
||||||
|
.delete(projects)
|
||||||
|
.where(eq(projects.organizationId, membership.organizationId))
|
||||||
|
.returning({ id: projects.projectId, name: projects.name });
|
||||||
|
for (const project of deletedProjects) {
|
||||||
|
console.log(` - deleted project "${project.name}" (${project.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedServers = await db
|
||||||
|
.delete(server)
|
||||||
|
.where(eq(server.organizationId, membership.organizationId))
|
||||||
|
.returning({ id: server.serverId, name: server.name });
|
||||||
|
for (const deletedServer of deletedServers) {
|
||||||
|
console.log(
|
||||||
|
` - deleted server "${deletedServer.name}" (${deletedServer.id})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(foundUser.id, {
|
||||||
|
onboardingCompletedAt: null,
|
||||||
|
stripeCustomerId: null,
|
||||||
|
// startFreeTrial sets these directly on the user row (not via webhook),
|
||||||
|
// so cancelling the subscription above doesn't clear them on its own.
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
serversQuantity: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch to confirm and print the final state.
|
||||||
|
const finalUser = await findUserById(foundUser.id);
|
||||||
|
console.log("\nDone. Final state:");
|
||||||
|
console.log(` onboardingCompletedAt: ${finalUser.onboardingCompletedAt}`);
|
||||||
|
console.log(` stripeCustomerId: ${finalUser.stripeCustomerId}`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error("Error resetting onboarding", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
clearOldDeployments,
|
clearOldDeployments,
|
||||||
createApplication,
|
createApplication,
|
||||||
|
createDomain,
|
||||||
deleteAllMiddlewares,
|
deleteAllMiddlewares,
|
||||||
findApplicationById,
|
findApplicationById,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findPreviewDeploymentsByApplicationId,
|
findPreviewDeploymentsByApplicationId,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
generateTraefikMeDomain,
|
||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getApplicationStats,
|
getApplicationStats,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
@@ -138,6 +140,118 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
deployNginxQuickstart: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
environmentId: z.string().min(1),
|
||||||
|
serverId: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const environment = await findEnvironmentById(input.environmentId);
|
||||||
|
const project = await findProjectById(environment.projectId);
|
||||||
|
|
||||||
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
|
if (project.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD && !input.serverId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "You need to use a server to create an application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.serverId) {
|
||||||
|
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
if (!accessibleIds.has(input.serverId)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this server",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = nanoid(6).toLowerCase();
|
||||||
|
const newApplication = await createApplication({
|
||||||
|
name: "Hello World",
|
||||||
|
appName: `hello-world-${suffix}`,
|
||||||
|
description: "Nginx demo app created by the onboarding wizard",
|
||||||
|
environmentId: input.environmentId,
|
||||||
|
serverId: input.serverId,
|
||||||
|
sourceType: "docker",
|
||||||
|
});
|
||||||
|
|
||||||
|
await addNewService(ctx, newApplication.applicationId);
|
||||||
|
|
||||||
|
await updateApplication(newApplication.applicationId, {
|
||||||
|
dockerImage: "nginxdemos/hello",
|
||||||
|
sourceType: "docker",
|
||||||
|
applicationStatus: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = await generateTraefikMeDomain(
|
||||||
|
newApplication.appName,
|
||||||
|
ctx.user.ownerId,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const domain = await createDomain({
|
||||||
|
host,
|
||||||
|
port: 80,
|
||||||
|
https: false,
|
||||||
|
applicationId: newApplication.applicationId,
|
||||||
|
domainType: "application",
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "create",
|
||||||
|
resourceType: "service",
|
||||||
|
resourceId: newApplication.applicationId,
|
||||||
|
resourceName: newApplication.appName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: newApplication.applicationId,
|
||||||
|
titleLog: "Onboarding quickstart deployment",
|
||||||
|
descriptionLog: "",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: !!input.serverId,
|
||||||
|
serverId: input.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && input.serverId) {
|
||||||
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{ removeOnComplete: true, removeOnFail: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "deploy",
|
||||||
|
resourceType: "application",
|
||||||
|
resourceId: newApplication.applicationId,
|
||||||
|
resourceName: newApplication.appName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
applicationId: newApplication.applicationId,
|
||||||
|
domainUrl: `http://${domain.host}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
one: protectedProcedure
|
one: protectedProcedure
|
||||||
.input(apiFindOneApplication)
|
.input(apiFindOneApplication)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -428,6 +428,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
descriptionLog: input.description || "",
|
descriptionLog: input.description || "",
|
||||||
server: !!compose.serverId,
|
server: !!compose.serverId,
|
||||||
serverId: compose.serverId ?? undefined,
|
serverId: compose.serverId ?? undefined,
|
||||||
|
freshVolumes: input.freshVolumes,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (IS_CLOUD && compose.serverId) {
|
if (IS_CLOUD && compose.serverId) {
|
||||||
@@ -477,6 +478,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
descriptionLog: input.description || "",
|
descriptionLog: input.description || "",
|
||||||
server: !!compose.serverId,
|
server: !!compose.serverId,
|
||||||
serverId: compose.serverId ?? undefined,
|
serverId: compose.serverId ?? undefined,
|
||||||
|
freshVolumes: input.freshVolumes,
|
||||||
};
|
};
|
||||||
if (IS_CLOUD && compose.serverId) {
|
if (IS_CLOUD && compose.serverId) {
|
||||||
deploy(jobData).catch((error) => {
|
deploy(jobData).catch((error) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string().min(1),
|
||||||
logo: z.string().optional(),
|
logo: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -130,7 +130,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
name: z.string(),
|
name: z.string().min(1),
|
||||||
logo: z.string().optional(),
|
logo: z.string().optional(),
|
||||||
defaultRole: z.string().min(1).nullable().optional(),
|
defaultRole: z.string().min(1).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
findUserById,
|
findUserById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
updateProjectById,
|
updateProjectById,
|
||||||
|
updateUser,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +66,7 @@ import {
|
|||||||
projects,
|
projects,
|
||||||
redis,
|
redis,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { getBillingStatus } from "@/server/utils/billing";
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -654,6 +656,35 @@ export const projectRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
onboardingStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const projectCountRows = await db
|
||||||
|
.select({ projectCount: sql<number>`count(*)::int` })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.organizationId, ctx.session.activeOrganizationId));
|
||||||
|
const projectCount = projectCountRows[0]?.projectCount ?? 0;
|
||||||
|
|
||||||
|
const billingStatus = await getBillingStatus(ctx.user.ownerId);
|
||||||
|
const currentUser = await findUserById(ctx.user.id);
|
||||||
|
|
||||||
|
const isOwner = ctx.user.role === "owner";
|
||||||
|
const billingGatePassed = IS_CLOUD ? !billingStatus.hasActiveAccess : true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldShowOnboarding:
|
||||||
|
isOwner &&
|
||||||
|
!currentUser.onboardingCompletedAt &&
|
||||||
|
projectCount === 0 &&
|
||||||
|
billingGatePassed,
|
||||||
|
projectCount,
|
||||||
|
...billingStatus,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
completeOnboarding: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
await updateUser(ctx.user.id, { onboardingCompletedAt: new Date() });
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
search: protectedProcedure
|
search: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../../trpc";
|
} from "../../trpc";
|
||||||
|
|
||||||
|
/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */
|
||||||
|
function clearBrandingSSRCache() {
|
||||||
|
globalThis.__SETTINGS_CACHE = null;
|
||||||
|
}
|
||||||
|
|
||||||
export const whitelabelingRouter = createTRPCRouter({
|
export const whitelabelingRouter = createTRPCRouter({
|
||||||
get: protectedProcedure.query(async ({ ctx }) => {
|
get: protectedProcedure.query(async ({ ctx }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
@@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({
|
|||||||
whitelabelingConfig: input.whitelabelingConfig,
|
whitelabelingConfig: input.whitelabelingConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear the cache so Next.js SSR applies changes immediately
|
||||||
|
clearBrandingSSRCache();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -77,11 +85,14 @@ export const whitelabelingRouter = createTRPCRouter({
|
|||||||
docsUrl: null,
|
docsUrl: null,
|
||||||
errorPageTitle: null,
|
errorPageTitle: null,
|
||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
metaTitle: null,
|
ogImageUrl: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear the cache so Next.js SSR applies changes immediately
|
||||||
|
clearBrandingSSRCache();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,15 @@ export const serverRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
ctx.session.activeOrganizationId,
|
ctx.session.activeOrganizationId,
|
||||||
);
|
);
|
||||||
await applyDockerCleanupSchedule(
|
try {
|
||||||
project.serverId,
|
await applyDockerCleanupSchedule(
|
||||||
ctx.session.activeOrganizationId,
|
project.serverId,
|
||||||
input.enableDockerCleanup,
|
ctx.session.activeOrganizationId,
|
||||||
);
|
input.enableDockerCleanup,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to schedule docker cleanup:", error);
|
||||||
|
}
|
||||||
await audit(ctx, {
|
await audit(ctx, {
|
||||||
action: "create",
|
action: "create",
|
||||||
resourceType: "server",
|
resourceType: "server",
|
||||||
@@ -81,6 +85,9 @@ export const serverRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return project;
|
return project;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error creating the server",
|
message: "Error creating the server",
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
import {
|
||||||
|
getBillingStatus,
|
||||||
|
getCurrentPlan as getCurrentPlanForOrganization,
|
||||||
|
getStripeClient,
|
||||||
|
TRIAL_DURATION_DAYS,
|
||||||
|
TRIAL_SERVER_LIMIT,
|
||||||
|
} from "@/server/utils/billing";
|
||||||
import {
|
import {
|
||||||
type BillingTier,
|
type BillingTier,
|
||||||
getStripeItems,
|
getStripeItems,
|
||||||
@@ -35,6 +41,77 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getBillingStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return getBillingStatus(ctx.user.ownerId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
startFreeTrial: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "This feature is only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HOBBY_PRICE_MONTHLY_ID) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Trials are not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
const billingStatus = await getBillingStatus(owner.id);
|
||||||
|
|
||||||
|
if (billingStatus.hasActiveAccess) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "You already have an active plan or trial",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingStatus.hasUsedTrial) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "You have already used your free trial",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
|
let stripeCustomerId = owner.stripeCustomerId;
|
||||||
|
if (stripeCustomerId) {
|
||||||
|
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||||
|
if (customer.deleted) {
|
||||||
|
stripeCustomerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
const customer = await stripe.customers.create({ email: owner.email });
|
||||||
|
stripeCustomerId = customer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }],
|
||||||
|
trial_period_days: TRIAL_DURATION_DAYS,
|
||||||
|
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
|
||||||
|
metadata: { source: "onboarding_trial", adminId: owner.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateUser(owner.id, {
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId: subscription.id,
|
||||||
|
serversQuantity: TRIAL_SERVER_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
trialEndsAt: subscription.trial_end
|
||||||
|
? new Date(subscription.trial_end * 1000)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
const stripeCustomerId = user.stripeCustomerId;
|
const stripeCustomerId = user.stripeCustomerId;
|
||||||
@@ -256,7 +333,10 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
{ expand: ["items.data.price"] },
|
{ expand: ["items.data.price"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subscription.status !== "active") {
|
if (
|
||||||
|
subscription.status !== "active" &&
|
||||||
|
subscription.status !== "trialing"
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Subscription is not active",
|
message: "Subscription is not active",
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ export const processDeploymentJob = async (job: InMemoryJob) => {
|
|||||||
composeId: job.data.composeId,
|
composeId: job.data.composeId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
freshVolumes: job.data.freshVolumes,
|
||||||
});
|
});
|
||||||
} else if (job.data.type === "redeploy") {
|
} else if (job.data.type === "redeploy") {
|
||||||
await rebuildCompose({
|
await rebuildCompose({
|
||||||
composeId: job.data.composeId,
|
composeId: job.data.composeId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
freshVolumes: job.data.freshVolumes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (job.data.applicationType === "application-preview") {
|
} else if (job.data.applicationType === "application-preview") {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type DeployJob =
|
|||||||
type: "deploy" | "redeploy";
|
type: "deploy" | "redeploy";
|
||||||
applicationType: "compose";
|
applicationType: "compose";
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
freshVolumes?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
|
|||||||
@@ -11,28 +11,12 @@ import {
|
|||||||
|
|
||||||
export type BillingPlan = "legacy" | "hobby" | "startup";
|
export type BillingPlan = "legacy" | "hobby" | "startup";
|
||||||
|
|
||||||
export const getCurrentPlanForUser = async (
|
export const getStripeClient = () =>
|
||||||
userId: string,
|
new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
): Promise<BillingPlan | null> => {
|
|
||||||
if (!IS_CLOUD) return null;
|
|
||||||
|
|
||||||
const owner = await findUserById(userId);
|
|
||||||
if (!owner?.stripeCustomerId) return null;
|
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
||||||
apiVersion: "2024-09-30.acacia",
|
apiVersion: "2024-09-30.acacia",
|
||||||
});
|
});
|
||||||
const subscriptions = await stripe.subscriptions.list({
|
|
||||||
customer: owner.stripeCustomerId,
|
|
||||||
status: "active",
|
|
||||||
expand: ["data.items.data.price"],
|
|
||||||
});
|
|
||||||
if (subscriptions.data.length === 0) return null;
|
|
||||||
|
|
||||||
const priceIds = subscriptions.data.flatMap((sub) =>
|
|
||||||
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
|
||||||
if (
|
if (
|
||||||
priceIds.some(
|
priceIds.some(
|
||||||
(id) =>
|
(id) =>
|
||||||
@@ -52,10 +36,36 @@ export const getCurrentPlanForUser = async (
|
|||||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||||
return "legacy";
|
return "legacy";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCurrentPlanForUser = async (
|
||||||
|
userId: string,
|
||||||
|
): Promise<BillingPlan | null> => {
|
||||||
|
if (!IS_CLOUD) return null;
|
||||||
|
|
||||||
|
const owner = await findUserById(userId);
|
||||||
|
if (!owner?.stripeCustomerId) return null;
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: owner.stripeCustomerId,
|
||||||
|
status: "all",
|
||||||
|
expand: ["data.items.data.price"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const relevantSubs = subscriptions.data.filter(
|
||||||
|
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||||
|
);
|
||||||
|
if (relevantSubs.length === 0) return null;
|
||||||
|
|
||||||
|
const priceIds = relevantSubs.flatMap((sub) =>
|
||||||
|
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return planFromPriceIds(priceIds);
|
||||||
|
};
|
||||||
|
|
||||||
export const getCurrentPlan = async (
|
export const getCurrentPlan = async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<BillingPlan | null> => {
|
): Promise<BillingPlan | null> => {
|
||||||
@@ -66,3 +76,79 @@ export const getCurrentPlan = async (
|
|||||||
|
|
||||||
return getCurrentPlanForUser(ownerId);
|
return getCurrentPlanForUser(ownerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TRIAL_DURATION_DAYS = 7;
|
||||||
|
export const TRIAL_SERVER_LIMIT = 1;
|
||||||
|
|
||||||
|
export interface BillingStatus {
|
||||||
|
plan: BillingPlan | null;
|
||||||
|
isOnTrial: boolean;
|
||||||
|
trialEndsAt: Date | null;
|
||||||
|
trialDaysRemaining: number | null;
|
||||||
|
hasUsedTrial: boolean;
|
||||||
|
hasActiveAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBillingStatus = async (
|
||||||
|
userId: string,
|
||||||
|
): Promise<BillingStatus> => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
plan: null,
|
||||||
|
isOnTrial: false,
|
||||||
|
trialEndsAt: null,
|
||||||
|
trialDaysRemaining: null,
|
||||||
|
hasUsedTrial: false,
|
||||||
|
hasActiveAccess: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await findUserById(userId);
|
||||||
|
if (!owner?.stripeCustomerId) {
|
||||||
|
return {
|
||||||
|
plan: null,
|
||||||
|
isOnTrial: false,
|
||||||
|
trialEndsAt: null,
|
||||||
|
trialDaysRemaining: null,
|
||||||
|
hasUsedTrial: false,
|
||||||
|
hasActiveAccess: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: owner.stripeCustomerId,
|
||||||
|
status: "all",
|
||||||
|
expand: ["data.items.data.price"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const relevantSubs = subscriptions.data.filter(
|
||||||
|
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||||
|
);
|
||||||
|
const priceIds = relevantSubs.flatMap((sub) =>
|
||||||
|
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||||
|
);
|
||||||
|
const plan = planFromPriceIds(priceIds);
|
||||||
|
|
||||||
|
const trialingSub = subscriptions.data.find(
|
||||||
|
(sub) => sub.status === "trialing",
|
||||||
|
);
|
||||||
|
const trialEndsAt = trialingSub?.trial_end
|
||||||
|
? new Date(trialingSub.trial_end * 1000)
|
||||||
|
: null;
|
||||||
|
const trialDaysRemaining = trialEndsAt
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((trialEndsAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
isOnTrial: !!trialingSub,
|
||||||
|
trialEndsAt,
|
||||||
|
trialDaysRemaining,
|
||||||
|
hasUsedTrial: subscriptions.data.length > 0,
|
||||||
|
hasActiveAccess: plan !== null || !!trialingSub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import type { AppRouter } from "@/server/api/root";
|
||||||
|
|
||||||
|
export const generateServerSideHelper = (
|
||||||
|
router: AppRouter,
|
||||||
|
context: GetServerSidePropsContext<any>,
|
||||||
|
) => {
|
||||||
|
return createServerSideHelpers({
|
||||||
|
router,
|
||||||
|
ctx: {
|
||||||
|
req: context.req as any,
|
||||||
|
res: context.res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: null as any,
|
||||||
|
user: null as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const resizeImage = (file: File, maxSize: number): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
let { width, height } = img;
|
||||||
|
|
||||||
|
if (width > maxSize || height > maxSize) {
|
||||||
|
if (width > height) {
|
||||||
|
height = Math.round((height * maxSize) / width);
|
||||||
|
width = maxSize;
|
||||||
|
} else {
|
||||||
|
width = Math.round((width * maxSize) / height);
|
||||||
|
height = maxSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(event.target?.result as string);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
resolve(canvas.toDataURL("image/webp", 0.8));
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = event.target?.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
export const sanitizeSvg = (svgContent: string): string | null => {
|
||||||
|
const clean = DOMPurify.sanitize(svgContent, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!clean) return null;
|
||||||
|
|
||||||
|
// Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars)
|
||||||
|
const bytes = new TextEncoder().encode(clean);
|
||||||
|
let binString = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binString += String.fromCharCode(bytes[i]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `data:image/svg+xml;base64,${btoa(binString)}`;
|
||||||
|
};
|
||||||
@@ -135,6 +135,7 @@ export const ssoProvider = pgTable("sso_provider", {
|
|||||||
providerId: text("provider_id").notNull().unique(),
|
providerId: text("provider_id").notNull().unique(),
|
||||||
organizationId: text("organization_id"),
|
organizationId: text("organization_id"),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
|
domainVerified: boolean("domain_verified"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const twoFactor = pgTable(
|
export const twoFactor = pgTable(
|
||||||
@@ -156,6 +157,29 @@ export const twoFactor = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const passkey = pgTable(
|
||||||
|
"passkey",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
publicKey: text("public_key").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
credentialID: text("credential_id").notNull(),
|
||||||
|
counter: integer("counter").notNull(),
|
||||||
|
deviceType: text("device_type").notNull(),
|
||||||
|
backedUp: boolean("backed_up").notNull(),
|
||||||
|
transports: text("transports"),
|
||||||
|
createdAt: timestamp("created_at"),
|
||||||
|
aaguid: text("aaguid"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("passkey_userId_idx").on(table.userId),
|
||||||
|
index("passkey_credentialID_idx").on(table.credentialID),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const organization = pgTable(
|
export const organization = pgTable(
|
||||||
"organization",
|
"organization",
|
||||||
{
|
{
|
||||||
@@ -242,6 +266,7 @@ export const userRelations = relations(user, ({ many }) => ({
|
|||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
ssoProviders: many(ssoProvider),
|
ssoProviders: many(ssoProvider),
|
||||||
twoFactors: many(twoFactor),
|
twoFactors: many(twoFactor),
|
||||||
|
passkeys: many(passkey),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
}));
|
}));
|
||||||
@@ -274,6 +299,13 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [passkey.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||||
organizationRoles: many(organizationRole),
|
organizationRoles: many(organizationRole),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||||
|
"@aws-sdk/client-ssm": "3.1108.0",
|
||||||
"@better-auth/api-key": "1.6.23",
|
"@better-auth/api-key": "1.6.23",
|
||||||
"@better-auth/passkey": "1.6.23",
|
"@better-auth/passkey": "1.6.23",
|
||||||
"@better-auth/scim": "1.6.23",
|
"@better-auth/scim": "1.6.23",
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.18",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
"node-pty": "1.1.0",
|
"node-pty": "1.1.0",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
|
|||||||
@@ -237,12 +237,14 @@ export const apiDeployCompose = z.object({
|
|||||||
composeId: z.string().min(1),
|
composeId: z.string().min(1),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
freshVolumes: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiRedeployCompose = z.object({
|
export const apiRedeployCompose = z.object({
|
||||||
composeId: z.string().min(1),
|
composeId: z.string().min(1),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
freshVolumes: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiDeleteCompose = z.object({
|
export const apiDeleteCompose = z.object({
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const dnsProviderType = pgEnum("DnsProviderType", [
|
|||||||
"cloudflare",
|
"cloudflare",
|
||||||
"route53",
|
"route53",
|
||||||
"porkbun",
|
"porkbun",
|
||||||
|
"infomaniak",
|
||||||
|
"ovh",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const cloudflareDnsConfigSchema = z.object({
|
export const cloudflareDnsConfigSchema = z.object({
|
||||||
@@ -27,10 +29,35 @@ export const porkbunDnsConfigSchema = z.object({
|
|||||||
secretApiKey: z.string().trim().min(1),
|
secretApiKey: z.string().trim().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const infomaniakDnsConfigSchema = z.object({
|
||||||
|
providerType: z.literal("infomaniak"),
|
||||||
|
apiToken: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ovhApiEndpoints = [
|
||||||
|
"ovh-eu",
|
||||||
|
"ovh-ca",
|
||||||
|
"ovh-us",
|
||||||
|
"kimsufi-eu",
|
||||||
|
"kimsufi-ca",
|
||||||
|
"soyoustart-eu",
|
||||||
|
"soyoustart-ca",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ovhDnsConfigSchema = z.object({
|
||||||
|
providerType: z.literal("ovh"),
|
||||||
|
endpoint: z.enum(ovhApiEndpoints).default("ovh-eu"),
|
||||||
|
applicationKey: z.string().trim().min(1),
|
||||||
|
applicationSecret: z.string().trim().min(1),
|
||||||
|
consumerKey: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||||
cloudflareDnsConfigSchema,
|
cloudflareDnsConfigSchema,
|
||||||
route53DnsConfigSchema,
|
route53DnsConfigSchema,
|
||||||
porkbunDnsConfigSchema,
|
porkbunDnsConfigSchema,
|
||||||
|
infomaniakDnsConfigSchema,
|
||||||
|
ovhDnsConfigSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
|
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { organization } from "./account";
|
import { organization } from "./account";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
@@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
|
|||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
|
domainVerified: boolean("domain_verified").notNull().default(true),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const user = pgTable("user", {
|
|||||||
bookmarkedTemplates: text("bookmarkedTemplates")
|
bookmarkedTemplates: text("bookmarkedTemplates")
|
||||||
.array()
|
.array()
|
||||||
.default(sql`ARRAY[]::text[]`),
|
.default(sql`ARRAY[]::text[]`),
|
||||||
|
onboardingCompletedAt: timestamp("onboardingCompletedAt"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usersRelations = relations(user, ({ one, many }) => ({
|
export const usersRelations = relations(user, ({ one, many }) => ({
|
||||||
@@ -97,6 +98,7 @@ const createSchema = createInsertSchema(user, {
|
|||||||
bookmarkedTemplates: true,
|
bookmarkedTemplates: true,
|
||||||
isValidEnterpriseLicense: true,
|
isValidEnterpriseLicense: true,
|
||||||
isEnterpriseCloud: true,
|
isEnterpriseCloud: true,
|
||||||
|
onboardingCompletedAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateUserInvitation = createSchema.pick({}).extend({
|
export const apiCreateUserInvitation = createSchema.pick({}).extend({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
|
|||||||
"hashicorp",
|
"hashicorp",
|
||||||
"infisical",
|
"infisical",
|
||||||
"aws",
|
"aws",
|
||||||
|
"aws-parameter-store",
|
||||||
"doppler",
|
"doppler",
|
||||||
"azure",
|
"azure",
|
||||||
"scaleway",
|
"scaleway",
|
||||||
@@ -40,6 +41,21 @@ export const awsVaultConfigSchema = z.object({
|
|||||||
endpoint: z.string().url().optional(),
|
endpoint: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const awsParameterStoreVaultConfigSchema = z.object({
|
||||||
|
providerType: z.literal("aws-parameter-store"),
|
||||||
|
region: z.string().min(1),
|
||||||
|
accessKeyId: z.string().min(1),
|
||||||
|
secretAccessKey: z.string().min(1),
|
||||||
|
endpoint: z.string().url().optional(),
|
||||||
|
parameterPath: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((path) => path === "" || path.startsWith("/"), {
|
||||||
|
message: "Parameter discovery path must start with /",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const dopplerVaultConfigSchema = z.object({
|
export const dopplerVaultConfigSchema = z.object({
|
||||||
providerType: z.literal("doppler"),
|
providerType: z.literal("doppler"),
|
||||||
serviceToken: z.string().min(1),
|
serviceToken: z.string().min(1),
|
||||||
@@ -76,6 +92,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
|||||||
hashicorpVaultConfigSchema,
|
hashicorpVaultConfigSchema,
|
||||||
infisicalVaultConfigSchema,
|
infisicalVaultConfigSchema,
|
||||||
awsVaultConfigSchema,
|
awsVaultConfigSchema,
|
||||||
|
awsParameterStoreVaultConfigSchema,
|
||||||
dopplerVaultConfigSchema,
|
dopplerVaultConfigSchema,
|
||||||
azureVaultConfigSchema,
|
azureVaultConfigSchema,
|
||||||
scalewayVaultConfigSchema,
|
scalewayVaultConfigSchema,
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
docsUrl: string | null;
|
docsUrl: string | null;
|
||||||
errorPageTitle: string | null;
|
errorPageTitle: string | null;
|
||||||
errorPageDescription: string | null;
|
errorPageDescription: string | null;
|
||||||
metaTitle: string | null;
|
|
||||||
footerText: string | null;
|
footerText: string | null;
|
||||||
|
ogImageUrl: string | null;
|
||||||
}>()
|
}>()
|
||||||
.default({
|
.default({
|
||||||
appName: null,
|
appName: null,
|
||||||
@@ -100,8 +100,8 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
docsUrl: null,
|
docsUrl: null,
|
||||||
errorPageTitle: null,
|
errorPageTitle: null,
|
||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
metaTitle: null,
|
|
||||||
footerText: null,
|
footerText: null,
|
||||||
|
ogImageUrl: null,
|
||||||
}),
|
}),
|
||||||
// Deployment Configuration (self-hosted only)
|
// Deployment Configuration (self-hosted only)
|
||||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||||
@@ -223,8 +223,8 @@ export const whitelabelingConfigSchema = z.object({
|
|||||||
docsUrl: safeUrl,
|
docsUrl: safeUrl,
|
||||||
errorPageTitle: z.string().nullable(),
|
errorPageTitle: z.string().nullable(),
|
||||||
errorPageDescription: z.string().nullable(),
|
errorPageDescription: z.string().nullable(),
|
||||||
metaTitle: z.string().nullable(),
|
|
||||||
footerText: z.string().nullable(),
|
footerText: z.string().nullable(),
|
||||||
|
ogImageUrl: safeUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUpdateWhitelabeling = z.object({
|
export const apiUpdateWhitelabeling = z.object({
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
apiKey({ enableMetadata: true, references: "user" }),
|
apiKey({ enableMetadata: true, references: "user" }),
|
||||||
sso(),
|
sso({
|
||||||
|
trustEmailVerified: true,
|
||||||
|
domainVerification: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
twoFactor(),
|
twoFactor(),
|
||||||
passkey(),
|
passkey(),
|
||||||
organization({
|
organization({
|
||||||
|
|||||||
@@ -125,6 +125,24 @@ const createBetterAuth = () =>
|
|||||||
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
|
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
|
||||||
...(await resolveTrustedOrigins()),
|
...(await resolveTrustedOrigins()),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const isBlockedAuthPath =
|
||||||
|
ctx.path.startsWith("/sign-in/email") ||
|
||||||
|
ctx.path.startsWith("/sign-in/social") ||
|
||||||
|
ctx.path.startsWith("/sign-in/passkey") ||
|
||||||
|
ctx.path.startsWith("/sign-up/email") ||
|
||||||
|
ctx.path.startsWith("/passkey/verify-authentication") ||
|
||||||
|
ctx.path.startsWith("/passkey/generate-authenticate-options");
|
||||||
|
|
||||||
|
if (!IS_CLOUD && isBlockedAuthPath) {
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
if (settings?.enforceSSO) {
|
||||||
|
throw new APIError("FORBIDDEN", {
|
||||||
|
message:
|
||||||
|
"SSO is enforced. Direct password, social, and passkey sign-in are disabled.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user