Skip to content

feat(access): Configure visitor email verification for domains - #113

Merged
nfebe merged 4 commits into
mainfrom
feat/email-access-gates
Sep 23, 2026
Merged

nfebe merged 4 commits into
mainfrom
feat/email-access-gates

Conversation

@nfebe

@nfebe nfebe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Add visitor access settings to the domain form. Operators can choose who may sign in, the email target, and the session length.

Operators can protect a domain or path from the domain form, choose who may sign in, and select the email delivery target and session length.
Editors can choose an enabled email target for a protected domain using their deployment access, without needing notification administration rights.
@sourceant

sourceant Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Code Review Summary

✨ This PR adds a visitor access section to DomainFormModal.vue, letting operators require email verification for a domain: choose who may sign in (allowlist or any_verified), list allowed email addresses/domains, pick an SMTP delivery target, and set a session length (1–720 hours). Supporting changes include a new DomainAccessConfig type in src/types/index.ts, a new notificationsApi.getAccessEmailTargets(deployment) endpoint in src/services/api.ts (deployment name URL-encoded), and a new DomainFormModal visitor access test suite that mocks the API, asserts targets load for the deployment, verifies round-tripping of an existing access config, and checks allowlist normalization plus rejection of an out-of-range session length.

Validation is tightened in isValid: the email target is required when access is enabled, session hours must be within range, and an empty allowlist blocks submission. Submitted allowlists are lowercased/trimmed and split on newlines or commas, and access is omitted entirely from the emitted payload when the toggle is off. The review raised two clarity points in DomainFormModal.vue: the "Allowed emails and domains" label can be read as web hostnames (the form's existing notion of domains) rather than email-domain patterns like @example.com, and the two-line textarea placeholder depends on the 
 entity being decoded and preserved by the browser.

🚀 Key Improvements

  • src/components/DomainFormModal.vue: visitor access settings are gated behind the Require email verification toggle, and access is only emitted when enabled, so existing payloads stay unchanged for domains that don't use the feature.
  • src/components/DomainFormModal.vue: allowlist entries are normalized (trimmed, lowercased, split on newline or comma) before save, and isValid blocks submission on a missing email target, out-of-range session hours, or an empty allowlist.
  • src/components/DomainFormModal.test.ts: new coverage mocks notificationsApi.getAccessEmailTargets, asserts the deployment-scoped call and rendered target options, and exercises both the preserved-config and normalization/validation paths.

💡 Minor Suggestions

  • 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.
  • 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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread src/components/DomainFormModal.vue Outdated
async function loadEmailTargets() {
try {
const response = await notificationsApi.getAccessEmailTargets(props.deploymentName);
emailTargets.value = response.data.targets;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

response.data.targets is assigned without a fallback. The other target loaders in this repo (LogRulesPanel.vue, AlertRulesPanel.vue) defensively coalesce with || []. If the endpoint ever returns a payload without targets, emailTargets becomes undefined, which contradicts its declared Pick<NotificationTarget, "id" | "name">[] type and leaves the select in an inconsistent state.

Suggested change
emailTargets.value = response.data.targets;
emailTargets.value = response.data.targets || [];

}
}

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);
}

Comment thread src/components/DomainFormModal.test.ts Outdated
vi.mocked(notificationsApi.getAccessEmailTargets).mockResolvedValue({
data: { targets: [{ id: "smtp-primary", name: "Primary mail" }] },
} as Awaited<ReturnType<typeof notificationsApi.getAccessEmailTargets>>);
const wrapper = mountModal();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This test declares a local const wrapper, shadowing the module-scoped let wrapper that the shared afterEach unmounts. As a result the mounted component is never unmounted in the new describe block (the same applies to the second test at line 111), leaking component instances between tests and bypassing the existing cleanup contract. Assign to the module-scoped variable instead.

Suggested change
const wrapper = mountModal();
wrapper = mountModal();


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;

Comment thread src/components/DomainFormModal.vue Outdated
<BaseField
v-if="form.access.mode === 'allowlist'"
label="Allowed email addresses"
hint="Enter one email address per line."

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 hint tells operators to enter one address per line, but accessEmails() splits on both newlines and commas (split(/[\n,]/)). The hint under-documents the accepted input; align it with the parser.

Suggested change
hint="Enter one email address per line."
+ hint="Enter one email address per line, or separate addresses with commas."

Visitor access settings now reject invalid session lengths and normalize saved emails.

Email targets remain usable when the selector response omits its target list.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Deploying flatrun-ui with  Cloudflare Pages  Cloudflare Pages

Latest commit: fe4dbaf
Status: ✅  Deploy successful!
Preview URL: https://a700244c.flatrun-ui.pages.dev
Branch Preview URL: https://feat-email-access-gates.flatrun-ui.pages.dev

View logs

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

The visitor access form now shows how to allow every verified address on a domain.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.


<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"

<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'"

@nfebe
nfebe merged commit 3bbf7de into main Sep 23, 2026
5 checks passed
@nfebe
nfebe deleted the feat/email-access-gates branch September 23, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant