Skip to content
Merged
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
102 changes: 98 additions & 4 deletions src/components/DomainFormModal.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { describe, it, expect, afterEach } from "vitest";
import { mount, type VueWrapper } from "@vue/test-utils";
import { describe, it, expect, afterEach, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import DomainFormModal from "./DomainFormModal.vue";
import type { DomainConfig } from "@/types";
import { notificationsApi } from "@/services/api";

vi.mock("@/services/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/api")>();
return {
...actual,
notificationsApi: { ...actual.notificationsApi, getAccessEmailTargets: vi.fn() },
};
});

let wrapper: VueWrapper;

Expand All @@ -21,6 +30,7 @@ const mountModal = (domain?: DomainConfig) => {
afterEach(() => {
wrapper?.unmount();
document.body.innerHTML = "";
vi.mocked(notificationsApi.getAccessEmailTargets).mockReset();
});

describe("DomainFormModal routing-only hostnames", () => {
Expand All @@ -39,7 +49,7 @@ describe("DomainFormModal routing-only hostnames", () => {
});

it("emits routing-only hostnames separately from certificate-bearing aliases", async () => {
const wrapper = mountModal({
mountModal({
id: "d1",
service: "web",
container_port: 80,
Expand All @@ -60,7 +70,7 @@ describe("DomainFormModal routing-only hostnames", () => {

describe("DomainFormModal static caching", () => {
it("emits the static-cache toggle when enabled on the domain", async () => {
const wrapper = mountModal({
mountModal({
id: "d1",
service: "web",
container_port: 80,
Expand All @@ -76,3 +86,87 @@ describe("DomainFormModal static caching", () => {
expect(saved.static_cache).toBe(true);
});
});

describe("DomainFormModal visitor access", () => {
it("loads the deployment's permitted email targets", async () => {
vi.mocked(notificationsApi.getAccessEmailTargets).mockResolvedValue({
data: { targets: [{ id: "smtp-primary", name: "Primary mail" }] },
} as Awaited<ReturnType<typeof notificationsApi.getAccessEmailTargets>>);
mountModal();
await flushPromises();
expect(notificationsApi.getAccessEmailTargets).toHaveBeenCalledWith("shop");
const accessToggle = Array.from(document.querySelectorAll("label")).find((label) =>
label.textContent?.includes("Require email verification"),
);
if (!accessToggle) throw new Error("Access toggle missing");
const input = accessToggle.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("Access input missing");
input.checked = true;
input.dispatchEvent(new Event("change", { bubbles: true }));
await wrapper.vm.$nextTick();
expect(document.body.textContent).toContain("Primary mail");
});

it("preserves an email allowlist and session settings", async () => {
mountModal({
id: "d1",
service: "web",
container_port: 80,
domain: "private.example.com",
ssl: { enabled: true, auto_cert: true },
access: {
enabled: true,
mode: "allowlist",
allowed_emails: ["person@example.com"],
email_target_id: "smtp-primary",
session_hours: 48,
},
});

document.querySelector<HTMLButtonElement>(".btn-primary")?.click();
await wrapper.vm.$nextTick();

const saved = wrapper.emitted("save")?.[0]?.[0] as DomainConfig;
expect(saved.access).toEqual({
enabled: true,
mode: "allowlist",
allowed_emails: ["person@example.com"],
email_target_id: "smtp-primary",
session_hours: 48,
});
});

it("normalizes allowlist emails and rejects invalid session lengths", async () => {
mountModal({
id: "d1",
service: "web",
container_port: 80,
domain: "private.example.com",
ssl: { enabled: true, auto_cert: true },
access: {
enabled: true,
mode: "allowlist",
allowed_emails: ["Person@Example.com"],
email_target_id: "smtp-primary",
session_hours: 721,
},
});

document.querySelector<HTMLButtonElement>(".btn-primary")?.click();
await wrapper.vm.$nextTick();
expect(wrapper.emitted("save")).toBeUndefined();

const sessionInput = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="number"]')).find(
(input) => input.max === "720",
);
if (!sessionInput) throw new Error("Session input missing");
sessionInput.value = "24";
sessionInput.dispatchEvent(new Event("input", { bubbles: true }));
await wrapper.vm.$nextTick();
document.querySelector<HTMLButtonElement>(".btn-primary")?.click();
await wrapper.vm.$nextTick();

const saved = wrapper.emitted("save")?.[0]?.[0] as DomainConfig;
expect(saved.access?.allowed_emails).toEqual(["person@example.com"]);
});
});
102 changes: 101 additions & 1 deletion src/components/DomainFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,52 @@
</span>
</div>

<div class="form-section">
<h4>Visitor Access</h4>

<div class="form-group checkbox-group">
<label class="checkbox-label">
<input v-model="form.access.enabled" type="checkbox" />
<span>Require email verification</span>
</label>
<span class="hint">Protects this domain and path without changing the application.</span>
</div>

<template v-if="form.access.enabled">
<BaseField label="Who can sign in">
<BaseSelect v-model="form.access.mode">
<option value="allowlist">Only listed email addresses</option>
<option value="any_verified">Anyone with a verified email</option>
</BaseSelect>
</BaseField>

<BaseField
v-if="form.access.mode === 'allowlist'"
label="Allowed emails and domains"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DomainFormModal is the modal for configuring web hostnames, so "domains" is already overloaded in this file. The label "Allowed emails and domains" reads as "emails plus web domains", but the field actually accepts an email address or an email domain pattern (@example.com). Spell out the email-domain sense so operators don't confuse this with the host names the rest of the form manages.

Suggested change
label="Allowed emails and domains"
+ label="Allowed emails and email domains"

hint="Enter an email or @domain on each line, or separate entries with commas."
>
<BaseTextarea
v-model="form.access.allowed_emails"
:rows="4"
placeholder="person@example.com&#10;@flatrun.dev"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line break in the placeholder relies on the numeric character reference &#10; being decoded by the template compiler and then preserved when the browser renders a <textarea> placeholder. Express the two-line example as a bound JS string instead so the newline is explicit and independent of entity decoding / placeholder whitespace handling.

Suggested change
placeholder="person@example.com&#10;@flatrun.dev"
+ :placeholder="'person@example.com\n@flatrun.dev'"

/>
</BaseField>

<BaseField label="Email delivery target" hint="FlatRun sends sign-in links through this SMTP target.">
<BaseSelect v-model="form.access.email_target_id" required>
<option value="" disabled>Select an email target</option>
<option v-for="target in emailTargets" :key="target.id" :value="target.id">
{{ target.name }}
</option>
</BaseSelect>
</BaseField>

<BaseField label="Session length" hint="Hours before a visitor must verify again.">
<BaseInput v-model.number="form.access.session_hours" type="number" min="1" max="720" />
</BaseField>
</template>
</div>

<div class="form-group">
<label>Domain Aliases</label>
<div class="aliases-input">
Expand Down Expand Up @@ -143,6 +189,11 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import type { DomainConfig, Service } from "@/types";
import { notificationsApi, type NotificationTarget } from "@/services/api";
import BaseField from "@/components/base/BaseField.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import BaseSelect from "@/components/base/BaseSelect.vue";
import BaseTextarea from "@/components/base/BaseTextarea.vue";

const props = defineProps<{
visible: boolean;
Expand All @@ -167,6 +218,13 @@ const form = ref<{
aliases: string[];
route_only_aliases: string[];
static_cache: boolean;
access: {
enabled: boolean;
mode: "allowlist" | "any_verified";
allowed_emails: string;
email_target_id: string;
session_hours: number;
};
}>({
domain: "",
service: "",
Expand All @@ -177,12 +235,16 @@ const form = ref<{
aliases: [],
route_only_aliases: [],
static_cache: false,
access: { enabled: false, mode: "allowlist", allowed_emails: "", email_target_id: "", session_hours: 24 },
});

const emailTargets = ref<Pick<NotificationTarget, "id" | "name">[]>([]);

watch(
() => props.visible,
(visible) => {
if (visible) {
void loadEmailTargets();
if (props.domain) {
form.value = {
domain: props.domain.domain || "",
Expand All @@ -197,6 +259,13 @@ watch(
aliases: [...(props.domain.aliases || [])],
route_only_aliases: [...(props.domain.route_only_aliases || [])],
static_cache: props.domain.static_cache || false,
access: {
enabled: props.domain.access?.enabled || false,
mode: props.domain.access?.mode || "allowlist",
allowed_emails: (props.domain.access?.allowed_emails || []).join("\n"),
email_target_id: props.domain.access?.email_target_id || "",
session_hours: props.domain.access?.session_hours || 24,
},
};
} else {
form.value = {
Expand All @@ -209,6 +278,7 @@ watch(
aliases: [],
route_only_aliases: [],
static_cache: false,
access: { enabled: false, mode: "allowlist", allowed_emails: "", email_target_id: "", session_hours: 24 },
};
}
}
Expand All @@ -217,9 +287,30 @@ watch(
);

const isValid = computed(() => {
return form.value.domain.trim() !== "";
if (form.value.domain.trim() === "") return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isValid is the only gate for this form (the modal never submits natively), but it does not enforce the min="1" max="720" bounds declared on the session-length BaseInput. A user can type 0 or 9999, accessEmails()/target checks still pass, and the out-of-range value is persisted (session_hours || 24 only rescues 0/empty, not negatives or huge values). Validate the range here so it matches the input contract.

Suggested change
if (form.value.domain.trim() === "") return false;
if (form.value.domain.trim() === "") return false;
if (!form.value.access.enabled) return true;
if (!form.value.access.email_target_id) return false;
const hours = form.value.access.session_hours;
if (!Number.isFinite(hours) || hours < 1 || hours > 720) return false;
if (form.value.access.mode === "allowlist") return accessEmails().length > 0;
return true;

if (!form.value.access.enabled) return true;
if (!form.value.access.email_target_id) return false;
if (form.value.access.session_hours < 1 || form.value.access.session_hours > 720) return false;
if (form.value.access.mode === "allowlist") return accessEmails().length > 0;
return true;
});

async function loadEmailTargets() {
try {
const response = await notificationsApi.getAccessEmailTargets(props.deploymentName);
emailTargets.value = response.data.targets || [];
} catch {
emailTargets.value = [];
}
}

function accessEmails() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Email allowlist entries are trimmed but not case-normalized. Email addresses are effectively case-insensitive, so an operator entering Person@Example.com and a visitor signing in as person@example.com can fail to match if the backend compares the allowlist literally. Normalizing to lowercase here also normalizes the value emitted to the API, since accessEmails() is used for both validation and submission.

Suggested change
function accessEmails() {
function accessEmails() {
return form.value.access.allowed_emails
.split(/[\n,]/)
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
}

return form.value.access.allowed_emails
.split(/[\n,]/)
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
}

function addAlias() {
form.value.aliases.push("");
}
Expand Down Expand Up @@ -250,6 +341,15 @@ function handleSubmit() {
aliases: form.value.aliases.filter((a) => a.trim() !== ""),
route_only_aliases: form.value.route_only_aliases.filter((a) => a.trim() !== ""),
static_cache: form.value.static_cache || undefined,
access: form.value.access.enabled
? {
enabled: true,
mode: form.value.access.mode,
allowed_emails: form.value.access.mode === "allowlist" ? accessEmails() : undefined,
email_target_id: form.value.access.email_target_id,
session_hours: form.value.access.session_hours || 24,
}
: undefined,
};

emit("save", domainData);
Expand Down
4 changes: 4 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,10 @@ export interface NotificationIncident {
}

export const notificationsApi = {
getAccessEmailTargets: (deployment: string) =>
apiClient.get<{ targets: Pick<NotificationTarget, "id" | "name">[] }>(
`/deployments/${encodeURIComponent(deployment)}/access/email-targets`,
),
getTargets: () => apiClient.get<{ targets: NotificationTarget[] }>("/notifications/targets"),
getAlertTargetOptions: () =>
apiClient.get<{ targets: Pick<NotificationTarget, "id" | "name">[] }>("/alerts/target-options"),
Expand Down
9 changes: 9 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ export interface DomainConfig {
aliases?: string[];
route_only_aliases?: string[];
static_cache?: boolean;
access?: DomainAccessConfig;
}

export interface DomainAccessConfig {
enabled: boolean;
mode: "allowlist" | "any_verified";
allowed_emails?: string[];
email_target_id: string;
session_hours?: number;
}

export interface QuickAction {
Expand Down
Loading