Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-site-url-admin-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Adds a Site URL field to admin General Settings that updates the `emdash:site_url` option, making the URL used for transactional emails (magic-link, invites, password resets) editable after the setup wizard. Fixes #989.

A new endpoint, `GET/POST /_emdash/api/settings/site-url`, gates the write on `settings:manage` and normalizes the submitted value to a bare origin (rejecting non-http(s) schemes and any value carrying a path, query string, or fragment). The pre-existing `site:url` setting in the Site Identity section (used for canonical links and sitemaps) is unchanged.
100 changes: 99 additions & 1 deletion packages/admin/src/components/settings/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import { FloppyDisk, CheckCircle, WarningCircle, Upload, X } from "@phosphor-ico
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";

import { fetchSettings, updateSettings, type SiteSettings, type MediaItem } from "../../lib/api";
import {
fetchSettings,
fetchSiteUrl,
updateSettings,
updateSiteUrl,
type SiteSettings,
type MediaItem,
} from "../../lib/api";
import { EditorHeader } from "../EditorHeader";
import { MediaPickerModal } from "../MediaPickerModal";
import { BackToSettingsLink } from "./BackToSettingsLink.js";
Expand All @@ -26,7 +33,21 @@ export function GeneralSettings() {
staleTime: Infinity,
});

// `emdash:site_url` lives outside the `site:*` settings namespace and is
// served by a dedicated endpoint -- keep it on a separate query so the
// main settings cache doesn't have to know about a sibling concept.
const { data: siteUrlSetting } = useQuery({
queryKey: ["settings", "site-url"],
queryFn: fetchSiteUrl,
staleTime: Infinity,
});

