Merge canary and regenerate the migration after Infomaniak landed
#5257 merged, so canary now carries the Infomaniak provider and its 0191_cool_christian_walker. This branch's 0191_elite_ultragirl collided on the number, and the conflicts were the usual "infomaniak and ovh added at the same spot" across the enum, the config union, the client registry, SENSITIVE_FIELDS, both providerLabels maps, the icons and the provider form — all resolved by keeping both. Regenerated as 0192_light_lake with a current timestamp rather than renaming the file, so drizzle still applies it on a database that has already run canary's 0191.
@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
|
||||
until it answers (usually ~10-15s).
|
||||
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
|
||||
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
|
||||
|
||||
54
.watch/baseline.txt
Normal file
@ -0,0 +1,54 @@
|
||||
pr5154.state=OPEN
|
||||
pr5154.mergeable=CONFLICTING
|
||||
pr5154.decision=CHANGES_REQUESTED
|
||||
pr5154.merged=no
|
||||
pr5154.closed=no
|
||||
pr5154.head=b0c7790b2
|
||||
pr5154.commits=6
|
||||
pr5154.ci=
|
||||
pr5154.labels=size:L
|
||||
pr5154.updated=2026-09-02T12:18:19Z
|
||||
pr5154.reviews=2
|
||||
pr5154.inline=1
|
||||
pr5154.comments=4
|
||||
pr5154.lastactor=mitc-gjuge
|
||||
pr5257.state=MERGED
|
||||
pr5257.mergeable=UNKNOWN
|
||||
pr5257.decision=APPROVED
|
||||
pr5257.merged=2026-09-04T21:44:12Z
|
||||
pr5257.closed=2026-09-04T21:44:12Z
|
||||
pr5257.head=ea9a466c0
|
||||
pr5257.commits=6
|
||||
pr5257.ci=Greptile Review:SUCCESS,cherry-pick:SKIPPED,format:SUCCESS,pr-check (build):SUCCESS,pr-check (test):SUCCESS,pr-check (typecheck):SUCCESS
|
||||
pr5257.labels=enhancement,requested-changes
|
||||
pr5257.updated=2026-09-04T21:44:12Z
|
||||
pr5257.reviews=6
|
||||
pr5257.inline=6
|
||||
pr5257.comments=6
|
||||
pr5257.lastactor=narcisonunez
|
||||
pr5258.state=OPEN
|
||||
pr5258.mergeable=CONFLICTING
|
||||
pr5258.decision=CHANGES_REQUESTED
|
||||
pr5258.merged=no
|
||||
pr5258.closed=no
|
||||
pr5258.head=28f71e727
|
||||
pr5258.commits=6
|
||||
pr5258.ci=Greptile Review:SUCCESS,format:SUCCESS,pr-check (build):SUCCESS,pr-check (test):SUCCESS,pr-check (typecheck):SUCCESS
|
||||
pr5258.labels=requested-changes
|
||||
pr5258.updated=2026-09-04T19:19:05Z
|
||||
pr5258.reviews=11
|
||||
pr5258.inline=12
|
||||
pr5258.comments=6
|
||||
pr5258.lastactor=narcisonunez
|
||||
issue5344.state=OPEN
|
||||
issue5344.updated=2026-09-04T10:08:24Z
|
||||
issue5344.labels=
|
||||
issue5344.comments=1
|
||||
issue5344.lastactor=linear-code[bot]
|
||||
issue5256.state=OPEN
|
||||
issue5256.updated=2026-09-01T18:41:26Z
|
||||
issue5256.labels=enhancement
|
||||
issue5256.comments=1
|
||||
issue5256.lastactor=linear-code[bot]
|
||||
canary.head=d7884c7ce
|
||||
canary.lastmigration=0191_cool_christian_walker
|
||||
12
.watch/check.sh
Executable file
@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Un seul instantané sert à la fois au diff ET à la baseline : rien ne peut être
|
||||
# absorbé silencieusement entre les deux.
|
||||
cd "$(dirname "$0")/.."
|
||||
./.watch/snapshot.sh > /tmp/watch-now.txt 2>&1
|
||||
if diff -q .watch/baseline.txt /tmp/watch-now.txt >/dev/null; then
|
||||
echo "aucun changement"
|
||||
else
|
||||
echo "=== DIFF ==="
|
||||
diff .watch/baseline.txt /tmp/watch-now.txt
|
||||
cp /tmp/watch-now.txt .watch/baseline.txt # exactement le fichier qui a servi au diff
|
||||
fi
|
||||
38
.watch/reponse-5257.md
Normal file
@ -0,0 +1,38 @@
|
||||
Yes — tested manually against a real Infomaniak account, screenshots below.
|
||||
|
||||

|
||||
*Infomaniak in the provider selector, with its icon and label.*
|
||||
|
||||

|
||||
*The five zones the account manages, with record counts.*
|
||||
|
||||

|
||||
*The records of one zone.*
|
||||
|
||||
That third one is the one worth looking at: apex records render as `olidian.io`, not `..olidian.io`, and the TXT reads `v=spf1 -all` unquoted even though Infomaniak stores it as `"v=spf1 -all"`. Those are the two bugs the live testing turned up and that `be4a5da` fixes.
|
||||
|
||||
## On the Greptile summary in the description
|
||||
|
||||
It predates the fixes. It was generated on 2026-09-01T14:25 and Greptile hasn't re-run since, so the block still names the apex alias mismatch as blocking. That was fixed the same day in `be4a5da` — `normalizeSource` on the upsert lookup, plus a parametrized test over `"."`, `""` and `"@"` — and I replied on its thread at the time. GitHub marks both of your own threads on this PR as `outdated` for the same reason: the code moved past them.
|
||||
|
||||
Your two points were fixed in `59a7484`: the plural `/1/products` endpoint with pagination, and the single-use helper inlined.
|
||||
|
||||
## On "it's not working with a real account"
|
||||
|
||||
I'd really like to know what you saw, because I can't reproduce it: which provider, which step, and the exact error message would help a lot.
|
||||
|
||||
One strong candidate, and it isn't this PR. `canary` currently breaks self-hosted instances at login. `2e2e0c8c2` added the onboarding wizard, and `pages/dashboard/home.tsx` statically imports `onboarding-wizard.tsx`, which statically imports `steps/plan-step.tsx`, which calls at module scope:
|
||||
|
||||
```ts
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||
```
|
||||
|
||||
The `isCloud` check inside the wizard only filters which steps render — the module is imported either way. Without `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` set, opening the dashboard throws:
|
||||
|
||||
```
|
||||
IntegrationError: Missing value for Stripe(): apiKey should be a string
|
||||
```
|
||||
|
||||
It hit our own instance this morning and blocked login until we set a placeholder key. Since I merged `canary` into both DNS branches on 2026-09-03, anyone checking them out inherits it — and it fires before you can reach Settings → DNS Providers. That would also explain why the same symptom appears on both PRs, which otherwise share no code.
|
||||
|
||||
Happy to open a separate issue for that if it's useful.
|
||||
72
.watch/reponse-5258.md
Normal file
@ -0,0 +1,72 @@
|
||||
Yes — tested manually against a real OVHcloud account (24 zones). Screenshots below, and the detailed results were already on this PR, though spread across review threads rather than in one place — summarising both here.
|
||||
|
||||

|
||||
*OVHcloud in the provider selector, and the corrected permission hint from `f83097b` listing the five rights.*
|
||||
|
||||

|
||||
*The 24 zones the credentials can manage.*
|
||||
|
||||

|
||||
*One zone's records: apex, CNAME, A, MX, TXT, NS — and an `SPF` record, see the note below.*
|
||||
|
||||
## What was verified against the live API
|
||||
|
||||
Full cycle through the Dokploy UI's endpoints, with **every mutation confirmed by `dig` against the zone's authoritative nameserver** rather than trusting the API's own response:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `testConnection` / `listZones` | 24 zones — request signing and the server-clock timestamp work |
|
||||
| `listRecords` | apex, subdomains and wildcards (`*.lab0.…`) mapped correctly |
|
||||
| create | new record, resolves via `dig` |
|
||||
| create again, same name and type | **same id returned** — no duplicate |
|
||||
| update | content and TTL changed, `dig` confirms |
|
||||
| type change A → CNAME | **new id** returned, i.e. the delete-and-recreate path ran; `dig` resolves the CNAME |
|
||||
| delete | `NXDOMAIN` on the authoritative nameserver, zone left with its original records |
|
||||
|
||||
Two failure paths were exercised deliberately rather than only mocked:
|
||||
|
||||
- **Rollback on a failed type change** — I forced the replacement `POST` to fail (type change to `AAAA` while keeping an IPv4 target, which OVH rejects on its own validation). The original error surfaces, the record comes back with its type, name, TTL and target intact, and `dig` resolves it again, which also proves the `/refresh` inside the restore path. Detail worth knowing: the restore recreates the record, so **OVH assigns it a new id** — that's unavoidable through this API, and losing the record seemed clearly worse.
|
||||
- **The permission problem you spotted** — I created a consumer key carrying exactly one rule, `GET /domain/zone/*`, and confirmed via `/auth/currentCredential` that was really all it had:
|
||||
|
||||
```
|
||||
GET /domain/zone -> 403 This call has not been granted
|
||||
GET /domain/zone/ -> 200 (24 zones)
|
||||
GET /domain/zone/{zone}/record -> 200
|
||||
```
|
||||
|
||||
You were right, and it was worse than a documentation gap: a token created by following the form's old hint literally could not list zones at all. Fixed in `f83097b` — the hint now lists the five rights verbatim, and a 403 on that call names the missing one instead of echoing OVH's message.
|
||||
|
||||
Two things the screenshots make visible.
|
||||
|
||||
Loading a zone's records is noticeably slower than for the other providers. That's inherent to the API — `GET /domain/zone/{zone}/record` returns ids only, so each record needs its own request. The fan-out is capped at 8 concurrent requests to stay within OVH's rate limits.
|
||||
|
||||
And the `SPF` row is worth a look: OVH still exposes record types that Dokploy's `dnsRecordTypes` doesn't include. I flagged this in the PR description as a limitation, but I described it imprecisely — I said editing one is rejected by the shared zod enum. In practice `show-dns-records.tsx` already gates the edit action on `DNS_RECORD_TYPES.includes(record.type)`, so the pencil simply isn't offered on that row while delete still is. The existing UI handles it more gracefully than I gave it credit for; the record is listed truthfully and can't be edited into an invalid state. I'll correct the description.
|
||||
|
||||
## On the Greptile summary in the description
|
||||
|
||||
It predates the fixes. It was generated on 2026-09-01T14:25 and Greptile hasn't re-run since, so the block still lists both findings as blocking. Since then, on its own threads:
|
||||
|
||||
- the failed-type-replacement rollback was fixed in `80a6baf`, and Greptile confirmed: *"This live verification addresses the concern […] no further change is needed for this comment."*
|
||||
- the multi-value RRset finding it **withdrew**: *"You're right — this is a deliberate and defensible trade-off, not an OVH-specific correctness bug […] I'll withdraw this finding; no change is needed here."*
|
||||
|
||||
GitHub marks those threads `outdated` for the same reason — the code moved past them.
|
||||
|
||||
## On "it's not working with a real account"
|
||||
|
||||
I'd really like to know what you saw, because I can't reproduce it: which step, and the exact error message would help a lot.
|
||||
|
||||
One strong candidate, and it isn't this PR. `canary` currently breaks self-hosted instances at login. `2e2e0c8c2` added the onboarding wizard, and `pages/dashboard/home.tsx` statically imports `onboarding-wizard.tsx`, which statically imports `steps/plan-step.tsx`, which calls at module scope:
|
||||
|
||||
```ts
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||
```
|
||||
|
||||
The `isCloud` check inside the wizard only filters which steps render — the module is imported either way. Without `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` set, opening the dashboard throws:
|
||||
|
||||
```
|
||||
IntegrationError: Missing value for Stripe(): apiKey should be a string
|
||||
```
|
||||
|
||||
It hit our own instance this morning and blocked login until we set a placeholder key. Since I merged `canary` into both DNS branches on 2026-09-03, anyone checking them out inherits it — and it fires before you can reach Settings → DNS Providers. That would also explain why the same symptom appears on this PR and on #5257, which otherwise share no code.
|
||||
|
||||
Happy to open a separate issue for that if it's useful.
|
||||
BIN
.watch/shots-ovh/1-providers-list.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
.watch/shots-ovh/1-providers-list.original.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
.watch/shots-ovh/2-zones.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
.watch/shots-ovh/3-records-moninfraprivee.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
.watch/shots/1-providers-list.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
.watch/shots/1-providers-list.original.jpg
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
.watch/shots/2-zones.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.watch/shots/3-records-olidian-io.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
32
.watch/snapshot.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Instantané de l'état des 3 PR Dokploy suivies + de canary.
|
||||
R=dokploy/dokploy
|
||||
for n in 5154 5257 5258; do
|
||||
gh pr view "$n" --repo "$R" --json number,state,mergeable,reviewDecision,mergedAt,closedAt,commits,statusCheckRollup,labels,updatedAt \
|
||||
--jq '"pr\(.number).state=\(.state)
|
||||
pr\(.number).mergeable=\(.mergeable)
|
||||
pr\(.number).decision=\(.reviewDecision // "none")
|
||||
pr\(.number).merged=\(.mergedAt // "no")
|
||||
pr\(.number).closed=\(.closedAt // "no")
|
||||
pr\(.number).head=\(.commits[-1].oid[0:9])
|
||||
pr\(.number).commits=\(.commits|length)
|
||||
pr\(.number).ci=\([.statusCheckRollup[]? | "\(.name // .context):\(.conclusion // .state)"] | sort | join(","))
|
||||
pr\(.number).labels=\([.labels[]?.name] | sort | join(","))
|
||||
pr\(.number).updated=\(.updatedAt)"'
|
||||
echo "pr$n.reviews=$(gh api "repos/$R/pulls/$n/reviews" --jq 'length')"
|
||||
echo "pr$n.inline=$(gh api "repos/$R/pulls/$n/comments" --jq 'length')"
|
||||
echo "pr$n.comments=$(gh api "repos/$R/issues/$n/comments" --jq 'length')"
|
||||
echo "pr$n.lastactor=$(gh api "repos/$R/issues/$n/comments" --jq '[.[].user.login] | last // "none"')"
|
||||
done
|
||||
# Issues suivies : #5344 (bug Stripe canary), #5256 (feature request OVH, se
|
||||
# fermera au merge de #5258). Les commentaires y sont invisibles côté PR.
|
||||
for i in 5344 5256; do
|
||||
gh issue view "$i" --repo "$R" --json number,state,updatedAt,labels \
|
||||
--jq '"issue\(.number).state=\(.state)
|
||||
issue\(.number).updated=\(.updatedAt)
|
||||
issue\(.number).labels=\([.labels[]?.name] | sort | join(","))"'
|
||||
echo "issue$i.comments=$(gh api "repos/$R/issues/$i/comments" --jq 'length')"
|
||||
echo "issue$i.lastactor=$(gh api "repos/$R/issues/$i/comments" --jq '[.[].user.login] | last // "none"')"
|
||||
done
|
||||
echo "canary.head=$(gh api "repos/$R/commits/canary" --jq '.sha[0:9]')"
|
||||
echo "canary.lastmigration=$(gh api "repos/$R/contents/apps/dokploy/drizzle/meta/_journal.json?ref=canary" --jq '.content' | base64 -d | python3 -c 'import json,sys; print(json.load(sys.stdin)["entries"][-1]["tag"])')"
|
||||
@ -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
|
||||
|
||||
```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
|
||||
|
||||
```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.
|
||||
|
||||
412
apps/dokploy/__test__/dns/infomaniak.test.ts
Normal file
@ -0,0 +1,412 @@
|
||||
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 instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.1.1.1", 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("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.1.1.1", 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"');
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||
export { UpdateConfigForm } from "./update-config-form";
|
||||
export { filterEmptyValues, hasValues } from "./utils";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||
import { api } from "@/utils/api";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
interface ShowIconSettingsProps {
|
||||
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) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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 { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -14,6 +16,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -24,6 +27,8 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { resizeImage } from "@/utils/image-processing";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
||||
interface Props {
|
||||
organizationId?: string;
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddOrganization({ organizationId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
export function AddOrganization({
|
||||
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 { data: organization } = api.organization.one.useQuery(
|
||||
{
|
||||
@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.reset({
|
||||
name: organization.name,
|
||||
logo: organization.logo || "",
|
||||
});
|
||||
setUploadedFileName(null);
|
||||
}
|
||||
}, [organization, form]);
|
||||
|
||||
const onSubmit = async (values: OrganizationFormValues) => {
|
||||
if (isUploading) return;
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
logo: values.logo,
|
||||
@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
})
|
||||
.then(() => {
|
||||
form.reset();
|
||||
setUploadedFileName(null);
|
||||
toast.success(
|
||||
`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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
if (!val) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
}
|
||||
setOpen(val);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{organizationId ? (
|
||||
<Button
|
||||
@ -146,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="tems-center gap-4">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
<FormItem className="items-center gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Organization name"
|
||||
@ -162,23 +274,78 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
const isDataUrl = field.value?.startsWith("data:");
|
||||
const displayValue = isDataUrl
|
||||
? uploadedFileName || "Uploaded image"
|
||||
: field.value || "";
|
||||
|
||||
return (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">
|
||||
Logo URL or Upload
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<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>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
<Button type="submit" isLoading={isPending || isUploading}>
|
||||
{organizationId ? "Update organization" : "Create organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -44,6 +44,7 @@ const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
infomaniak: "Infomaniak",
|
||||
ovh: "OVHcloud",
|
||||
} as const;
|
||||
|
||||
@ -66,7 +67,13 @@ const DnsProviderSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum(["cloudflare", "route53", "porkbun", "ovh"]),
|
||||
providerType: z.enum([
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
"infomaniak",
|
||||
"ovh",
|
||||
]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
@ -118,6 +125,11 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
apiKey: data.apiKey,
|
||||
secretApiKey: data.secretApiKey,
|
||||
};
|
||||
case "infomaniak":
|
||||
return {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
case "ovh":
|
||||
return {
|
||||
providerType: "ovh" as const,
|
||||
@ -187,6 +199,9 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
apiKey: provider.config.apiKey,
|
||||
secretApiKey: provider.config.secretApiKey,
|
||||
}),
|
||||
...(provider.config.providerType === "infomaniak" && {
|
||||
apiToken: provider.config.apiToken,
|
||||
}),
|
||||
...(provider.config.providerType === "ovh" && {
|
||||
endpoint: provider.config.endpoint,
|
||||
applicationKey: provider.config.applicationKey,
|
||||
@ -421,6 +436,27 @@ 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
|
||||
|
||||
@ -24,6 +24,7 @@ const providerLabels: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
infomaniak: "Infomaniak",
|
||||
ovh: "OVHcloud",
|
||||
};
|
||||
|
||||
|
||||
@ -115,10 +115,13 @@ export const ShowServers = () => {
|
||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||
>
|
||||
<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">
|
||||
<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}
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
||||
@ -91,6 +91,17 @@ export const PorkbunIcon = ({ className }: Props) => (
|
||||
</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"
|
||||
@ -106,5 +117,6 @@ export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
porkbun: PorkbunIcon,
|
||||
infomaniak: InfomaniakIcon,
|
||||
ovh: OvhIcon,
|
||||
} as const;
|
||||
|
||||
@ -43,6 +43,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@ -616,7 +617,7 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
{/* Organization Logo and Selector */}
|
||||
<SidebarMenuItem className={"w-full"}>
|
||||
<SidebarMenuItem className={"w-full min-w-0"}>
|
||||
<Popover
|
||||
open={organizationSelectorOpen}
|
||||
onOpenChange={setOrganizationSelectorOpen}
|
||||
@ -632,14 +633,13 @@ function SidebarLogo() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
"flex min-w-0 flex-1 items-center gap-2",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-sm border",
|
||||
"size-6",
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
|
||||
)}
|
||||
>
|
||||
<Logo
|
||||
@ -652,22 +652,27 @@ function SidebarLogo() {
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start",
|
||||
"flex flex-col items-start min-w-0 flex-1",
|
||||
isCollapsed && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 min-w-0 w-full">
|
||||
<TruncateTooltip
|
||||
text={
|
||||
activeOrganization?.name ?? "Select Organization"
|
||||
}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
{haveValidLicense && (
|
||||
<Badge variant="blue">Enterprise</Badge>
|
||||
<Badge variant="blue" className="shrink-0">
|
||||
Enterprise
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown
|
||||
className={cn("ml-auto", isCollapsed && "hidden")}
|
||||
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
apps/dokploy/components/shared/truncate-tooltip.tsx
Normal file
@ -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"
|
||||
> {
|
||||
classNameWrapper?: string;
|
||||
classNameContent?: string;
|
||||
className?: string;
|
||||
dropMessage: string;
|
||||
onChange: (acceptedFiles: FileList | null) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
// Function to handle drag over event
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
|
||||
1
apps/dokploy/drizzle/0191_cool_christian_walker.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "487034a7-66d0-4e98-b86d-b2dd235f73d0",
|
||||
"id": "3245801b-bd78-477e-baf0-2a5f7a3a2071",
|
||||
"prevId": "d3186290-04e7-4f38-9216-d55a766b1dae",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@ -8954,7 +8954,7 @@
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
"ovh"
|
||||
"infomaniak"
|
||||
]
|
||||
},
|
||||
"public.domainType": {
|
||||
|
||||
9155
apps/dokploy/drizzle/meta/0192_snapshot.json
Normal file
@ -1342,8 +1342,15 @@
|
||||
{
|
||||
"idx": 191,
|
||||
"version": "7",
|
||||
"when": 1788332268306,
|
||||
"tag": "0191_elite_ultragirl",
|
||||
"when": 1788332224024,
|
||||
"tag": "0191_cool_christian_walker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 192,
|
||||
"version": "7",
|
||||
"when": 1788600069255,
|
||||
"tag": "0192_light_lake",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@ -3,12 +3,10 @@ import "@/styles/globals.css";
|
||||
import type { NextPage } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
import Head from "next/head";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
||||
import { Analytics } from "@/components/shared/analytics";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@ -40,9 +38,6 @@ const MyApp = ({
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
</Head>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@ -52,7 +47,6 @@ const MyApp = ({
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<WhitelabelingProvider />
|
||||
<Analytics />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
|
||||
@ -1,10 +1,39 @@
|
||||
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";
|
||||
|
||||
export default function Document() {
|
||||
interface WhitelabelingDocumentProps {
|
||||
metaTitle: string | null;
|
||||
faviconHref: string | null;
|
||||
customCss: string | null;
|
||||
}
|
||||
|
||||
export default function Document({
|
||||
metaTitle,
|
||||
faviconHref,
|
||||
customCss,
|
||||
}: WhitelabelingDocumentProps) {
|
||||
const title = metaTitle || "Dokploy";
|
||||
return (
|
||||
<Html lang="en" className="font-sans">
|
||||
<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>
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
{customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{ __html: customCss }}
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
<body className="flex h-full w-full flex-col font-sans">
|
||||
<Main />
|
||||
@ -13,3 +42,67 @@ export default function Document() {
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
declare global {
|
||||
var __SETTINGS_CACHE: {
|
||||
data: {
|
||||
metaTitle: 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 metaTitle: string | null = null;
|
||||
let faviconHref: string | null = null;
|
||||
let customCss: string | null = null;
|
||||
|
||||
if (
|
||||
globalThis.__SETTINGS_CACHE &&
|
||||
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
|
||||
globalThis.__SETTINGS_CACHE.data
|
||||
) {
|
||||
return {
|
||||
...initialProps,
|
||||
...globalThis.__SETTINGS_CACHE.data,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getPublicWhitelabelingConfig();
|
||||
if (config) {
|
||||
metaTitle = config.metaTitle;
|
||||
// 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: {
|
||||
metaTitle,
|
||||
faviconHref,
|
||||
customCss,
|
||||
},
|
||||
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||
};
|
||||
|
||||
return {
|
||||
...initialProps,
|
||||
metaTitle,
|
||||
faviconHref,
|
||||
customCss,
|
||||
};
|
||||
};
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
@ -46,6 +47,7 @@ import {
|
||||
} from "@/components/ui/input-otp";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (data?.twoFactorRedirect as boolean) {
|
||||
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
|
||||
setTwoFactorCode("");
|
||||
setIsTwoFactor(true);
|
||||
toast.info("Please enter your 2FA code");
|
||||
@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => {
|
||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
};
|
||||
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) {
|
||||
try {
|
||||
const { user } = await validateRequest(context.req);
|
||||
@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
IS_CLOUD: IS_CLOUD,
|
||||
enforceSSO: false,
|
||||
},
|
||||
@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
hasAdmin,
|
||||
enforceSSO: webServerSettings?.enforceSSO ?? false,
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -23,6 +24,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => {
|
||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
};
|
||||
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 token = query.token;
|
||||
@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
if (invitation.userAlreadyExists) {
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
isCloud: IS_CLOUD,
|
||||
token: token,
|
||||
invitation: invitation,
|
||||
@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
isCloud: IS_CLOUD,
|
||||
token: token,
|
||||
invitation: invitation,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
@ -27,6 +28,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => {
|
||||
);
|
||||
};
|
||||
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) {
|
||||
const { user } = await validateRequest(context.req);
|
||||
|
||||
@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
isCloud: true,
|
||||
},
|
||||
};
|
||||
@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
isCloud: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { IS_CLOUD } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -22,6 +23,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z.object({
|
||||
@ -165,7 +167,7 @@ export default function Home() {
|
||||
Home.getLayout = (page: ReactElement) => {
|
||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
};
|
||||
export async function getServerSideProps(_context: GetServerSidePropsContext) {
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
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 {
|
||||
props: {},
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
name: z.string().min(1),
|
||||
logo: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
@ -130,7 +130,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
organizationId: z.string(),
|
||||
name: z.string(),
|
||||
name: z.string().min(1),
|
||||
logo: z.string().optional(),
|
||||
defaultRole: z.string().min(1).nullable().optional(),
|
||||
}),
|
||||
|
||||
@ -14,6 +14,11 @@ import {
|
||||
publicProcedure,
|
||||
} 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({
|
||||
get: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({
|
||||
whitelabelingConfig: input.whitelabelingConfig,
|
||||
});
|
||||
|
||||
// Clear the cache so Next.js SSR applies changes immediately
|
||||
clearBrandingSSRCache();
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// Clear the cache so Next.js SSR applies changes immediately
|
||||
clearBrandingSSRCache();
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
|
||||
21
apps/dokploy/utils/create-server-helpers.ts
Normal file
@ -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,
|
||||
});
|
||||
};
|
||||
37
apps/dokploy/utils/image-processing.ts
Normal file
@ -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);
|
||||
});
|
||||
};
|
||||
18
apps/dokploy/utils/sanitize-svg.ts
Normal 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)}`;
|
||||
};
|
||||
@ -8,6 +8,7 @@ export const dnsProviderType = pgEnum("DnsProviderType", [
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
"infomaniak",
|
||||
"ovh",
|
||||
]);
|
||||
|
||||
@ -28,6 +29,11 @@ export const porkbunDnsConfigSchema = z.object({
|
||||
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",
|
||||
@ -50,6 +56,7 @@ export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
cloudflareDnsConfigSchema,
|
||||
route53DnsConfigSchema,
|
||||
porkbunDnsConfigSchema,
|
||||
infomaniakDnsConfigSchema,
|
||||
ovhDnsConfigSchema,
|
||||
]);
|
||||
|
||||
|
||||
@ -125,6 +125,24 @@ const createBetterAuth = () =>
|
||||
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
|
||||
...(await resolveTrustedOrigins()),
|
||||
].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: {
|
||||
|
||||
@ -18,6 +18,7 @@ const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
|
||||
cloudflare: ["apiToken"],
|
||||
route53: ["secretAccessKey"],
|
||||
porkbun: ["secretApiKey"],
|
||||
infomaniak: ["apiToken"],
|
||||
ovh: ["applicationSecret", "consumerKey"],
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
|
||||
import { cloudflareClient } from "./cloudflare";
|
||||
import { infomaniakClient } from "./infomaniak";
|
||||
import { ovhClient } from "./ovh";
|
||||
import { porkbunClient } from "./porkbun";
|
||||
import { route53Client } from "./route53";
|
||||
@ -9,6 +10,7 @@ const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
|
||||
cloudflare: cloudflareClient as DnsClient,
|
||||
route53: route53Client as DnsClient,
|
||||
porkbun: porkbunClient as DnsClient,
|
||||
infomaniak: infomaniakClient as DnsClient,
|
||||
ovh: ovhClient as DnsClient,
|
||||
};
|
||||
|
||||
|
||||
226
packages/server/src/utils/dns/infomaniak.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import type { infomaniakDnsConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type DnsClient, dnsFetch } from "./types";
|
||||
|
||||
type InfomaniakConfig = z.infer<typeof infomaniakDnsConfigSchema>;
|
||||
|
||||
interface InfomaniakResponse<T> {
|
||||
result: "success" | "error";
|
||||
data?: T;
|
||||
error?: { code?: string; description?: string };
|
||||
page?: number;
|
||||
pages?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface InfomaniakRecord {
|
||||
id: number | string;
|
||||
type: string;
|
||||
source: string;
|
||||
target: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface InfomaniakDomain {
|
||||
id: number;
|
||||
customer_name: string;
|
||||
}
|
||||
|
||||
const INFOMANIAK_API = "https://api.infomaniak.com";
|
||||
|
||||
// Infomaniak requires a TTL on every record, within a 60..86400 range.
|
||||
const DEFAULT_TTL = 300;
|
||||
|
||||
const ikRequest = async <T>(
|
||||
config: InfomaniakConfig,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<InfomaniakResponse<T>> => {
|
||||
const response = await dnsFetch(`${INFOMANIAK_API}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiToken.trim()}`,
|
||||
"Content-Type": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const body = (await response.json()) as InfomaniakResponse<T>;
|
||||
if (!response.ok || body.result !== "success") {
|
||||
const detail = body.error?.description ?? body.error?.code;
|
||||
throw new Error(
|
||||
`Infomaniak: request to ${path} failed${
|
||||
detail ? `: ${detail}` : ` (status ${response.status})`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const ikFetch = async <T>(
|
||||
config: InfomaniakConfig,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> => (await ikRequest<T>(config, path, init)).data as T;
|
||||
|
||||
// Infomaniak's "source" holds the subdomain only, relative to the zone. The apex
|
||||
// is a bare root dot; "" and "@" are accepted too so a hand-written record still
|
||||
// round-trips.
|
||||
const APEX_SOURCES = new Set(["", ".", "@"]);
|
||||
|
||||
const toSource = (name: string, zone: string) => {
|
||||
const fqdn = name.replace(/\.$/, "");
|
||||
if (fqdn === zone) {
|
||||
return ".";
|
||||
}
|
||||
const suffix = `.${zone}`;
|
||||
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
|
||||
};
|
||||
|
||||
const toFqdn = (source: string, zone: string) =>
|
||||
APEX_SOURCES.has(source) ? zone : `${source}.${zone}`;
|
||||
|
||||
// toSource always writes the apex as ".", so an existing record stored under one
|
||||
// of the other apex spellings has to normalize to the same thing before it can
|
||||
// be matched.
|
||||
const normalizeSource = (source: string) =>
|
||||
APEX_SOURCES.has(source) ? "." : source;
|
||||
|
||||
// TXT targets are stored quoted; keep Dokploy's view of them unquoted so that
|
||||
// editing a record does not stack a new pair of quotes on every save.
|
||||
const unquoteTarget = (target: string) => {
|
||||
if (target.length >= 2 && target.startsWith('"') && target.endsWith('"')) {
|
||||
try {
|
||||
const unquoted: unknown = JSON.parse(target);
|
||||
if (typeof unquoted === "string") {
|
||||
return unquoted;
|
||||
}
|
||||
} catch {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
const quoteTarget = (type: string, content: string) => {
|
||||
const value = content.trim();
|
||||
if (type !== "TXT") {
|
||||
return value;
|
||||
}
|
||||
return value.startsWith('"') && value.endsWith('"')
|
||||
? value
|
||||
: JSON.stringify(value);
|
||||
};
|
||||
|
||||
const recordPayload = (
|
||||
record: { type: string; name: string; content: string; ttl?: number },
|
||||
zone: string,
|
||||
) => ({
|
||||
type: record.type,
|
||||
source: toSource(record.name, zone),
|
||||
target: quoteTarget(record.type, record.content),
|
||||
ttl: record.ttl ?? DEFAULT_TTL,
|
||||
});
|
||||
|
||||
const PRODUCTS_PER_PAGE = 100;
|
||||
|
||||
// The products endpoint paginates — 15 per page by default — so an account with
|
||||
// more domains than fit on one page would otherwise silently lose zones.
|
||||
const listDomainProducts = async (config: InfomaniakConfig) => {
|
||||
const domains: InfomaniakDomain[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const body = await ikRequest<InfomaniakDomain[]>(
|
||||
config,
|
||||
`/1/products?service_name=domain&page=${page}&per_page=${PRODUCTS_PER_PAGE}`,
|
||||
);
|
||||
domains.push(...(body.data ?? []));
|
||||
if (page >= (body.pages ?? 1)) {
|
||||
return domains;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) =>
|
||||
await ikFetch<InfomaniakRecord[]>(
|
||||
config,
|
||||
`/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`,
|
||||
);
|
||||
|
||||
export const infomaniakClient: DnsClient<InfomaniakConfig> = {
|
||||
async listZones(config) {
|
||||
const domains = await listDomainProducts(config);
|
||||
// The v2 record endpoints are keyed by zone name, not by product id.
|
||||
return domains.map((domain) => ({
|
||||
id: domain.customer_name,
|
||||
name: domain.customer_name,
|
||||
}));
|
||||
},
|
||||
|
||||
async listRecords(config, zoneId) {
|
||||
const records = await listZoneRecords(config, zoneId);
|
||||
return records.map((record) => ({
|
||||
id: String(record.id),
|
||||
type: record.type,
|
||||
name: toFqdn(record.source, zoneId),
|
||||
content: unquoteTarget(record.target),
|
||||
ttl: Number(record.ttl),
|
||||
}));
|
||||
},
|
||||
|
||||
async upsertRecord(config, record) {
|
||||
const source = toSource(record.name, record.zoneId);
|
||||
const existing = await listZoneRecords(config, record.zoneId);
|
||||
const match = existing.find(
|
||||
(candidate) =>
|
||||
candidate.type === record.type &&
|
||||
normalizeSource(candidate.source) === source,
|
||||
);
|
||||
|
||||
const body = JSON.stringify(recordPayload(record, record.zoneId));
|
||||
const zone = encodeURIComponent(record.zoneId);
|
||||
|
||||
if (match) {
|
||||
await ikFetch(config, `/2/zones/${zone}/records/${match.id}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
return { id: String(match.id) };
|
||||
}
|
||||
|
||||
const created = await ikFetch<InfomaniakRecord>(
|
||||
config,
|
||||
`/2/zones/${zone}/records`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
// The API returns the created record, but older responses only carry its id.
|
||||
return {
|
||||
id:
|
||||
typeof created === "object" && created !== null
|
||||
? String(created.id)
|
||||
: String(created),
|
||||
};
|
||||
},
|
||||
|
||||
async updateRecord(config, zoneId, recordId, record) {
|
||||
await ikFetch(
|
||||
config,
|
||||
`/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`,
|
||||
{ method: "PUT", body: JSON.stringify(recordPayload(record, zoneId)) },
|
||||
);
|
||||
return { id: recordId };
|
||||
},
|
||||
|
||||
async deleteRecord(config, zoneId, recordId) {
|
||||
await ikFetch(
|
||||
config,
|
||||
`/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await ikFetch(config, "/1/products?service_name=domain&per_page=1");
|
||||
},
|
||||
};
|
||||