feat(access): Configure visitor email verification for domains - #113
Conversation
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.
Code Review Summary✨ This PR adds a visitor access section to Validation is tightened in 🚀 Key Improvements
💡 Minor Suggestions
|
| async function loadEmailTargets() { | ||
| try { | ||
| const response = await notificationsApi.getAccessEmailTargets(props.deploymentName); | ||
| emailTargets.value = response.data.targets; |
There was a problem hiding this comment.
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.
| emailTargets.value = response.data.targets; | |
| emailTargets.value = response.data.targets || []; |
| } | ||
| } | ||
|
|
||
| function accessEmails() { |
There was a problem hiding this comment.
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.
| function accessEmails() { | |
| function accessEmails() { | |
| return form.value.access.allowed_emails | |
| .split(/[\n,]/) | |
| .map((email) => email.trim().toLowerCase()) | |
| .filter(Boolean); | |
| } |
| vi.mocked(notificationsApi.getAccessEmailTargets).mockResolvedValue({ | ||
| data: { targets: [{ id: "smtp-primary", name: "Primary mail" }] }, | ||
| } as Awaited<ReturnType<typeof notificationsApi.getAccessEmailTargets>>); | ||
| const wrapper = mountModal(); |
There was a problem hiding this comment.
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.
| const wrapper = mountModal(); | |
| wrapper = mountModal(); |
|
|
||
| const isValid = computed(() => { | ||
| return form.value.domain.trim() !== ""; | ||
| if (form.value.domain.trim() === "") return false; |
There was a problem hiding this comment.
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.
| 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; |
| <BaseField | ||
| v-if="form.access.mode === 'allowlist'" | ||
| label="Allowed email addresses" | ||
| hint="Enter one email address per line." |
There was a problem hiding this comment.
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.
| 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.
Deploying flatrun-ui with
|
| 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 |
The visitor access form now shows how to allow every verified address on a domain.
|
|
||
| <BaseField | ||
| v-if="form.access.mode === 'allowlist'" | ||
| label="Allowed emails and domains" |
There was a problem hiding this comment.
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.
| label="Allowed emails and domains" | |
| + label="Allowed emails and email domains" |
| <BaseTextarea | ||
| v-model="form.access.allowed_emails" | ||
| :rows="4" | ||
| placeholder="person@example.com @flatrun.dev" |
There was a problem hiding this comment.
The line break in the placeholder relies on the numeric character reference 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.
| placeholder="person@example.com @flatrun.dev" | |
| + :placeholder="'person@example.com\n@flatrun.dev'" |
Add visitor access settings to the domain form. Operators can choose who may sign in, the email target, and the session length.