const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
const [siteUrlInput, setSiteUrlInput] = React.useState("");
const [siteUrlStatus, setSiteUrlStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
const [saveStatus, setSaveStatus] = React.useState<{
type: "success" | "error";
message: string;
Expand All @@ -39,13 +60,24 @@ export function GeneralSettings() {
if (settings) setFormData(settings);
}, [settings]);

React.useEffect(() => {
if (siteUrlSetting) setSiteUrlInput(siteUrlSetting.siteUrl ?? "");
}, [siteUrlSetting]);

React.useEffect(() => {
if (saveStatus) {
const timer = setTimeout(setSaveStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [saveStatus]);

React.useEffect(() => {
if (siteUrlStatus) {
const timer = setTimeout(setSiteUrlStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [siteUrlStatus]);

const saveMutation = useMutation({
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
onSuccess: () => {
Expand All @@ -60,6 +92,21 @@ export function GeneralSettings() {
},
});

const siteUrlMutation = useMutation({
mutationFn: (value: string) => updateSiteUrl(value),
onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: ["settings", "site-url"] });
if (data.siteUrl) setSiteUrlInput(data.siteUrl);
setSiteUrlStatus({ type: "success", message: t`Site URL saved` });
},
onError: (error) => {
setSiteUrlStatus({
type: "error",
message: error instanceof Error ? error.message : t`Failed to save site URL`,
});
},
});

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
Expand Down Expand Up @@ -287,6 +334,57 @@ export function GeneralSettings() {
</div>
</div>

{/* Email Site URL --
A separate form section because `emdash:site_url` is updated via
its own endpoint (`/_emdash/api/settings/site-url`) and lives
outside the `site:*` settings namespace edited by the form above.
See packages/core/src/api/site-url.ts and upstream issue #989. */}
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Email Site URL`}</h2>
<p className="mb-4 text-sm text-kumo-subtle">
{t`Sets the base URL used for magic-link, invitation, and password-reset emails. Changes do not affect URLs in emails that have already been sent.`}
</p>
{siteUrlStatus && (
<div
className={`mb-4 flex items-center gap-2 rounded-lg border p-3 text-sm ${
siteUrlStatus.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{siteUrlStatus.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{siteUrlStatus.message}
</div>
)}
<div className="space-y-4">
<Input
label={t`Site URL (transactional emails)`}
type="url"
value={siteUrlInput}
onChange={(e) => setSiteUrlInput(e.target.value)}
description={t`Must be an absolute http or https origin, e.g. https://example.com. Set during initial setup; edit here to update.`}
placeholder="https://example.com"
/>
<div className="flex justify-end">
<Button
type="button"
disabled={
siteUrlMutation.isPending ||
siteUrlInput.trim() === (siteUrlSetting?.siteUrl ?? "")
}
icon={<FloppyDisk />}
onClick={() => siteUrlMutation.mutate(siteUrlInput.trim())}
>
{siteUrlMutation.isPending ? t`Saving...` : t`Save Site URL`}
</Button>
</div>
</div>
</div>

{/* Reading Settings */}
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Reading`}</h2>
Expand Down
9 changes: 8 additions & 1 deletion packages/admin/src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,14 @@ export {
} from "./plugins.js";

// Settings
export { type SiteSettings, fetchSettings, updateSettings } from "./settings.js";
export {
type SiteSettings,
type SiteUrlSetting,
fetchSettings,
updateSettings,
fetchSiteUrl,
updateSiteUrl,
} from "./settings.js";

// Users, passkeys, allowed domains
export {
Expand Down
33 changes: 33 additions & 0 deletions packages/admin/src/lib/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,36 @@ export async function updateSettings(
});
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to update settings");
}

// ---------------------------------------------------------------------------
// `emdash:site_url` -- the internal origin used to build links in
// magic-link / invitation / password-reset emails. Separate from
// `SiteSettings.url` (presentation-layer URL used for canonical links).
// See `packages/core/src/astro/routes/api/settings/site-url.ts` and
// upstream issue #989 for why these are distinct.
// ---------------------------------------------------------------------------

export interface SiteUrlSetting {
siteUrl: string | null;
}

/**
* Fetch the current `emdash:site_url` option.
*/
export async function fetchSiteUrl(): Promise<SiteUrlSetting> {
const response = await apiFetch(`${API_BASE}/settings/site-url`);
return parseApiResponse<SiteUrlSetting>(response, "Failed to fetch site URL");
}

/**
* Update the `emdash:site_url` option. The value is normalized server-side
* to a bare origin (e.g. `https://example.com`) before persistence.
*/
export async function updateSiteUrl(siteUrl: string): Promise<SiteUrlSetting> {
const response = await apiFetch(`${API_BASE}/settings/site-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siteUrl }),
});
return parseApiResponse<SiteUrlSetting>(response, "Failed to update site URL");
}
7 changes: 7 additions & 0 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
entrypoint: resolveRoute("api/settings/email.ts"),
});

// Site URL settings route -- governs `emdash:site_url`, the origin used
// to build links in magic-link / invitation / password-reset emails.
injectRoute({
pattern: "/_emdash/api/settings/site-url",
entrypoint: resolveRoute("api/settings/site-url.ts"),
});

// Snapshot route (for DO preview database population)
injectRoute({
pattern: "/_emdash/api/snapshot",
Expand Down
137 changes: 137 additions & 0 deletions packages/core/src/astro/routes/api/settings/site-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Site URL Settings API endpoint
*
* GET /_emdash/api/settings/site-url — current `emdash:site_url` value
* POST /_emdash/api/settings/site-url — update `emdash:site_url`
*
* Why a dedicated endpoint?
* The `emdash:site_url` option governs the base URL used to build links in
* magic-link, invitation, and password-reset emails (see
* `src/api/site-url.ts` -> `getSiteBaseUrl`). It is written once during the
* setup wizard via `setIfAbsent()` and was previously not editable from
* the admin UI. The regular Site Settings form edits `site:url`, which is
* a separate, presentation-layer URL (used for canonical links/sitemaps)
* and is not consulted by the auth email path. See issue #989.
*
* Keeping this as its own endpoint (rather than rolling it into
* `POST /_emdash/api/settings`) preserves the existing `site:*` namespace
* and makes it explicit that this write changes the security-sensitive
* origin used in transactional emails.
*/

import type { APIRoute } from "astro";
import { z } from "zod";

import { requirePerm } from "#api/authorize.js";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
import { OptionsRepository } from "#db/repositories/options.js";

export const prerender = false;

const SITE_URL_OPTION = "emdash:site_url";

/**
* Accept any non-empty string -- shape validation (http/https scheme,
* parseability) is done after parsing with `URL` so we can produce a
* single, normalized origin (no path, no trailing slash) before write.
*
* Matching `getPublicOrigin()` semantics: the stored value is always an
* origin like `https://example.com`, never with a path or trailing slash,
* so subsequent reads in `getSiteBaseUrl()` can append `/_emdash` cleanly.
*/
const siteUrlBody = z.object({
siteUrl: z.string().min(1).max(2048),
});

/**
* GET /_emdash/api/settings/site-url
*
* Returns `{ siteUrl: string | null }`. `null` means the option has never
* been set (only possible in the rare case the setup wizard skipped this
* write -- the wizard writes via `setIfAbsent` on every completion).
*/
export const GET: APIRoute = async ({ locals }) => {
const { emdash, user } = locals;

if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}

const denied = requirePerm(user, "settings:read");
if (denied) return denied;

try {
const options = new OptionsRepository(emdash.db);
const siteUrl = (await options.get<string>(SITE_URL_OPTION)) ?? null;
return apiSuccess({ siteUrl });
} catch (error) {
return handleError(error, "Failed to read site URL", "SITE_URL_READ_ERROR");
}
};

/**
* POST /_emdash/api/settings/site-url
*
* Updates `emdash:site_url`. Accepts `{ siteUrl: string }` and normalizes
* to a bare origin (`https://host[:port]`) before persisting.
*
* Rejects values that:
* - fail `new URL()` parsing,
* - use a scheme other than `http:` or `https:` (XSS-prone schemes like
* `javascript:` and `data:` must not be writable here -- this value is
* interpolated into outgoing email content),
* - carry a path, query, or fragment (we want an origin only, to match
* how `getSiteBaseUrl` appends `/_emdash`).
*/
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash, user } = locals;

if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}

const denied = requirePerm(user, "settings:manage");
if (denied) return denied;

try {
const body = await parseBody(request, siteUrlBody);
if (isParseError(body)) return body;

let parsed: URL;
try {
parsed = new URL(body.siteUrl.trim());
} catch {
return apiError(
"INVALID_SITE_URL",
"Site URL must be a valid absolute URL (e.g. https://example.com)",
400,
);
}

if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return apiError("INVALID_SITE_URL", "Site URL must use the http or https scheme", 400);
}

// Allow trailing-slash-only path so users pasting `https://example.com/`
// from a browser address bar don't get rejected. Anything beyond that
// (a real path, query string, or fragment) is rejected -- we store an
// origin, not a deep link.
if ((parsed.pathname !== "" && parsed.pathname !== "/") || parsed.search || parsed.hash) {
return apiError(
"INVALID_SITE_URL",
"Site URL must be an origin only (no path, query string, or fragment)",
400,
);
}

const normalized = parsed.origin;

const options = new OptionsRepository(emdash.db);
await options.set(SITE_URL_OPTION, normalized);

return apiSuccess({ siteUrl: normalized });
} catch (error) {
return handleError(error, "Failed to update site URL", "SITE_URL_UPDATE_ERROR");
}
};
Loading
Loading