Skip to content

Enterprise - Customer Portal Customization#531

Merged
softmarshmallow merged 11 commits intomainfrom
enterprise
Feb 7, 2026
Merged

Enterprise - Customer Portal Customization#531
softmarshmallow merged 11 commits intomainfrom
enterprise

Conversation

@softmarshmallow
Copy link
Member

@softmarshmallow softmarshmallow commented Feb 7, 2026

Grida Customer Portal Customizaton
  • New Features
    • Portal presets: per-project presets for login text and verification emails with primary-preset management
    • Email template authoring with rich HTML previews, sender/reply-to controls, and live previewed templates
    • Reusable portal login view with text overrides and interactive/email‑OTP flows
    • Admin UI: list, create, edit, delete, and set-primary presets with in-context previews
    • Composable email-frame UI and sidebar entry for Customer Portal

Summary by CodeRabbit

Release Notes

  • New Features

    • Portal preset management: create, edit, and configure per-project portal presets
    • Customizable login page overrides with live preview
    • Custom HTML email templates for verification emails with real-time preview
    • Set primary portal preset functionality per project
    • Enhanced email display components
  • Chores

    • Streamlined notification channels interface with improved email customization controls

… 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.
@vercel
Copy link

vercel bot commented Feb 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Feb 7, 2026 3:51pm
grida Ready Ready Preview, Comment Feb 7, 2026 3:51pm
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Feb 7, 2026 3:51pm
legacy Ignored Ignored Feb 7, 2026 3:51pm
backgrounds Skipped Skipped Feb 7, 2026 3:51pm
blog Skipped Skipped Feb 7, 2026 3:51pm
viewer Skipped Skipped Feb 7, 2026 3:51pm

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

Walkthrough

This PR introduces a customizable portal preset system enabling projects to configure branded login pages and verification email templates. Changes include a new portal_preset database table, TypeScript types for structured JSON schemas, refactored login/email UI components, and portal management pages for editing presets.

Changes

Cohort / File(s) Summary
Database Schema & Types
database/database-generated.types.ts, database/database.types.ts
Added portal_preset table and view definitions with Row/Insert/Update signatures; introduced strongly-typed JSON fields PortalPresetVerificationEmailTemplate and PortalPresetLoginPage; added set_primary_portal_preset RPC function signature.
Email Frame Components
editor/components/frames/email-frame.tsx, editor/components/frames/mail-app-frame.tsx
Created new composable email frame components (EmailFrame, EmailFrameSubject, EmailFrameSender, EmailFrameBody) replacing the removed monolithic MailAppFrame component.
Portal Login System
editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx, editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx, editor/theme/templates/portal-login/202602-default/portal-login-view.tsx
Refactored PortalLogin component to accept overrides?: PortalPresetLoginPage; added PortalLoginView component with full email/OTP flow; page now fetches login overrides from portal presets via fetchLoginPageOverrides.
Email Verification & Templates
editor/services/ciam/portal-verification-email.ts, editor/services/ciam/portal-verification-email.test.ts, editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts
Introduced renderPortalVerificationEmail service for rendering custom email templates with Handlebars-like placeholders; email endpoint now prefers custom HTML templates from portal presets; includes test coverage.
Email Display Name Sanitization
editor/utils/sanitize.ts, editor/utils/sanitize.test.ts
Added sanitize_email_display_name utility function removing unsafe characters from email display names; includes comprehensive test coverage.
Email Template Authoring
editor/kits/email-template-authoring/index.tsx
Updated ControlledField<T> type to include placeholder?: string in "on" state; refactored renderRow/renderBody to derive placeholders from field configuration instead of parameters.
Notification Settings & Channels
editor/scaffolds/settings/notification-respondent-email-preferences.tsx, editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx
Removed notification respondent email preferences file; integrated email template editing directly into channels page with new EmailFormValues type and form state management via react-hook-form.
Portal Preset Management Pages
editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx, editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx
Added new pages for listing and editing portal presets; includes create/delete dialogs, set-primary actions, name editing, login page override form with live preview, and email template customization with preview.
Sidebar Navigation
editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx
Added "Customer Portal" menu item under CIAM linking to /ciam/portal; updated CIAM menu to use matchSubpaths={false}.
Dev Frames & Documentation
editor/app/(dev)/dev/frames/mail/page.tsx, editor/app/(dev)/ui/frames/page.tsx, editor/components/AGENTS.md
Updated mail frame dev page to use new email frame components; added email frame showcases to UI frames page; updated AGENTS guidelines preventing creation of new component directories.
Database Migration & Tests
supabase/migrations/20260207000000_grida_ciam_portal_preset.sql, supabase/schemas/grida_ciam.sql, supabase/tests/test_grida_ciam_portal_preset_rls_test.sql
Created migration for portal_preset table with JSON schema constraints, RLS policies, primary uniqueness constraint, and set_primary_portal_preset RPC; added comprehensive pgTAP test suite validating read/write isolation and RPC behavior.

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

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • grida_ciam #491: Introduces the same CIAM feature surface including portal presets, portal login page overrides, and email templating infrastructure.
  • chore #507: Modifies the PortalLogin component in editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx affecting the same login component signature and behavior.
  • Grida Forms - Respondent Email Notifications #522: Alters the email template authoring kit (editor/kits/email-template-authoring/index.tsx) with changes to the ControlledField API and placeholder handling.

Suggested labels

migration, feature, database-schema, breaking-change

Poem

🐰 A portal preset blooms, with templates so fine,
Customized logins and emails that shine,
From database to UI, each piece in its place,
Branding the login with elegance and grace!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Enterprise - Customer Portal Customization' directly and clearly summarizes the main change—adding enterprise-level customer portal customization features including login page overrides, email templates, and preset management.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch enterprise

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

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

Comment on lines +174 to +176
<span
dangerouslySetInnerHTML={{
__html: template(otpStepDescription, { email: otpEmail }),

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 | 🟡 Minor

Missing .catch() for network errors in sendEmail call.

If sendEmail rejects (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 resets isLoading; 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: Local SidebarMenuLink duplicates the shared one in editor/components/sidebar/index.tsx.

The shared component uses a layout prop for subpath matching while this local version introduces matchSubpaths and size. 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 own InputOTPGroup.

Typically, InputOTPGroup wraps 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 in AvatarImage src.

The avatar && (...) guard on line 87 already ensures avatar is truthy, so avatar || "/placeholder.svg" on line 88 can never reach the fallback. Simplify to just src={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.ciam query at line 139 errors out, preset_list will be null/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 brief console.error on 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.

fetchPortalTitle and fetchLoginPageOverrides are independent and both resolve the tenant name separately. Running them with Promise.all would 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 fetchLoginPageOverrides discard errors silently. If the www or portal_preset lookup fails, the function returns null and 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 any cast on vars bypasses type safety.

The render function expects TemplateVariables.Context, which is a union of complex context types (GlobalContext, FormContext, FormResponseContext, etc.). The vars object here is just Record<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: error from usePortalPresets is unused — no error state shown to the user.

If the SWR fetch fails, presets will be undefined and the user will see the loading skeleton forever (since isLoading becomes false but presets is still falsy, showing "No presets yet" — actually that might be acceptable). Consider displaying an error banner when error is truthy.

supabase/schemas/grida_ciam.sql (2)

787-787: anon role granted ALL on portal_preset — overly permissive.

Granting ALL (including INSERT, UPDATE, DELETE) to the anon role violates the deny-by-default principle. While RLS via rls_project() should block anonymous access, the guideline calls for explicit REVOKE ALL FROM PUBLIC followed by targeted grants. Portal presets are admin-managed resources — only authenticated and service_role need 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: Same anon concern on the public-facing view.

The view grida_ciam_public.portal_preset uses security_invoker = true, so RLS on the underlying table applies. However, granting ALL to anon on the view is still unnecessarily broad for an admin resource. Consider restricting to authenticated and service_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 lack project_id scoping — relies solely on RLS.

The .update() and .delete() calls throughout this file filter only by preset.id without also filtering by project_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, preset is undefined and isLoading is false, so the condition isLoading || !preset shows the skeleton indefinitely. Consider handling the error state explicitly to provide user feedback (e.g. "Preset not found" or retry option).

Comment on lines +21 to +33
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.
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +4 to +10
import {
ArchiveIcon,
ForwardIcon,
ReplyIcon,
StarIcon,
TrashIcon,
} from "lucide-react";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +693 to +698
{body_html_template?.trim() ? (
<div
dangerouslySetInnerHTML={{
__html: body_html_template,
}}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Suggested change
{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.

Comment on lines 144 to 151
<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,
}}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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];
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +173 to +179
<span className="text-sm text-muted-foreground">
<span
dangerouslySetInnerHTML={{
__html: template(otpStepDescription, { email: otpEmail }),
}}
/>
</span>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

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.

@softmarshmallow softmarshmallow changed the title Enterprise Enterprise - Customer Portal Customization Feb 7, 2026
- 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.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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: anon role gets full DML on portal_preset — intentional?

Granting ALL to anon on the underlying table (even with RLS) is broader than necessary. If rls_project() never returns true for 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 since set_primary_portal_preset already restricts to authenticated, 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_id is locked to a single value via "const" — future templates will require a schema migration.

The JSON schema constraint uses "const": "202602-default" for template_id. Adding a new login page template later means altering this CHECK constraint, which on a large table requires an ACCESS EXCLUSIVE lock. 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.

Comment on lines +176 to +188
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;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 emailEnabled is false, the form fields are hidden but a Save button is still rendered. This button calls handleEmailSubmit(onEmailSubmit) via onClick, which will submit the form with enabled: 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).

Comment on lines +329 to +334
{body_html_template?.trim() ? (
<div
dangerouslySetInnerHTML={{
__html: body_html_template,
}}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

@vercel vercel bot temporarily deployed to Preview – viewer February 7, 2026 15:48 Inactive
@vercel vercel bot temporarily deployed to Preview – backgrounds February 7, 2026 15:48 Inactive
@vercel vercel bot temporarily deployed to Preview – blog February 7, 2026 15:48 Inactive
@softmarshmallow softmarshmallow merged commit e85e607 into main Feb 7, 2026
10 of 11 checks passed
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 a space-y-3 container, but <Link> is an inline element by default. This may cause the space-y-3 gap to not apply consistently across browsers. Consider adding className="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 extracting usePortalPresets to 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 need createPreset / 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 into editor/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 like editor/scaffolds/ciam/portal-preset-edit.tsx would improve discoverability and testability. Not blocking, just a suggestion for future cleanup. Based on learnings, "Store feature-specific larger components, pages, and editors in editor/scaffolds".


197-204: as any cast on JSONB columns suppresses type safety.

The as any casts on lines 200 and 231 bypass TypeScript checking for the JSONB payloads. If the PortalPresetLoginPage / PortalPresetVerificationEmailTemplate types match the database column schema, the cast shouldn't be needed. If there's a Supabase type-generation mismatch (JSONB columns often type as Json), consider augmenting the generated types or using a narrower cast (as unknown as Json) to retain some safety.

Comment on lines +250 to +266
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]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +292 to +301
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>
);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +92 to +106
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);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +108 to +114
const handleSetPrimary = (presetId: string) => {
toast.promise(setPrimary(presetId), {
loading: "Setting primary...",
success: "Primary preset updated",
error: "Failed to set primary",
});
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

handleSetPrimary also has an unhandled rejection.

Same pattern as handleCreatetoast.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.

Suggested change
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.

Comment on lines +144 to +151
<Input
placeholder="e.g. Default, VIP Portal"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +199 to +206
<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>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find Supabase database types file
fd -e ts -e tsx | xargs rg -l "portal_preset" | head -20

Repository: gridaco/grida

Length of output: 399


🏁 Script executed:

# Examine the database type definitions
cat database/database.types.ts | head -500

Repository: 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 -50

Repository: 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 2

Repository: 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 -n

Repository: 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 -30

Repository: 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 -n

Repository: 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 -n

Repository: 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant