Enterprise - Customer Portal Customization#531
Conversation
… authoring - Introduced MailAppFrame component in FramesPage for displaying email previews with hidden sidebar. - Added two email preview sections: one for a verification code and another for a registration confirmation. - Updated MailAppFrame styles for better responsiveness and overflow handling. - Enhanced email template authoring by adding placeholder support for controlled fields, improving user experience in email composition.
- Introduced a new `portal_preset` table to allow multiple portal variants per project, with the ability to set one as primary. - Implemented functionality for managing portal presets, including creating, updating, and setting primary presets. - Added support for admin-authored HTML email templates for OTP verification, enhancing customization options for customer communications. - Created UI components for displaying and editing portal presets, including a dedicated page for managing presets and their email templates. - Implemented tests for the new portal preset functionality and email rendering logic to ensure reliability and correctness.
- Added `portal_login_page` field to the `portal_preset` table for storing customizable text overrides for the login page. - Updated the database schema to enforce JSON structure for login page text, allowing for future revisions. - Implemented UI components to manage login page text overrides, including form fields for email step and OTP step titles and descriptions. - Integrated the new login page customization into the existing portal login flow, allowing for dynamic text rendering based on preset configurations.
- Updated sidebar links to reflect new naming conventions, changing "Portal Presets" to "Customer Portal" and adjusting the CIAM link to not match subpaths. - Enhanced the `PortalPresetsPage` header to display "Customer Portal" instead of "Portal Presets". - Modified the `PortalPresetEditPage` to improve form structure and labels, including renaming "Login Page Text" to "Login Page" and updating button labels for clarity. - Introduced new UI components for better form handling and user experience in the portal preset management.
- Introduced a new guideline stating that no new directories should be created under `components/` by default unless explicitly required, emphasizing the importance of maintaining a curated and reusable component tree.
- Replaced the existing login component with a new `PortalLoginView` template for improved structure and customization. - Removed unused imports and legacy code related to the previous login flow. - Enhanced the login page to support dynamic text overrides for email and OTP steps, integrating with the existing portal preset management. - Updated the `PortalPresetEditPage` to include new form fields for login page customization, improving user experience in managing portal presets.
- Removed the MailAppFrame component and replaced it with a new EmailFrame structure across multiple pages for improved composability and maintainability. - Introduced EmailFrame, EmailFrameSubject, EmailFrameSender, and EmailFrameBody components to standardize email previews. - Updated various pages to utilize the new email components, enhancing the email template authoring experience and ensuring consistent styling and functionality.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis PR introduces a customizable portal preset system enabling projects to configure branded login pages and verification email templates. Changes include a new Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client/Browser
participant Portal as Portal Login Page
participant API as Email Access API
participant DB as Database (portal_preset)
participant Email as Email Service
Note over Client,Email: Login Flow with Portal Preset Customization
Client->>Portal: Load login page
Portal->>DB: Fetch portal_preset (project_id, primary)
DB-->>Portal: Return login overrides
Portal->>Portal: Render with PortalLoginView<br/>(using overrides)
Portal-->>Client: Display branded login UI
Client->>Portal: Submit email
Portal->>API: POST /api/p/access/with-email
API->>DB: Fetch portal_preset<br/>(verification_email_template)
DB-->>API: Return preset + template
alt Custom Template Exists
API->>API: renderPortalVerificationEmail<br/>(template + context)
API->>Email: Send with custom HTML
else Fallback
API->>Email: Send React Email template
end
Email-->>Client: Verification email received
sequenceDiagram
participant Admin as Admin/Console
participant Page as Portal Edit Page
participant API as Browser API (Supabase)
participant DB as Database
Note over Admin,DB: Portal Preset Edit & Preview Flow
Admin->>Page: Open preset edit page
Page->>DB: Fetch portal_preset by ID
DB-->>Page: Return preset data
Admin->>Page: Edit login overrides
Page->>Page: Live preview via PortalLoginView
Admin->>Page: Save changes
Page->>DB: Update preset login page field
DB-->>Page: Confirm update
Admin->>Page: Edit email template
Page->>Page: Live preview via<br/>renderPortalVerificationEmail
Admin->>Page: Save template
Page->>DB: Update preset verification_email_template
DB-->>Page: Confirm update
Admin->>Page: Set as Primary
Page->>DB: Call set_primary_portal_preset RPC
DB->>DB: Clear other primary flags<br/>Set this as primary
DB-->>Page: Success
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0f76094b8b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| <span | ||
| dangerouslySetInnerHTML={{ | ||
| __html: template(otpStepDescription, { email: otpEmail }), |
There was a problem hiding this comment.
Sanitize portal login overrides before HTML injection
The new portal login template renders otpStepDescription via dangerouslySetInnerHTML, but those strings now come from portal_login_page overrides stored in the database. That makes the OTP step a stored-XSS sink for any project member who can edit presets: arbitrary HTML/JS in the override will execute for every customer who opens the login page. Previously the string was hardcoded, so this attack surface is new. Consider escaping/sanitizing overrides or rendering them as text (or allow only a vetted subset of tags).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx (1)
51-62:⚠️ Potential issue | 🟡 MinorMissing
.catch()for network errors insendEmailcall.If
sendEmailrejects (e.g. network failure, DNS timeout), the.then()success handler is skipped but the rejection propagates uncaught — the user sees no feedback and the console logs an unhandled promise rejection..finally()only resetsisLoading; it doesn't swallow the error.🐛 Proposed fix
sendEmail(email) .then(({ ok, challenge_id }) => { if (ok) { setChallengeId(challenge_id ?? null); setStep("otp"); } else { toast.error("Something went wrong"); } }) + .catch(() => { + toast.error("Network error. Please try again."); + }) .finally(() => { setIsLoading(false); });
🤖 Fix all issues with AI agents
In `@editor/app/`(dev)/dev/frames/mail/page.tsx:
- Around line 21-33: Update the demo JSX text to fix missing apostrophes:
replace "Im" with "I'm", "Weve" with "We've", and "were" with "we're" in the
paragraph strings shown (the paragraph containing "Im excited..." and the
subsequent paragraph with "Weve been working... were confident..."); ensure the
JSX text nodes are updated exactly to the corrected contractions so rendering
shows proper punctuation.
In `@editor/app/`(dev)/ui/frames/page.tsx:
- Around line 4-10: The import list at the top of page.tsx includes an unused
symbol StarIcon; remove StarIcon from the named import (leaving ArchiveIcon,
ForwardIcon, ReplyIcon, TrashIcon) or replace it with the actual icon used in
the component; update the import statement that currently references
ArchiveIcon, ForwardIcon, ReplyIcon, StarIcon, TrashIcon to exclude StarIcon so
there are no unused imports.
In `@editor/app/`(tenant)/~/[tenant]/api/p/access/with-email/route.ts:
- Around line 174-179: Sanitize the admin-provided display name before
interpolating into the Resend "from" header: when building fromName (derived
from preset_template.from_name or brand_name) strip or escape characters illegal
in RFC 5322 (e.g., double quotes, angle brackets < >, carriage returns/newlines,
control chars) and collapse excess whitespace so the final display name contains
only safe printable characters; then use that sanitized value in the from
interpolation passed to resend.emails.send. Also apply the same sanitization to
brand_name wherever it is used as a display name fallback.
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx:
- Around line 693-698: The preview currently renders admin-authored HTML via
dangerouslySetInnerHTML using body_html_template, which allows stored XSS;
sanitize body_html_template before rendering by integrating a sanitizer (e.g.,
DOMPurify): add the dependency, import the sanitizer into the component that
renders body_html_template (the block using dangerouslySetInnerHTML), call the
sanitizer (e.g., DOMPurify.sanitize) on body_html_template and use the sanitized
output for __html, and keep using the same conditional that checks
body_html_template?.trim() to avoid rendering empty content.
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`:
- Around line 144-151: The preview currently injects admin-authored HTML via
dangerouslySetInnerHTML (in EmailFrameBody using body_html_template), which
static analysis flags for XSS risk; sanitize body_html_template before assigning
it (e.g., run it through DOMPurify.sanitize or an equivalent sanitizer) and use
the sanitized string in the dangerouslySetInnerHTML payload, ensuring you
import/initialize the sanitizer where this component renders and keep a clear
comment that this is a preview-only render for defense-in-depth.
In `@editor/theme/templates/portal-login/202602-default/portal-login-view.tsx`:
- Line 103: The current lookup const t = dictionary[locale as keyof typeof
dictionary] can yield undefined for unsupported locales and cause runtime
TypeErrors; update the lookup to validate locale against the dictionary keys and
fall back to English (e.g., use a conditional or hasOwnProperty check on
dictionary with locale and default to 'en') so that t is always a valid entry
before using t.title/t.description/etc.; touch the dictionary lookup site and
any uses of t in this file (portal-login-view.tsx) to ensure the fallback is
applied.
- Around line 173-179: The code in portal-login-view.tsx uses
dangerouslySetInnerHTML with template(otpStepDescription, { email: otpEmail }),
which allows tenant-controlled otpStepDescription and otpEmail to inject HTML;
replace this unsafe pattern by either (A) sanitizing the generated HTML with a
library like DOMPurify before assigning to dangerouslySetInnerHTML, or (B)
removing innerHTML entirely and rendering safely — implement a helper (e.g.,
renderOtpDescription) that splits otpStepDescription on a safe placeholder like
"{email}" and returns JSX that inserts otpEmail inside a <strong> (or other
element) so the rest of the text is rendered as plain text; update the span that
currently uses dangerouslySetInnerHTML to call the chosen safe approach and
ensure otpEmail is escaped/treated as text.
In `@supabase/migrations/20260207000000_grida_ciam_portal_preset.sql`:
- Around line 136-171: The function grida_ciam_public.set_primary_portal_preset
is created with SECURITY DEFINER but still callable by PUBLIC because PostgreSQL
grants EXECUTE to PUBLIC by default and the current script only grants to
authenticated and service_role; update the migration to explicitly revoke public
privileges (e.g., REVOKE EXECUTE ON FUNCTION
grida_ciam_public.set_primary_portal_preset(bigint, uuid) FROM PUBLIC) and then
grant only to the required roles (authenticated, service_role); also amend the
FUNCTION header’s SET search_path to include pg_catalog (e.g., SET search_path =
pg_catalog, public, extensions, pg_temp) to avoid unsafe lookup behavior.
In `@supabase/schemas/grida_ciam.sql`:
- Around line 815-844: The SECURITY DEFINER function
grida_ciam_public.set_primary_portal_preset uses an unsafe search_path ("public,
extensions, pg_temp") which can allow search-path hijacking; change the
function's SET search_path clause to a safe value that includes pg_catalog first
(e.g., "pg_catalog, public") and remove writable or extension schemas from the
search_path so built-in functions and operators resolve from pg_catalog before
public.
In `@supabase/tests/test_grida_ciam_portal_preset_rls_test.sql`:
- Around line 122-181: Add DELETE isolation tests analogous to the INSERT/UPDATE
ones: use test_set_auth('insider@grida.co') + lives_ok to run "DELETE FROM
grida_ciam_public.portal_preset WHERE id = current_setting('test.preset_b')" to
assert an insider can delete their preset, then test_reset_auth(); and use
test_set_auth('random@example.com') + a DO block that EXECUTEs the same DELETE,
captures ROW_COUNT into a session var (e.g.
set_config('test.outsider_delete_count', affected::text, true) via GET
DIAGNOSTICS) and assert that current_setting('test.outsider_delete_count')::int
= 0 to verify outsider cannot delete, then test_reset_auth(); follow the
existing patterns using lives_ok, throws_ok/DO block, test_set_auth,
test_reset_auth and current_setting('test.preset_b').
🧹 Nitpick comments (12)
editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx (1)
27-52: LocalSidebarMenuLinkduplicates the shared one ineditor/components/sidebar/index.tsx.The shared component uses a
layoutprop for subpath matching while this local version introducesmatchSubpathsandsize. Consider unifying these into the shared component to avoid drift between the two implementations.Not blocking — the local version is self-contained and correct.
editor/theme/templates/portal-login/202602-default/portal-login-view.tsx (1)
183-207: Each OTP slot is wrapped in its ownInputOTPGroup.Typically,
InputOTPGroupwraps multiple slots to visually group them (e.g., groups of 3 for a 6-digit code). Wrapping each slot individually defeats the grouping purpose. Consider grouping into two groups of three, or a single group of six:♻️ Suggested grouping (e.g., 3+3)
<InputOTP maxLength={6} disabled={viewOnly || isLoading} inputMode="numeric" onComplete={viewOnly ? undefined : (otp) => onOtpComplete?.(otp)} > - <InputOTPGroup> - <InputOTPSlot index={0} /> - </InputOTPGroup> - <InputOTPGroup> - <InputOTPSlot index={1} /> - </InputOTPGroup> - <InputOTPGroup> - <InputOTPSlot index={2} /> - </InputOTPGroup> - <InputOTPGroup> - <InputOTPSlot index={3} /> - </InputOTPGroup> - <InputOTPGroup> - <InputOTPSlot index={4} /> - </InputOTPGroup> - <InputOTPGroup> - <InputOTPSlot index={5} /> - </InputOTPGroup> + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + </InputOTPGroup> + <InputOTPGroup> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> </InputOTP>editor/components/frames/email-frame.tsx (1)
86-89: Unreachable fallback inAvatarImagesrc.The
avatar && (...)guard on line 87 already ensuresavataris truthy, soavatar || "/placeholder.svg"on line 88 can never reach the fallback. Simplify to justsrc={avatar}.🧹 Proposed fix
{avatar && ( - <AvatarImage src={avatar || "/placeholder.svg"} alt={name} /> + <AvatarImage src={avatar} alt={name} /> )}editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts (1)
138-150: Silent failure on portal preset query — consider logging.If the
service_role.ciamquery at line 139 errors out,preset_listwill benull/undefined, and the code silently falls back to the default template. This is a reasonable degraded path, but unlike other Supabase errors in this handler, it doesn't log anything. A briefconsole.erroron failure would help debugging.🔧 Suggested improvement
- const { data: preset_list } = await service_role.ciam + const { data: preset_list, error: preset_err } = await service_role.ciam .from("portal_preset") .select("verification_email_template") .eq("project_id", projectId) .eq("is_primary", true) .limit(1); + + if (preset_err) { + console.error("[portal]/error (ok) while fetching portal preset", preset_err); + }editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx (2)
70-73: Parallelize independent async calls.
fetchPortalTitleandfetchLoginPageOverridesare independent and both resolve the tenant name separately. Running them withPromise.allwould reduce waterfall latency on this server-rendered page.♻️ Suggested refactor
const { tenant } = await params; const locale = await getLocale(["en", "ko"]); - const title = await fetchPortalTitle(tenant); - const loginPageOverrides = await fetchLoginPageOverrides(tenant); + const [title, loginPageOverrides] = await Promise.all([ + fetchPortalTitle(tenant), + fetchLoginPageOverrides(tenant), + ]);
33-62: No error logging on failed portal preset queries.Both Supabase queries in
fetchLoginPageOverridesdiscard errors silently. If thewwworportal_presetlookup fails, the function returnsnulland the login page loads without overrides — a reasonable fallback. But without logging, diagnosing why overrides aren't applying in production will be difficult. Consider logging errors similarly to the email route handler.editor/services/ciam/portal-verification-email.ts (1)
46-49:as anycast onvarsbypasses type safety.The
renderfunction expectsTemplateVariables.Context, which is a union of complex context types (GlobalContext, FormContext, FormResponseContext, etc.). Thevarsobject here is justRecord<string, string>with simple string properties. The cast hides this mismatch. Consider either extracting a typed context object or adjusting the function to accept generic record types.editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx (1)
83-86:errorfromusePortalPresetsis unused — no error state shown to the user.If the SWR fetch fails,
presetswill beundefinedand the user will see the loading skeleton forever (sinceisLoadingbecomes false butpresetsis still falsy, showing "No presets yet" — actually that might be acceptable). Consider displaying an error banner whenerroris truthy.supabase/schemas/grida_ciam.sql (2)
787-787:anonrole grantedALLonportal_preset— overly permissive.Granting
ALL(includingINSERT,UPDATE,DELETE) to theanonrole violates the deny-by-default principle. While RLS viarls_project()should block anonymous access, the guideline calls for explicitREVOKE ALL FROM PUBLICfollowed by targeted grants. Portal presets are admin-managed resources — onlyauthenticatedandservice_roleneed access.Proposed fix
-GRANT ALL ON TABLE grida_ciam.portal_preset TO anon, authenticated, service_role; +REVOKE ALL ON TABLE grida_ciam.portal_preset FROM PUBLIC; +GRANT ALL ON TABLE grida_ciam.portal_preset TO authenticated, service_role;As per coding guidelines, "Explicitly grant permissions only to required roles; avoid accidental PUBLIC access by using
REVOKE ALL FROM PUBLIC;followed by specific role grants."
808-808: Sameanonconcern on the public-facing view.The view
grida_ciam_public.portal_presetusessecurity_invoker = true, so RLS on the underlying table applies. However, grantingALLtoanonon the view is still unnecessarily broad for an admin resource. Consider restricting toauthenticatedandservice_role.Proposed fix
-GRANT ALL ON TABLE grida_ciam_public.portal_preset TO anon, authenticated, service_role; +REVOKE ALL ON TABLE grida_ciam_public.portal_preset FROM PUBLIC; +GRANT ALL ON TABLE grida_ciam_public.portal_preset TO authenticated, service_role;As per coding guidelines, "Explicitly grant permissions only to required roles; avoid accidental PUBLIC access."
editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx (2)
112-130: Update operations lackproject_idscoping — relies solely on RLS.The
.update()and.delete()calls throughout this file filter only bypreset.idwithout also filtering byproject_id. While RLS enforces the tenant boundary, adding.eq("project_id", project.id)provides defense-in-depth and makes the intent explicit. This applies to all mutation calls: name update (line 114), login page update (lines 198-201), email template update (lines 229-232), and delete (lines 275-278).Example fix for name update
const req = Promise.resolve( - client.from("portal_preset").update({ name: data.name }).eq("id", params.id) + client.from("portal_preset").update({ name: data.name }).eq("id", params.id).eq("project_id", project.id) ).then(({ error }) => {
290-301: Loading/error state doesn't distinguish fetch errors from initial load.When SWR returns an error,
presetisundefinedandisLoadingisfalse, so the conditionisLoading || !presetshows the skeleton indefinitely. Consider handling the error state explicitly to provide user feedback (e.g. "Preset not found" or retry option).
| Im excited to announce the release of our latest feature update. This | ||
| release includes several new capabilities that will help you work | ||
| more efficiently and effectively. | ||
| </p> | ||
| <p>Some of the key highlights include:</p> | ||
| <ul> | ||
| <li>Improved email search and filtering</li> | ||
| <li>Enhanced email templates and signatures</li> | ||
| <li>Seamless integration with our project management tools</li> | ||
| </ul> | ||
| <p> | ||
| Weve been working hard to deliver these improvements, and were | ||
| confident they will have a positive impact on your daily workflow. |
There was a problem hiding this comment.
Minor typos in demo text: missing apostrophes.
Lines 21, 32: "Im" → "I'm", "Weve" → "We've", "were" → "we're".
🤖 Prompt for AI Agents
In `@editor/app/`(dev)/dev/frames/mail/page.tsx around lines 21 - 33, Update the
demo JSX text to fix missing apostrophes: replace "Im" with "I'm", "Weve" with
"We've", and "were" with "we're" in the paragraph strings shown (the paragraph
containing "Im excited..." and the subsequent paragraph with "Weve been
working... were confident..."); ensure the JSX text nodes are updated exactly to
the corrected contractions so rendering shows proper punctuation.
| import { | ||
| ArchiveIcon, | ||
| ForwardIcon, | ||
| ReplyIcon, | ||
| StarIcon, | ||
| TrashIcon, | ||
| } from "lucide-react"; |
There was a problem hiding this comment.
Unused import: StarIcon.
StarIcon is imported but never referenced in this file.
🧹 Proposed fix
import {
ArchiveIcon,
ForwardIcon,
ReplyIcon,
- StarIcon,
TrashIcon,
} from "lucide-react";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { | |
| ArchiveIcon, | |
| ForwardIcon, | |
| ReplyIcon, | |
| StarIcon, | |
| TrashIcon, | |
| } from "lucide-react"; | |
| import { | |
| ArchiveIcon, | |
| ForwardIcon, | |
| ReplyIcon, | |
| TrashIcon, | |
| } from "lucide-react"; |
🤖 Prompt for AI Agents
In `@editor/app/`(dev)/ui/frames/page.tsx around lines 4 - 10, The import list at
the top of page.tsx includes an unused symbol StarIcon; remove StarIcon from the
named import (leaving ArchiveIcon, ForwardIcon, ReplyIcon, TrashIcon) or replace
it with the actual icon used in the component; update the import statement that
currently references ArchiveIcon, ForwardIcon, ReplyIcon, StarIcon, TrashIcon to
exclude StarIcon so there are no unused imports.
editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts
Outdated
Show resolved
Hide resolved
| {body_html_template?.trim() ? ( | ||
| <div | ||
| dangerouslySetInnerHTML={{ | ||
| __html: body_html_template, | ||
| }} | ||
| /> |
There was a problem hiding this comment.
dangerouslySetInnerHTML renders admin-authored HTML without sanitization.
The body_html_template is stored in the DB and editable by any authenticated project member. While this is an admin-facing preview, a malicious project member could inject script tags that execute in another member's browser when they view the preset (stored XSS within a team). Sanitize the HTML before rendering.
Proposed fix using DOMPurify
Add dompurify as a dependency, then:
+import DOMPurify from "dompurify";
...
{body_html_template?.trim() ? (
<div
dangerouslySetInnerHTML={{
- __html: body_html_template,
+ __html: DOMPurify.sanitize(body_html_template),
}}
/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {body_html_template?.trim() ? ( | |
| <div | |
| dangerouslySetInnerHTML={{ | |
| __html: body_html_template, | |
| }} | |
| /> | |
| import DOMPurify from "dompurify"; | |
| // ... other imports | |
| // At lines 693-698: | |
| {body_html_template?.trim() ? ( | |
| <div | |
| dangerouslySetInnerHTML={{ | |
| __html: DOMPurify.sanitize(body_html_template), | |
| }} | |
| /> |
🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 694-694: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.3.13)
[error] 695-695: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx
around lines 693 - 698, The preview currently renders admin-authored HTML via
dangerouslySetInnerHTML using body_html_template, which allows stored XSS;
sanitize body_html_template before rendering by integrating a sanitizer (e.g.,
DOMPurify): add the dependency, import the sanitizer into the component that
renders body_html_template (the block using dangerouslySetInnerHTML), call the
sanitizer (e.g., DOMPurify.sanitize) on body_html_template and use the sanitized
output for __html, and keep using the same conditional that checks
body_html_template?.trim() to avoid rendering empty content.
| <EmailFrameBody className="prose prose-stone dark:prose-invert max-w-none"> | ||
| {body_html_template?.trim() ? ( | ||
| <div | ||
| // Admin-authored HTML preview. This is only rendered inside the editor. | ||
| dangerouslySetInnerHTML={{ | ||
| __html: body_html_template, | ||
| }} | ||
| /> |
There was a problem hiding this comment.
Acknowledge XSS risk with dangerouslySetInnerHTML — consider sanitization.
Static analysis correctly flags this. While the HTML is admin-authored and the preview is only rendered inside the editor settings panel (self-XSS scenario), sanitizing with a library like DOMPurify would add defense-in-depth — particularly against stored XSS if an admin account is ever compromised or if the template content is shared across roles in the future.
Since this is a preview-only context and the same admin authors the content they see, this is low severity today but worth noting.
🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 147-147: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.3.13)
[error] 148-148: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
🤖 Prompt for AI Agents
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`
around lines 144 - 151, The preview currently injects admin-authored HTML via
dangerouslySetInnerHTML (in EmailFrameBody using body_html_template), which
static analysis flags for XSS risk; sanitize body_html_template before assigning
it (e.g., run it through DOMPurify.sanitize or an equivalent sanitizer) and use
the sanitized string in the dangerouslySetInnerHTML payload, ensuring you
import/initialize the sanitizer where this component renders and keep a clear
comment that this is a preview-only render for defense-in-depth.
| onBack, | ||
| error, | ||
| }: PortalLoginViewProps) { | ||
| const t = dictionary[locale as keyof typeof dictionary]; |
There was a problem hiding this comment.
Runtime crash when locale is not "en" or "ko".
If an unsupported locale string is passed (e.g. "fr", "ja"), dictionary[locale] returns undefined and every subsequent t.title, t.description, etc. will throw a TypeError.
🐛 Proposed fix — fall back to English
- const t = dictionary[locale as keyof typeof dictionary];
+ const t = dictionary[locale as keyof typeof dictionary] ?? dictionary.en;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const t = dictionary[locale as keyof typeof dictionary]; | |
| const t = dictionary[locale as keyof typeof dictionary] ?? dictionary.en; |
🤖 Prompt for AI Agents
In `@editor/theme/templates/portal-login/202602-default/portal-login-view.tsx` at
line 103, The current lookup const t = dictionary[locale as keyof typeof
dictionary] can yield undefined for unsupported locales and cause runtime
TypeErrors; update the lookup to validate locale against the dictionary keys and
fall back to English (e.g., use a conditional or hasOwnProperty check on
dictionary with locale and default to 'en') so that t is always a valid entry
before using t.title/t.description/etc.; touch the dictionary lookup site and
any uses of t in this file (portal-login-view.tsx) to ensure the fallback is
applied.
| <span className="text-sm text-muted-foreground"> | ||
| <span | ||
| dangerouslySetInnerHTML={{ | ||
| __html: template(otpStepDescription, { email: otpEmail }), | ||
| }} | ||
| /> | ||
| </span> |
There was a problem hiding this comment.
XSS risk via dangerouslySetInnerHTML with user-controlled content.
otpStepDescription can originate from overrides?.otp_step_description, which is tenant-editable data stored in the DB. The template() call also interpolates the email value, which could contain malicious markup. Together, this creates an XSS vector.
Consider sanitizing the HTML before injection (e.g., with DOMPurify), or restructuring the description to avoid dangerouslySetInnerHTML entirely — for instance, split the template into segments around {email} and render the email in a <strong> element via JSX:
🔒 Sketch: avoid dangerouslySetInnerHTML
- <CardDescription className="max-w-xs">
- <span className="text-sm text-muted-foreground">
- <span
- dangerouslySetInnerHTML={{
- __html: template(otpStepDescription, { email: otpEmail }),
- }}
- />
- </span>
- </CardDescription>
+ <CardDescription className="max-w-xs text-sm text-muted-foreground">
+ {renderOtpDescription(otpStepDescription, otpEmail)}
+ </CardDescription>Where renderOtpDescription splits on the {email} placeholder and wraps it safely in <strong> without raw HTML injection. If full HTML support in overrides is needed, sanitize with DOMPurify before setting innerHTML.
🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 174-174: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🤖 Prompt for AI Agents
In `@editor/theme/templates/portal-login/202602-default/portal-login-view.tsx`
around lines 173 - 179, The code in portal-login-view.tsx uses
dangerouslySetInnerHTML with template(otpStepDescription, { email: otpEmail }),
which allows tenant-controlled otpStepDescription and otpEmail to inject HTML;
replace this unsafe pattern by either (A) sanitizing the generated HTML with a
library like DOMPurify before assigning to dangerouslySetInnerHTML, or (B)
removing innerHTML entirely and rendering safely — implement a helper (e.g.,
renderOtpDescription) that splits otpStepDescription on a safe placeholder like
"{email}" and returns JSX that inserts otpEmail inside a <strong> (or other
element) so the rest of the text is rendered as plain text; update the span that
currently uses dangerouslySetInnerHTML to call the chosen safe approach and
ensure otpEmail is escaped/treated as text.
- Introduced a new utility function `sanitize_email_display_name` to clean display names for email headers by removing unsafe characters and collapsing whitespace. - Updated the email sending logic to utilize the new sanitization function for both the sender's name and brand name. - Added comprehensive tests for `sanitize_email_display_name` to ensure proper functionality and edge case handling.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@editor/app/`(tenant)/~/[tenant]/api/p/access/with-email/route.ts:
- Around line 176-188: The admin-provided preset_template.reply_to is used
directly when building the payload for resend.emails.send (via the replyTo
variable) and can cause the send to fail if malformed; before passing replyTo
into resend.emails.send, validate/sanitize it (e.g., ensure it contains a single
'@' and basic local/domain parts or a lightweight regex) and only include the
replyTo field when the check passes, otherwise set replyTo to undefined and
surface/log the invalid address (so resend_err isn't triggered by
misconfiguration). Update the code around sanitize_email_display_name,
preset_template.reply_to, replyTo and the resend.emails.send call to perform
this guard and avoid sending malformed reply-to values.
🧹 Nitpick comments (2)
supabase/schemas/grida_ciam.sql (2)
787-787:anonrole gets full DML onportal_preset— intentional?Granting
ALLtoanonon the underlying table (even with RLS) is broader than necessary. Ifrls_project()never returnstruefor anonymous users, the grant is harmless but still widens the attack surface if that function's behavior ever changes. The existing tables in this file follow the same pattern, so this may be a deliberate project convention — but it's worth confirming, especially sinceset_primary_portal_presetalready restricts toauthenticated, service_role.Based on learnings, "Explicitly grant permissions only to required roles; avoid accidental PUBLIC access by using
REVOKE ALL FROM PUBLIC;followed by specific role grants."
746-746:template_idis locked to a single value via"const"— future templates will require a schema migration.The JSON schema constraint uses
"const": "202602-default"fortemplate_id. Adding a new login page template later means altering this CHECK constraint, which on a large table requires anACCESS EXCLUSIVElock. Consider using"enum": ["202602-default"]instead — it's semantically equivalent today but makes it clearer that the set is expected to grow, and the migration pattern (drop + recreate constraint) remains the same either way.
| const fromName = sanitize_email_display_name( | ||
| preset_template.from_name?.trim() || brand_name | ||
| ); | ||
| const replyTo = preset_template.reply_to?.trim() || undefined; | ||
|
|
||
| const { error } = await resend.emails.send({ | ||
| from: `${fromName} <no-reply@accounts.grida.co>`, | ||
| to: emailNormalized, | ||
| subject: renderedSubject, | ||
| html, | ||
| ...(replyTo ? { replyTo } : {}), | ||
| }); | ||
| resend_err = error; |
There was a problem hiding this comment.
replyTo is passed unsanitized — consider basic email validation.
preset_template.reply_to (line 179) is admin-authored and sent directly to Resend. If it contains a malformed address, the Resend API will reject the entire send request, silently failing the verification email (the error is caught at line 216 but the customer never gets their OTP).
A lightweight check (e.g., regex or a simple includes("@") guard) would prevent misconfigured presets from breaking the login flow entirely.
🤖 Prompt for AI Agents
In `@editor/app/`(tenant)/~/[tenant]/api/p/access/with-email/route.ts around lines
176 - 188, The admin-provided preset_template.reply_to is used directly when
building the payload for resend.emails.send (via the replyTo variable) and can
cause the send to fail if malformed; before passing replyTo into
resend.emails.send, validate/sanitize it (e.g., ensure it contains a single '@'
and basic local/domain parts or a lightweight regex) and only include the
replyTo field when the check passes, otherwise set replyTo to undefined and
surface/log the invalid address (so resend_err isn't triggered by
misconfiguration). Update the code around sanitize_email_display_name,
preset_template.reply_to, replyTo and the resend.emails.send call to perform
this guard and avoid sending malformed reply-to values.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@editor/app/`(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx:
- Around line 329-334: The JSX block that renders body_html_template via
dangerouslySetInnerHTML in the component (the conditional that checks
body_html_template?.trim()) poses an XSS risk; sanitize the HTML before
rendering by integrating a sanitizer (e.g., DOMPurify) and passing the sanitized
string to dangerouslySetInnerHTML instead of the raw body_html_template; update
the rendering code that currently creates the <div dangerouslySetInnerHTML={{
__html: body_html_template }} /> to first call the sanitizer (e.g.,
sanitize(body_html_template)) and use that sanitized output, and ensure
types/imports for the sanitizer are added where this component (the page
rendering body_html_template) is defined.
🧹 Nitpick comments (1)
editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx (1)
298-312: Save button when email is toggled off — verify intent.When
emailEnabledisfalse, the form fields are hidden but a Save button is still rendered. This button callshandleEmailSubmit(onEmailSubmit)viaonClick, which will submit the form withenabled: false. This works correctly for persisting the "disable email notifications" toggle.However, the UX message says "Enable the toggle above to customize…" while still showing a Save button — this could be confusing if the user hasn't actually changed the toggle (the button would be disabled via
!emailDirty, so functionally fine, but worth a UX review).
| {body_html_template?.trim() ? ( | ||
| <div | ||
| dangerouslySetInnerHTML={{ | ||
| __html: body_html_template, | ||
| }} | ||
| /> |
There was a problem hiding this comment.
XSS risk: unsanitized HTML rendered via dangerouslySetInnerHTML.
body_html_template is rendered without sanitization. Even though this is an admin-authored preview, if multiple users share project access, one could store a malicious payload (e.g. <img onerror="...">) that executes in another admin's browser session. Sanitize before rendering.
🛡️ Proposed fix using DOMPurify
Install dompurify and @types/dompurify, then:
+import DOMPurify from "dompurify"; {body_html_template?.trim() ? (
<div
dangerouslySetInnerHTML={{
- __html: body_html_template,
+ __html: DOMPurify.sanitize(body_html_template),
}}
/>🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 330-330: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.3.13)
[error] 331-331: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
🤖 Prompt for AI Agents
In `@editor/app/`(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx around
lines 329 - 334, The JSX block that renders body_html_template via
dangerouslySetInnerHTML in the component (the conditional that checks
body_html_template?.trim()) poses an XSS risk; sanitize the HTML before
rendering by integrating a sanitizer (e.g., DOMPurify) and passing the sanitized
string to dangerouslySetInnerHTML instead of the raw body_html_template; update
the rendering code that currently creates the <div dangerouslySetInnerHTML={{
__html: body_html_template }} /> to first call the sanitizer (e.g.,
sanitize(body_html_template)) and use that sanitized output, and ensure
types/imports for the sanitizer are added where this component (the page
rendering body_html_template) is defined.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Fix all issues with AI agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx:
- Around line 250-266: The handleSetPrimary callback calls mutate()
unconditionally after awaiting toast.promise, causing refetch even on RPC
failure; change the flow so mutate() runs only on success by awaiting the RPC
via toast.promise and then invoking mutate() in the success path (for example,
chain .then(() => mutate()) after the awaited toast.promise or use try/catch and
call mutate() only in the try block) — target symbols: handleSetPrimary,
client.rpc("set_primary_portal_preset"), toast.promise, and mutate.
- Around line 292-301: The component currently only checks isLoading and preset,
so when useSWR returns an error the UI stays stuck on the skeleton; update the
page.tsx rendering logic that uses isLoading and preset to also read the error
from useSWR and handle it: when error is present render an error state (a
user-friendly message and a retry control) instead of the skeleton, wiring the
retry control to SWR's mutate/revalidate or the fetch function; reference the
existing useSWR hook and the variables isLoading, preset, and Skeleton to locate
where to add the error branch and the retry handler.
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx:
- Around line 144-151: The Enter key handler on the Input calls handleCreate()
unguarded allowing duplicate submissions; update the onKeyDown logic to only
call handleCreate when not creating and newName.trim() is non-empty (same
condition used by the create button: creating || !newName.trim()). Reference the
Input component's onKeyDown, the newName state, the creating flag, and the
handleCreate() function and short-circuit the call when creating is true or
newName.trim() is falsy.
- Around line 92-106: handleCreate currently awaits
toast.promise(createPreset(...)) inside a try/finally but toast.promise rethrows
the underlying error, causing unhandled rejections; modify handleCreate to catch
errors from toast.promise (either by adding a catch block around the await or
chaining .catch(...) to the promise) so the error is consumed and does not
propagate, while preserving the existing success path that clears newName and
closes the dialog and keeping the finally block that calls setCreating(false);
reference the handleCreate function and createPreset/toast.promise calls when
making the change.
- Around line 108-114: handleSetPrimary currently calls toast.promise which can
re-throw and produce an unhandled rejection; make the handler async and await
toast.promise inside a try/catch (or attach a .catch) so rejections are handled.
Specifically update the handleSetPrimary function to either be declared async
and use: try { await toast.promise(setPrimary(presetId), {...}) } catch (err) {
/* handle/log/show error */ } or return toast.promise(...).catch(() => {}),
referencing handleSetPrimary and setPrimary to locate the change.
- Around line 199-206: The CardDescription rendering uses new
Date(preset.updated_at) but preset.updated_at is nullable; update the display
logic in the component (the JSX around CardDescription / preset.updated_at) to
guard against null by checking preset.updated_at and showing a fallback (e.g.,
"—" or "Not set") when it's null, otherwise formatting with new
Date(preset.updated_at).toLocaleDateString(); ensure you only call new Date(...)
when preset.updated_at is truthy to avoid "Invalid Date".
🧹 Nitpick comments (4)
editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx (2)
182-223: Preset cards lack vertical spacing between items.Each
<Link>wrapping a<Card>is rendered inside aspace-y-3container, but<Link>is an inline element by default. This may cause thespace-y-3gap to not apply consistently across browsers. Consider addingclassName="block"to the<Link>to ensure it participates in the vertical flow.Proposed fix
- <Link key={preset.id} href={`${pathname}/${preset.id}`}> + <Link key={preset.id} href={`${pathname}/${preset.id}`} className="block">
36-81: Consider extractingusePortalPresetsto a shared module.The custom hook contains all the data-fetching and mutation logic for portal presets. Per project conventions, feature-specific reusable logic is typically housed under
editor/scaffolds. If the preset detail page (linked at${pathname}/${preset.id}) will also needcreatePreset/setPrimary, extracting this hook would avoid duplication.Not blocking — fine to defer if the detail page uses a different data pattern.
Based on learnings: "Store feature-specific larger components, pages, and editors in
editor/scaffolds"editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx (2)
84-736: Consider extracting the component body intoeditor/scaffolds.At ~650 lines of mixed logic and JSX, this page component is quite large. The project convention is to store feature-specific larger components in
editor/scaffolds, with the page file acting as a thin re-export. Extracting the core editor into something likeeditor/scaffolds/ciam/portal-preset-edit.tsxwould improve discoverability and testability. Not blocking, just a suggestion for future cleanup. Based on learnings, "Store feature-specific larger components, pages, and editors ineditor/scaffolds".
197-204:as anycast on JSONB columns suppresses type safety.The
as anycasts on lines 200 and 231 bypass TypeScript checking for the JSONB payloads. If thePortalPresetLoginPage/PortalPresetVerificationEmailTemplatetypes match the database column schema, the cast shouldn't be needed. If there's a Supabase type-generation mismatch (JSONB columns often type asJson), consider augmenting the generated types or using a narrower cast (as unknown as Json) to retain some safety.
| const handleSetPrimary = useCallback(async () => { | ||
| const req = Promise.resolve( | ||
| client.rpc("set_primary_portal_preset", { | ||
| p_project_id: project.id, | ||
| p_preset_id: params.id, | ||
| }) | ||
| ).then(({ error }) => { | ||
| if (error) throw error; | ||
| }); | ||
|
|
||
| await toast.promise(req, { | ||
| loading: "Setting primary...", | ||
| success: "This preset is now primary", | ||
| error: "Failed to set primary", | ||
| }); | ||
| mutate(); | ||
| }, [client, project.id, params.id, mutate]); |
There was a problem hiding this comment.
mutate() runs even when the RPC fails.
toast.promise resolves its own promise regardless of whether the inner promise rejected. The mutate() call on line 265 therefore fires unconditionally, triggering a needless refetch on error. Wrap it to only mutate on success, consistent with the other handlers.
Proposed fix
const handleSetPrimary = useCallback(async () => {
const req = Promise.resolve(
client.rpc("set_primary_portal_preset", {
p_project_id: project.id,
p_preset_id: params.id,
})
).then(({ error }) => {
if (error) throw error;
});
- await toast.promise(req, {
- loading: "Setting primary...",
- success: "This preset is now primary",
- error: "Failed to set primary",
- });
- mutate();
+ try {
+ await toast.promise(req, {
+ loading: "Setting primary...",
+ success: "This preset is now primary",
+ error: "Failed to set primary",
+ });
+ mutate();
+ } catch {
+ // toast handles UI
+ }
}, [client, project.id, params.id, mutate]);🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx
around lines 250 - 266, The handleSetPrimary callback calls mutate()
unconditionally after awaiting toast.promise, causing refetch even on RPC
failure; change the flow so mutate() runs only on success by awaiting the RPC
via toast.promise and then invoking mutate() in the success path (for example,
chain .then(() => mutate()) after the awaited toast.promise or use try/catch and
call mutate() only in the try block) — target symbols: handleSetPrimary,
client.rpc("set_primary_portal_preset"), toast.promise, and mutate.
| if (isLoading || !preset) { | ||
| return ( | ||
| <main className="w-full h-full overflow-y-auto"> | ||
| <div className="container mx-auto py-10 space-y-6"> | ||
| <Skeleton className="h-8 w-48" /> | ||
| <Skeleton className="h-64 w-full rounded-lg" /> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Missing error state — fetch failure leaves the user on an infinite skeleton.
When SWR's fetch rejects, isLoading becomes false and preset remains undefined, so the component renders the skeleton indefinitely with no feedback. Consider handling the error return from useSWR and showing an error message or retry option.
Proposed fix
- const { data: preset, isLoading, mutate } = useSWR<PortalPresetRow>(
+ const { data: preset, isLoading, error, mutate } = useSWR<PortalPresetRow>(
key,
async () => { ... }
);
...
- if (isLoading || !preset) {
+ if (isLoading) {
return (
<main className="w-full h-full overflow-y-auto">
<div className="container mx-auto py-10 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full rounded-lg" />
</div>
</main>
);
}
+
+ if (error || !preset) {
+ return (
+ <main className="w-full h-full overflow-y-auto">
+ <div className="container mx-auto py-10 space-y-6">
+ <p className="text-destructive">Failed to load preset.</p>
+ <Button variant="outline" onClick={() => mutate()}>Retry</Button>
+ </div>
+ </main>
+ );
+ }🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx
around lines 292 - 301, The component currently only checks isLoading and
preset, so when useSWR returns an error the UI stays stuck on the skeleton;
update the page.tsx rendering logic that uses isLoading and preset to also read
the error from useSWR and handle it: when error is present render an error state
(a user-friendly message and a retry control) instead of the skeleton, wiring
the retry control to SWR's mutate/revalidate or the fetch function; reference
the existing useSWR hook and the variables isLoading, preset, and Skeleton to
locate where to add the error branch and the retry handler.
| const handleCreate = async () => { | ||
| if (!newName.trim()) return; | ||
| setCreating(true); | ||
| try { | ||
| await toast.promise(createPreset(newName.trim()), { | ||
| loading: "Creating preset...", | ||
| success: "Preset created", | ||
| error: "Failed to create preset", | ||
| }); | ||
| setNewName(""); | ||
| setDialogOpen(false); | ||
| } finally { | ||
| setCreating(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Unhandled promise rejection when preset creation fails.
toast.promise re-throws the underlying error after displaying the toast. Because there is no catch block, the rejection from createPreset propagates as an unhandled promise rejection (visible in the console and may trigger global error handlers). Either add a catch or swallow after the toast:
Proposed fix
const handleCreate = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
await toast.promise(createPreset(newName.trim()), {
loading: "Creating preset...",
success: "Preset created",
error: "Failed to create preset",
});
setNewName("");
setDialogOpen(false);
- } finally {
+ } catch {
+ // error already surfaced via toast
+ } finally {
setCreating(false);
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleCreate = async () => { | |
| if (!newName.trim()) return; | |
| setCreating(true); | |
| try { | |
| await toast.promise(createPreset(newName.trim()), { | |
| loading: "Creating preset...", | |
| success: "Preset created", | |
| error: "Failed to create preset", | |
| }); | |
| setNewName(""); | |
| setDialogOpen(false); | |
| } finally { | |
| setCreating(false); | |
| } | |
| }; | |
| const handleCreate = async () => { | |
| if (!newName.trim()) return; | |
| setCreating(true); | |
| try { | |
| await toast.promise(createPreset(newName.trim()), { | |
| loading: "Creating preset...", | |
| success: "Preset created", | |
| error: "Failed to create preset", | |
| }); | |
| setNewName(""); | |
| setDialogOpen(false); | |
| } catch { | |
| // error already surfaced via toast | |
| } finally { | |
| setCreating(false); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx
around lines 92 - 106, handleCreate currently awaits
toast.promise(createPreset(...)) inside a try/finally but toast.promise rethrows
the underlying error, causing unhandled rejections; modify handleCreate to catch
errors from toast.promise (either by adding a catch block around the await or
chaining .catch(...) to the promise) so the error is consumed and does not
propagate, while preserving the existing success path that clears newName and
closes the dialog and keeping the finally block that calls setCreating(false);
reference the handleCreate function and createPreset/toast.promise calls when
making the change.
| const handleSetPrimary = (presetId: string) => { | ||
| toast.promise(setPrimary(presetId), { | ||
| loading: "Setting primary...", | ||
| success: "Primary preset updated", | ||
| error: "Failed to set primary", | ||
| }); | ||
| }; |
There was a problem hiding this comment.
handleSetPrimary also has an unhandled rejection.
Same pattern as handleCreate — toast.promise re-throws, and the rejection is unhandled. Since this is called directly from an onClick, the browser won't crash, but it will log an unhandled rejection warning.
Proposed fix
const handleSetPrimary = (presetId: string) => {
- toast.promise(setPrimary(presetId), {
+ toast.promise(setPrimary(presetId), {
loading: "Setting primary...",
success: "Primary preset updated",
error: "Failed to set primary",
- });
+ }).catch(() => {});
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleSetPrimary = (presetId: string) => { | |
| toast.promise(setPrimary(presetId), { | |
| loading: "Setting primary...", | |
| success: "Primary preset updated", | |
| error: "Failed to set primary", | |
| }); | |
| }; | |
| const handleSetPrimary = (presetId: string) => { | |
| toast.promise(setPrimary(presetId), { | |
| loading: "Setting primary...", | |
| success: "Primary preset updated", | |
| error: "Failed to set primary", | |
| }).catch(() => {}); | |
| }; |
🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx
around lines 108 - 114, handleSetPrimary currently calls toast.promise which can
re-throw and produce an unhandled rejection; make the handler async and await
toast.promise inside a try/catch (or attach a .catch) so rejections are handled.
Specifically update the handleSetPrimary function to either be declared async
and use: try { await toast.promise(setPrimary(presetId), {...}) } catch (err) {
/* handle/log/show error */ } or return toast.promise(...).catch(() => {}),
referencing handleSetPrimary and setPrimary to locate the change.
| <Input | ||
| placeholder="e.g. Default, VIP Portal" | ||
| value={newName} | ||
| onChange={(e) => setNewName(e.target.value)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") handleCreate(); | ||
| }} | ||
| /> |
There was a problem hiding this comment.
Missing creating guard on Enter key allows double submission.
The onKeyDown handler triggers handleCreate() on Enter without checking the creating state, so a user can press Enter multiple times and fire concurrent inserts before the first completes. The button already has disabled={creating || !newName.trim()} — apply the same guard here.
Proposed fix
onKeyDown={(e) => {
- if (e.key === "Enter") handleCreate();
+ if (e.key === "Enter" && !creating) handleCreate();
}}🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx
around lines 144 - 151, The Enter key handler on the Input calls handleCreate()
unguarded allowing duplicate submissions; update the onKeyDown logic to only
call handleCreate when not creating and newName.trim() is non-empty (same
condition used by the create button: creating || !newName.trim()). Reference the
Input component's onKeyDown, the newName state, the creating flag, and the
handleCreate() function and short-circuit the call when creating is true or
newName.trim() is falsy.
| <CardDescription className="mt-1"> | ||
| {preset.verification_email_template?.enabled | ||
| ? "Custom email template enabled" | ||
| : "Using default email template"} | ||
| {" · "} | ||
| Updated{" "} | ||
| {new Date(preset.updated_at).toLocaleDateString()} | ||
| </CardDescription> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find Supabase database types file
fd -e ts -e tsx | xargs rg -l "portal_preset" | head -20Repository: gridaco/grida
Length of output: 399
🏁 Script executed:
# Examine the database type definitions
cat database/database.types.ts | head -500Repository: gridaco/grida
Length of output: 8449
🏁 Script executed:
# Check the generated database types for portal_preset and updated_at
rg -A 30 "portal_preset.*Row" database/database-generated.types.ts | head -50Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
# Search for portal_preset in the generated types file
rg "portal_preset" database/database-generated.types.ts -A 20 -B 2Repository: gridaco/grida
Length of output: 2083
🏁 Script executed:
# Now let's check the actual page.tsx file around lines 199-206
sed -n '180,220p' 'editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx' | cat -nRepository: gridaco/grida
Length of output: 2157
🏁 Script executed:
# Check what schema/view is being used for presets in the page.tsx
sed -n '1,100p' 'editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx' | grep -E "presets|import|from.*database" | head -30Repository: gridaco/grida
Length of output: 904
🏁 Script executed:
# Check the data fetching for presets to see what type it returns
sed -n '50,150p' 'editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx' | cat -nRepository: gridaco/grida
Length of output: 3842
🏁 Script executed:
# Check the useSWR call and what table/view is being queried for presets
sed -n '20,50p' 'editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx' | cat -nRepository: gridaco/grida
Length of output: 1120
Fix nullable updated_at handling.
preset.updated_at is nullable (string | null in the view), so new Date(preset.updated_at) will produce "Invalid Date" if null. Add a fallback: preset.updated_at ? new Date(preset.updated_at).toLocaleDateString() : "—" (or similar).
(Note: verification_email_template?.enabled is properly typed via the database type override in database.types.ts and does not have a type issue.)
🤖 Prompt for AI Agents
In
`@editor/app/`(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx
around lines 199 - 206, The CardDescription rendering uses new
Date(preset.updated_at) but preset.updated_at is nullable; update the display
logic in the component (the JSX around CardDescription / preset.updated_at) to
guard against null by checking preset.updated_at and showing a fallback (e.g.,
"—" or "Not set") when it's null, otherwise formatting with new
Date(preset.updated_at).toLocaleDateString(); ensure you only call new Date(...)
when preset.updated_at is truthy to avoid "Invalid Date".
Summary by CodeRabbit
Release Notes
New Features
Chores