From 4bbb53471d415c6508c0f06f5b48d5f1c4bb145d Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 7 Feb 2026 22:03:55 +0900 Subject: [PATCH 01/11] Add MailAppFrame integration to FramesPage and enhance email template 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. --- editor/app/(dev)/ui/frames/page.tsx | 113 +++++++++++++++++- editor/components/frames/mail-app-frame.tsx | 24 ++-- .../kits/email-template-authoring/index.tsx | 47 ++------ ...ification-respondent-email-preferences.tsx | 7 +- 4 files changed, 137 insertions(+), 54 deletions(-) diff --git a/editor/app/(dev)/ui/frames/page.tsx b/editor/app/(dev)/ui/frames/page.tsx index 777de1ca05..af347d5b51 100644 --- a/editor/app/(dev)/ui/frames/page.tsx +++ b/editor/app/(dev)/ui/frames/page.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Safari, SafariToolbar } from "@/components/frames/safari"; +import MailAppFrame from "@/components/frames/mail-app-frame"; export default function FramesPage() { return ( @@ -173,11 +174,115 @@ export default function FramesPage() {
+
+

Mail (sidebar hidden)

+

+ Email client frame for previewing email templates +

+
+ +

Your verification code

+

+ Hi Alice, use the following code to verify your identity: +

+

+ 123456 +

+

This code expires in 10 minutes.

+
+

+ If you did not request this, you can safely ignore this + email. +

+
+
+
+
+

+ Mail (long content, scrollable) +

+

+ Email body scrolls when content exceeds the frame height +

+
+ +

Thanks for registering!

+

We received your submission for the Annual Conference.

+

Your registration number: #042

+

What happens next?

+
    +
  • You will receive a confirmation email within 24 hours
  • +
  • Our team will review your application
  • +
  • If approved, you will get your ticket via email
  • +
+

Event details

+

+ Date: March 15, 2026 +
+ Location: Convention Center, Hall A
+ Time: 9:00 AM - 5:00 PM +

+

Important notes

+

+ Please bring a valid ID and your ticket (digital or printed) + to the event. Doors open at 8:30 AM for registration. +

+

+ If you have any dietary requirements, please let us know at + least 48 hours before the event. +

+

We look forward to seeing you there!

+
+

+ This is an automated message. If you did not submit this + form, please contact support@example.com. +

+
+
+
-

- More frame styles (Mail, Messages, etc.) are available in the - components library. -

diff --git a/editor/components/frames/mail-app-frame.tsx b/editor/components/frames/mail-app-frame.tsx index fbebda8305..a964167c4e 100644 --- a/editor/components/frames/mail-app-frame.tsx +++ b/editor/components/frames/mail-app-frame.tsx @@ -46,7 +46,7 @@ export default function MailAppFrame({ return (
-
-
+
+
@@ -129,11 +129,11 @@ export default function MailAppFrame({
-
-
-
-
-
+
+
+
+
+

Inbox

{today.toLocaleDateString()} @@ -173,10 +173,10 @@ export default function MailAppFrame({
-
-
-
-

{message.title}

+
+
+
+

{message.title}

{message.at}
diff --git a/editor/kits/email-template-authoring/index.tsx b/editor/kits/email-template-authoring/index.tsx index 4b98273754..3c6335a6ba 100644 --- a/editor/kits/email-template-authoring/index.tsx +++ b/editor/kits/email-template-authoring/index.tsx @@ -24,6 +24,7 @@ export type ControlledField = value: T; onValueChange: (value: T) => void; disabled?: boolean; + placeholder?: string; }; export type EmailTemplateAuthoringKitProps = { @@ -50,16 +51,15 @@ export type EmailTemplateAuthoringKitProps = { function renderRow({ label, field, - placeholder, }: { label: string; field: ControlledField; - placeholder?: string; }) { if (field.state === "off") return null; const disabled = field.state === "disabled" ? true : Boolean(field.disabled); + const placeholder = field.state === "on" ? field.placeholder : undefined; return ( ; - placeholder?: string; -}) { +function renderBody({ field }: { field: ControlledField }) { if (field.state === "off") return null; const disabled = field.state === "disabled" ? true : Boolean(field.disabled); + const placeholder = field.state === "on" ? field.placeholder : undefined; return ( @@ -129,34 +124,12 @@ export function EmailTemplateAuthoringKit({ {notice}
- {renderRow({ - label: "To:", - field: fields.to, - })} - {renderRow({ - label: "Reply to:", - field: fields.replyTo, - placeholder: "support@yourdomain.com", - })} - {renderRow({ - label: "Subject:", - field: fields.subject, - placeholder: "Thanks, {{fields.first_name}}", - })} - {renderRow({ - label: "From name:", - field: fields.fromName, - placeholder: "Grida Forms", - })} - {renderRow({ - label: "From:", - field: fields.from, - })} - {renderBody({ - field: fields.bodyHtml, - placeholder: - "

Thanks

\n

We received your submission for {{form_title}}.

", - })} + {renderRow({ label: "To:", field: fields.to })} + {renderRow({ label: "Reply to:", field: fields.replyTo })} + {renderRow({ label: "Subject:", field: fields.subject })} + {renderRow({ label: "From name:", field: fields.fromName })} + {renderRow({ label: "From:", field: fields.from })} + {renderBody({ field: fields.bodyHtml })}
{helper} diff --git a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx index d6a660881a..6762795b14 100644 --- a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx +++ b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx @@ -126,7 +126,7 @@ export function NotificationRespondentEmailPreferences() { } /> -
+
setValue("reply_to", v || null, { shouldDirty: true, @@ -203,6 +204,7 @@ export function NotificationRespondentEmailPreferences() { state: "on", value: subject_template ?? "", disabled: inputDisabled, + placeholder: "Thanks, {{fields.first_name}}", onValueChange: (v: string) => setValue("subject_template", v || null, { shouldDirty: true, @@ -212,6 +214,7 @@ export function NotificationRespondentEmailPreferences() { state: "on", value: from_name ?? "", disabled: inputDisabled, + placeholder: "Grida Forms", onValueChange: (v: string) => setValue("from_name", v || null, { shouldDirty: true }), }, @@ -223,6 +226,8 @@ export function NotificationRespondentEmailPreferences() { state: "on", value: body_html_template ?? "", disabled: inputDisabled, + placeholder: + "

Thanks

\n

We received your submission for {{form_title}}.

", onValueChange: (v: string) => setValue("body_html_template", v || null, { shouldDirty: true, From 52e5328c44086a3cc10b04f308edf8ef793dd30d Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 7 Feb 2026 22:30:22 +0900 Subject: [PATCH 02/11] Add portal presets management and email template customization - 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. --- database/database-generated.types.ts | 67 ++- database/database.types.ts | 43 ++ .../[tenant]/api/p/access/with-email/route.ts | 99 +++- .../(resources)/ciam/presets/[id]/page.tsx | 461 ++++++++++++++++++ .../(resources)/ciam/presets/page.tsx | 229 +++++++++ .../(resources)/console-resources-sidebar.tsx | 6 +- .../ciam/portal-verification-email.test.ts | 54 ++ .../ciam/portal-verification-email.ts | 50 ++ ...0260207000000_grida_ciam_portal_preset.sql | 142 ++++++ supabase/schemas/grida_ciam.sql | 126 +++++ ...test_grida_ciam_portal_preset_rls_test.sql | 246 ++++++++++ 11 files changed, 1497 insertions(+), 26 deletions(-) create mode 100644 editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx create mode 100644 editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx create mode 100644 editor/services/ciam/portal-verification-email.test.ts create mode 100644 editor/services/ciam/portal-verification-email.ts create mode 100644 supabase/migrations/20260207000000_grida_ciam_portal_preset.sql create mode 100644 supabase/tests/test_grida_ciam_portal_preset_rls_test.sql diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index 059e0eb7d4..ec826c9c7a 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -187,6 +187,36 @@ export type Database = { } Relationships: [] } + portal_preset: { + Row: { + created_at: string + id: string + is_primary: boolean + name: string + project_id: number + updated_at: string + verification_email_template: Json + } + Insert: { + created_at?: string + id?: string + is_primary?: boolean + name: string + project_id: number + updated_at?: string + verification_email_template?: Json + } + Update: { + created_at?: string + id?: string + is_primary?: boolean + name?: string + project_id?: number + updated_at?: string + verification_email_template?: Json + } + Relationships: [] + } } Views: { [_ in never]: never @@ -295,6 +325,36 @@ export type Database = { } Relationships: [] } + portal_preset: { + Row: { + created_at: string | null + id: string | null + is_primary: boolean | null + name: string | null + project_id: number | null + updated_at: string | null + verification_email_template: Json | null + } + Insert: { + created_at?: string | null + id?: string | null + is_primary?: boolean | null + name?: string | null + project_id?: number | null + updated_at?: string | null + verification_email_template?: Json | null + } + Update: { + created_at?: string | null + id?: string | null + is_primary?: boolean | null + name?: string | null + project_id?: number | null + updated_at?: string | null + verification_email_template?: Json | null + } + Relationships: [] + } } Functions: { create_customer_otp_challenge: { @@ -337,6 +397,10 @@ export type Database = { Args: { p_customer_uid: string; p_project_id: number } Returns: undefined } + set_primary_portal_preset: { + Args: { p_preset_id: string; p_project_id: number } + Returns: undefined + } touch_customer_portal_session: { Args: { p_min_seconds_between_touches?: number; p_token: string } Returns: { @@ -5162,5 +5226,4 @@ export const Constants = { pricing_tier: ["free", "v0_pro", "v0_team", "v0_enterprise"], }, }, -} as const - +} as const \ No newline at end of file diff --git a/database/database.types.ts b/database/database.types.ts index f8e582af6d..0bc976d75f 100644 --- a/database/database.types.ts +++ b/database/database.types.ts @@ -49,6 +49,19 @@ export type FormNotificationRespondentEmailConfig = { reply_to?: string | null; }; +/** + * `grida_ciam.portal_preset.verification_email_template` + * + * DB-enforced JSON schema (same shape as FormNotificationRespondentEmailConfig). + */ +export type PortalPresetVerificationEmailTemplate = { + enabled?: boolean; + from_name?: string | null; + subject_template?: string | null; + body_html_template?: string | null; + reply_to?: string | null; +}; + // Override the type for a specific column in a view: export type Database = MergeDeep< DatabaseGenerated, @@ -74,6 +87,36 @@ export type Database = MergeDeep< tags?: string[] | null; }; }; + portal_preset: { + Row: { + id: string; + created_at: string; + updated_at: string; + project_id: number; + name: string; + is_primary: boolean; + verification_email_template: PortalPresetVerificationEmailTemplate; + }; + Insert: { + id?: string; + created_at?: string; + updated_at?: string; + project_id: number; + name: string; + is_primary?: boolean; + verification_email_template?: PortalPresetVerificationEmailTemplate; + }; + Update: { + id?: string; + created_at?: string; + updated_at?: string; + project_id?: number; + name?: string; + is_primary?: boolean; + verification_email_template?: PortalPresetVerificationEmailTemplate; + }; + Relationships: []; + }; }; }; grida_library: { diff --git a/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts b/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts index 39d4432e7b..30d3e1dd6f 100644 --- a/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts +++ b/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts @@ -9,6 +9,8 @@ import TenantCustomerPortalAccessEmailVerification, { import { otp6 } from "@/lib/crypto/otp"; import { select_lang } from "@/i18n/utils"; import { getLocale } from "@/i18n/server"; +import { renderPortalVerificationEmail } from "@/services/ciam/portal-verification-email"; +import type { PortalPresetVerificationEmailTemplate } from "@app/database"; // TODO: add rate limiting export async function POST( req: NextRequest, @@ -133,29 +135,80 @@ export async function POST( : undefined; const brand_support_contact = publisher.includes("@") ? publisher : undefined; - // Prefer the visitor's device language. If unsupported, fall back to the tenant default. - const fallback_lang = select_lang(www.lang, supported_languages, "en"); - const emailLang: CustomerPortalVerificationEmailLang = await getLocale( - [...supported_languages], - fallback_lang - ); - - const { error: resend_err } = await resend.emails.send({ - from: `${brand_name} `, - to: emailNormalized, - subject: subject(emailLang, { - brand_name, - email_otp: otp, - }), - react: TenantCustomerPortalAccessEmailVerification({ - email_otp: otp, - customer_name: customer.name ?? undefined, - brand_name: brand_name, - brand_support_url, - brand_support_contact, - lang: emailLang, - }), - }); + // Check for a primary portal preset with a custom email template. + const { data: preset_list } = await service_role.ciam + .from("portal_preset") + .select("verification_email_template") + .eq("project_id", projectId) + .eq("is_primary", true) + .limit(1); + + const preset_template: PortalPresetVerificationEmailTemplate | null = + preset_list && preset_list.length > 0 + ? (preset_list[0] + .verification_email_template as PortalPresetVerificationEmailTemplate) + : null; + + const useCustomTemplate = + preset_template?.enabled && + typeof preset_template.body_html_template === "string" && + preset_template.body_html_template.trim().length > 0; + + let resend_err: Error | null = null; + + if (useCustomTemplate) { + // Admin-authored HTML template via Handlebars. + const { subject: renderedSubject, html } = renderPortalVerificationEmail({ + subject_template: preset_template.subject_template ?? null, + body_html_template: preset_template.body_html_template!, + context: { + email_otp: otp, + brand_name, + customer_name: customer.name ?? undefined, + expires_in_minutes, + brand_support_url, + brand_support_contact, + }, + }); + + const fromName = + preset_template.from_name?.trim() || brand_name; + const replyTo = preset_template.reply_to?.trim() || undefined; + + const { error } = await resend.emails.send({ + from: `${fromName} `, + to: emailNormalized, + subject: renderedSubject, + html, + ...(replyTo ? { replyTo } : {}), + }); + resend_err = error; + } else { + // Default React Email template. + const fallback_lang = select_lang(www.lang, supported_languages, "en"); + const emailLang: CustomerPortalVerificationEmailLang = await getLocale( + [...supported_languages], + fallback_lang + ); + + const { error } = await resend.emails.send({ + from: `${brand_name} `, + to: emailNormalized, + subject: subject(emailLang, { + brand_name, + email_otp: otp, + }), + react: TenantCustomerPortalAccessEmailVerification({ + email_otp: otp, + customer_name: customer.name ?? undefined, + brand_name: brand_name, + brand_support_url, + brand_support_contact, + lang: emailLang, + }), + }); + resend_err = error; + } if (resend_err) { console.error("[portal]/error while sending email", resend_err, email); diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx new file mode 100644 index 0000000000..ab8b039be5 --- /dev/null +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx @@ -0,0 +1,461 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import useSWR from "swr"; +import { createBrowserCIAMClient } from "@/lib/supabase/client"; +import { useProject } from "@/scaffolds/workspace"; +import { useForm, useWatch, Controller } from "react-hook-form"; +import { toast } from "sonner"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + PreferenceBody, + PreferenceBox, + PreferenceBoxFooter, + PreferenceBoxHeader, + PreferenceDescription, +} from "@/components/preferences"; +import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; +import MailAppFrame from "@/components/frames/mail-app-frame"; +import { + DeleteConfirmationAlertDialog, + DeleteConfirmationSnippet, +} from "@/components/dialogs/delete-confirmation-dialog"; +import { useDialogState } from "@/components/hooks/use-dialog-state"; +import { ArrowLeftIcon, StarIcon } from "lucide-react"; +import Link from "next/link"; +import type { Database, PortalPresetVerificationEmailTemplate } from "@app/database"; + +type PortalPresetRow = + Database["grida_ciam_public"]["Views"]["portal_preset"]["Row"]; + +type FormValues = { + name: string; + enabled: boolean; + from_name: string | null; + subject_template: string | null; + body_html_template: string | null; + reply_to: string | null; +}; + +function presetToFormValues(preset: PortalPresetRow): FormValues { + const t = preset.verification_email_template ?? {}; + return { + name: preset.name, + enabled: t.enabled ?? false, + from_name: t.from_name ?? null, + subject_template: t.subject_template ?? null, + body_html_template: t.body_html_template ?? null, + reply_to: t.reply_to ?? null, + }; +} + +export default function PortalPresetEditPage() { + const params = useParams<{ id: string; org: string; proj: string }>(); + const router = useRouter(); + const project = useProject(); + const client = useMemo(() => createBrowserCIAMClient(), []); + + const key = `portal-preset-${params.id}`; + + const { data: preset, isLoading, mutate } = useSWR( + key, + async () => { + const { data, error } = await client + .from("portal_preset") + .select("*") + .eq("id", params.id) + .eq("project_id", project.id) + .single(); + + if (error) throw error; + return data; + } + ); + + // --- Name form (isolated) --- + const nameForm = useForm<{ name: string }>({ + values: preset ? { name: preset.name } : undefined, + }); + + const onNameSubmit = async (data: { name: string }) => { + const req = Promise.resolve( + client.from("portal_preset").update({ name: data.name }).eq("id", params.id) + ).then(({ error }) => { + if (error) throw error; + }); + + try { + await toast.promise(req, { + loading: "Saving...", + success: "Name saved", + error: "Failed to save name", + }); + mutate(); + nameForm.reset(data); + } catch { + // toast handles UI + } + }; + + // --- Email template form (isolated) --- + const { + handleSubmit, + control, + setValue, + formState: { isSubmitting, isDirty }, + reset, + } = useForm>({ + values: preset + ? (() => { + const { name: _, ...rest } = presetToFormValues(preset); + return rest; + })() + : undefined, + }); + + const enabled = useWatch({ control, name: "enabled" }); + const reply_to = useWatch({ control, name: "reply_to" }); + const from_name = useWatch({ control, name: "from_name" }); + const subject_template = useWatch({ control, name: "subject_template" }); + const body_html_template = useWatch({ control, name: "body_html_template" }); + + const onSubmit = async (data: Omit) => { + const template: PortalPresetVerificationEmailTemplate = { + enabled: data.enabled, + from_name: data.from_name, + subject_template: data.subject_template, + body_html_template: data.body_html_template, + reply_to: data.reply_to, + }; + + const req = Promise.resolve( + client + .from("portal_preset") + .update({ verification_email_template: template as any }) + .eq("id", params.id) + ).then(({ error }) => { + if (error) throw error; + }); + + try { + await toast.promise(req, { + loading: "Saving...", + success: "Saved", + error: "Failed to save", + }); + mutate(); + reset(data); + } catch { + // toast handles UI + } + }; + + 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]); + + const deleteDialog = useDialogState<{ id: string; match: string }>( + "delete-preset", + { refreshkey: true } + ); + + const handleDelete = useCallback( + async (data: { id: string }) => { + const { error } = await client + .from("portal_preset") + .delete() + .eq("id", data.id); + if (error) { + toast.error("Failed to delete preset"); + return false; + } + toast.success("Preset deleted"); + router.push(`/${params.org}/${params.proj}/ciam/presets`); + return true; + }, + [client, params, router] + ); + + const basePath = `/${params.org}/${params.proj}/ciam/presets`; + + if (isLoading || !preset) { + return ( +
+
+ + +
+
+ ); + } + + return ( +
+
+
+ + + Back to Presets + +
+
+

{preset.name}

+ {preset.is_primary && ( + + + Primary + + )} +
+
+ {!preset.is_primary && ( + + )} + +
+
+
+ +
+ {/* Preset name */} + + + Preset Name + + + ( + + )} + /> + + + + + + + {/* Email template */} + + ( + + )} + /> + } + /> + {enabled ? ( + <> + +
+ + {body_html_template?.trim() ? ( +
+ ) : ( + <> +

Your verification code is: 123456

+

+ Tip: add a body HTML template below to preview the + email content here. +

+ + )} + +
+
+ + Supported variables:{" "} + {"{{email_otp}}"},{" "} + {"{{brand_name}}"},{" "} + {"{{customer_name}}"},{" "} + {"{{expires_in_minutes}}"},{" "} + {"{{brand_support_url}}"},{" "} + {"{{brand_support_contact}}"}. + + } + fields={{ + to: { + state: "disabled", + value: "Customer (verified email)", + }, + replyTo: { + state: "on", + value: reply_to ?? "", + placeholder: "support@yourdomain.com", + onValueChange: (v: string) => + setValue("reply_to", v || null, { + shouldDirty: true, + }), + }, + subject: { + state: "on", + value: subject_template ?? "", + placeholder: + "{{email_otp}} - {{brand_name}} verification code", + onValueChange: (v: string) => + setValue("subject_template", v || null, { + shouldDirty: true, + }), + }, + fromName: { + state: "on", + value: from_name ?? "", + placeholder: "{{brand_name}}", + onValueChange: (v: string) => + setValue("from_name", v || null, { + shouldDirty: true, + }), + }, + from: { + state: "disabled", + value: `${from_name?.trim() || "Portal"} `, + }, + bodyHtml: { + state: "on", + value: body_html_template ?? "", + placeholder: + "

Your verification code

\n

Hi {{customer_name}}, your code is {{email_otp}}.

\n

This code expires in {{expires_in_minutes}} minutes.

", + onValueChange: (v: string) => + setValue("body_html_template", v || null, { + shouldDirty: true, + }), + }, + }} + /> + + + + ) : ( + + + Enable the toggle above to customize the verification email. + When disabled, the default Grida verification email is used. + + + )} + + + + +
+
+ + This action cannot be undone. Type{" "} + + {deleteDialog.data?.match} + {" "} + to confirm. + + } + match={deleteDialog.data?.match} + onDelete={handleDelete} + /> +
+ ); +} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx new file mode 100644 index 0000000000..463b85ba13 --- /dev/null +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import useSWR from "swr"; +import { createBrowserCIAMClient } from "@/lib/supabase/client"; +import { useProject } from "@/scaffolds/workspace"; +import { toast } from "sonner"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { PlusIcon, StarIcon } from "lucide-react"; +import type { Database } from "@app/database"; + +type PortalPresetRow = + Database["grida_ciam_public"]["Views"]["portal_preset"]["Row"]; + +function usePortalPresets() { + const project = useProject(); + const client = useMemo(() => createBrowserCIAMClient(), []); + + const key = `portal-presets-${project.id}`; + + const { data, isLoading, error, mutate } = useSWR( + key, + async () => { + const { data, error } = await client + .from("portal_preset") + .select("*") + .eq("project_id", project.id) + .order("created_at", { ascending: true }); + + if (error) throw error; + return data ?? []; + } + ); + + const createPreset = useCallback( + async (name: string) => { + const { error } = await client.from("portal_preset").insert({ + project_id: project.id, + name, + }); + if (error) throw error; + mutate(); + }, + [client, project.id, mutate] + ); + + const setPrimary = useCallback( + async (presetId: string) => { + const { error } = await client.rpc("set_primary_portal_preset", { + p_project_id: project.id, + p_preset_id: presetId, + }); + if (error) throw error; + mutate(); + }, + [client, project.id, mutate] + ); + + return { presets: data, isLoading, error, createPreset, setPrimary }; +} + +export default function PortalPresetsPage() { + const { presets, isLoading, createPreset, setPrimary } = + usePortalPresets(); + const pathname = usePathname(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(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); + } finally { + setCreating(false); + } + }; + + const handleSetPrimary = (presetId: string) => { + toast.promise(setPrimary(presetId), { + loading: "Setting primary...", + success: "Primary preset updated", + error: "Failed to set primary", + }); + }; + + return ( +
+
+
+
+ + Portal Presets + +

+ Manage customer portal variants and customize the OTP verification + email template. +

+
+ + + + + + + Create Portal Preset + + Give your preset a name. You can configure the email template + after creation. + + + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> + + + + + +
+ + {isLoading ? ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ) : !presets || presets.length === 0 ? ( + + + No presets yet + + Create a portal preset to customize the verification email sent + to customers when they log in. + + + + ) : ( +
+ {presets.map((preset) => ( + + + +
+ + {preset.name} + {preset.is_primary && ( + + + Primary + + )} + + + {preset.verification_email_template?.enabled + ? "Custom email template enabled" + : "Using default email template"} + {" · "} + Updated{" "} + {new Date(preset.updated_at).toLocaleDateString()} + +
+ {!preset.is_primary && ( + + )} +
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx index 245e6e30a4..b1946324b4 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx @@ -15,7 +15,7 @@ import { import { ResourceTypeIcon } from "@/components/resource-type-icon"; import { DarwinSidebarHeaderDragArea } from "@/host/desktop"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; -import { HomeIcon, ShieldCheckIcon } from "lucide-react"; +import { HomeIcon, MailIcon, ShieldCheckIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -99,6 +99,10 @@ export function ConsoleResourcesSidebar({ CIAM + + + Portal Presets + diff --git a/editor/services/ciam/portal-verification-email.test.ts b/editor/services/ciam/portal-verification-email.test.ts new file mode 100644 index 0000000000..241f8a9fca --- /dev/null +++ b/editor/services/ciam/portal-verification-email.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { renderPortalVerificationEmail } from "./portal-verification-email"; + +describe("portal-verification-email", () => { + const baseContext = { + email_otp: "123456", + brand_name: "Acme Co", + customer_name: "Alice", + expires_in_minutes: 10, + brand_support_url: "https://acme.co/support", + brand_support_contact: "support@acme.co", + }; + + test("renders handlebars variables in subject and body", () => { + const { subject, html } = renderPortalVerificationEmail({ + subject_template: "{{email_otp}} - {{brand_name}} code", + body_html_template: + "

Hi {{customer_name}}, your code is {{email_otp}}. Expires in {{expires_in_minutes}} min.

", + context: baseContext, + }); + + expect(subject).toBe("123456 - Acme Co code"); + expect(html).toBe( + "

Hi Alice, your code is 123456. Expires in 10 min.

" + ); + }); + + test("uses default subject when subject_template is null", () => { + const { subject } = renderPortalVerificationEmail({ + subject_template: null, + body_html_template: "

Code: {{email_otp}}

", + context: baseContext, + }); + + expect(subject).toBe("123456 - Acme Co verification code"); + }); + + test("handles optional context fields gracefully", () => { + const { html } = renderPortalVerificationEmail({ + subject_template: null, + body_html_template: + "

{{customer_name}} {{brand_support_url}} {{brand_support_contact}}

", + context: { + email_otp: "999999", + brand_name: "Test", + expires_in_minutes: 5, + // customer_name, brand_support_url, brand_support_contact omitted + }, + }); + + // Omitted values render as empty strings + expect(html).toBe("

"); + }); +}); diff --git a/editor/services/ciam/portal-verification-email.ts b/editor/services/ciam/portal-verification-email.ts new file mode 100644 index 0000000000..4ba1ed920b --- /dev/null +++ b/editor/services/ciam/portal-verification-email.ts @@ -0,0 +1,50 @@ +import { render } from "@/lib/templating/template"; + +export interface PortalVerificationEmailContext { + email_otp: string; + brand_name: string; + customer_name?: string; + expires_in_minutes: number; + brand_support_url?: string; + brand_support_contact?: string; +} + +/** + * Render an admin-authored portal verification email using Handlebars. + * + * Supported template variables: + * - `{{email_otp}}` – OTP code + * - `{{brand_name}}` – tenant site title + * - `{{customer_name}}` – optional + * - `{{expires_in_minutes}}` – OTP expiry + * - `{{brand_support_url}}` – optional + * - `{{brand_support_contact}}` – optional + */ +export function renderPortalVerificationEmail({ + subject_template, + body_html_template, + context, +}: { + subject_template: string | null; + body_html_template: string; + context: PortalVerificationEmailContext; +}) { + const vars = { + email_otp: context.email_otp, + brand_name: context.brand_name, + customer_name: context.customer_name ?? "", + expires_in_minutes: String(context.expires_in_minutes), + brand_support_url: context.brand_support_url ?? "", + brand_support_contact: context.brand_support_contact ?? "", + }; + + const subjectSource = + subject_template?.trim() || + `{{email_otp}} - {{brand_name}} verification code`; + const htmlSource = body_html_template.trim(); + + return { + subject: render(subjectSource, vars as any), + html: render(htmlSource, vars as any), + }; +} diff --git a/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql b/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql new file mode 100644 index 0000000000..3911f6a66b --- /dev/null +++ b/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql @@ -0,0 +1,142 @@ +-- grida_ciam: portal presets +-- +-- Adds a per-project "portal preset" table so admins can create multiple +-- portal variants, pick one as primary, and customise the customer-portal +-- OTP verification email with admin-authored HTML (Handlebars). + +--------------------------------------------------------------------- +-- [grida_ciam.portal_preset] +--------------------------------------------------------------------- + +CREATE TABLE grida_ciam.portal_preset ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + project_id bigint NOT NULL REFERENCES public.project(id) ON DELETE CASCADE, + name text NOT NULL, + is_primary boolean NOT NULL DEFAULT false, + verification_email_template jsonb NOT NULL DEFAULT '{}'::jsonb +); + +-- JSON schema guard (mirrors grida_forms.form.notification_respondent_email shape). +ALTER TABLE grida_ciam.portal_preset + ADD CONSTRAINT portal_preset_verification_email_template_check + CHECK ( + extensions.jsonb_matches_schema( + '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "from_name": { "type": ["string", "null"] }, + "subject_template": { "type": ["string", "null"] }, + "body_html_template": { "type": ["string", "null"] }, + "reply_to": { "type": ["string", "null"] } + } + }'::json, + verification_email_template + ) + ); + +-- At most one primary preset per project. +CREATE UNIQUE INDEX portal_preset_primary_per_project + ON grida_ciam.portal_preset (project_id) + WHERE (is_primary = true); + +-- Lookup index for the runtime hot-path (resolve primary by project). +CREATE INDEX portal_preset_project_idx + ON grida_ciam.portal_preset (project_id); + +--------------------------------------------------------------------- +-- [updated_at trigger] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_ciam.set_portal_preset_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_portal_preset_updated_at + BEFORE UPDATE ON grida_ciam.portal_preset + FOR EACH ROW + EXECUTE FUNCTION grida_ciam.set_portal_preset_updated_at(); + +--------------------------------------------------------------------- +-- [RLS] +--------------------------------------------------------------------- + +ALTER TABLE grida_ciam.portal_preset ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "access_based_on_project_membership" + ON grida_ciam.portal_preset + USING (public.rls_project(project_id)) + WITH CHECK (public.rls_project(project_id)); + +GRANT ALL ON TABLE grida_ciam.portal_preset TO anon, authenticated, service_role; + +--------------------------------------------------------------------- +-- [grida_ciam_public.portal_preset] (public-facing view) +--------------------------------------------------------------------- + +CREATE VIEW grida_ciam_public.portal_preset +WITH (security_invoker = true) +AS +SELECT + id, + created_at, + updated_at, + project_id, + name, + is_primary, + verification_email_template +FROM grida_ciam.portal_preset; + +GRANT ALL ON TABLE grida_ciam_public.portal_preset TO anon, authenticated, service_role; + +--------------------------------------------------------------------- +-- [grida_ciam_public.set_primary_portal_preset] +-- Atomically sets one preset as primary for a project. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_ciam_public.set_primary_portal_preset( + p_project_id bigint, + p_preset_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions, pg_temp +AS $function$ +BEGIN + -- Enforce project membership (same check as RLS). + IF NOT public.rls_project(p_project_id) THEN + RAISE EXCEPTION 'access denied'; + END IF; + + -- Verify the preset belongs to the requested project. + IF NOT EXISTS ( + SELECT 1 FROM grida_ciam.portal_preset + WHERE id = p_preset_id AND project_id = p_project_id + ) THEN + RAISE EXCEPTION 'preset not found'; + END IF; + + -- Clear current primary (if any) and promote the chosen preset. + UPDATE grida_ciam.portal_preset + SET is_primary = false + WHERE project_id = p_project_id AND is_primary = true; + + UPDATE grida_ciam.portal_preset + SET is_primary = true + WHERE id = p_preset_id; +END; +$function$; + +GRANT EXECUTE ON FUNCTION grida_ciam_public.set_primary_portal_preset(bigint, uuid) + TO authenticated, service_role; diff --git a/supabase/schemas/grida_ciam.sql b/supabase/schemas/grida_ciam.sql index d96242cc3b..f7a44b6d09 100644 --- a/supabase/schemas/grida_ciam.sql +++ b/supabase/schemas/grida_ciam.sql @@ -695,3 +695,129 @@ $$; GRANT EXECUTE ON FUNCTION grida_ciam_public.revoke_customer_portal_sessions(bigint, uuid) TO service_role; + +--------------------------------------------------------------------- +-- [grida_ciam.portal_preset] +-- Per-project portal presets (multiple allowed, one primary). +-- Stores admin-authored verification email template as JSONB. +--------------------------------------------------------------------- + +CREATE TABLE grida_ciam.portal_preset ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + project_id bigint NOT NULL REFERENCES public.project(id) ON DELETE CASCADE, + name text NOT NULL, + is_primary boolean NOT NULL DEFAULT false, + verification_email_template jsonb NOT NULL DEFAULT '{}'::jsonb +); + +ALTER TABLE grida_ciam.portal_preset + ADD CONSTRAINT portal_preset_verification_email_template_check + CHECK ( + extensions.jsonb_matches_schema( + '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "from_name": { "type": ["string", "null"] }, + "subject_template": { "type": ["string", "null"] }, + "body_html_template": { "type": ["string", "null"] }, + "reply_to": { "type": ["string", "null"] } + } + }'::json, + verification_email_template + ) + ); + +CREATE UNIQUE INDEX portal_preset_primary_per_project + ON grida_ciam.portal_preset (project_id) + WHERE (is_primary = true); + +CREATE INDEX portal_preset_project_idx + ON grida_ciam.portal_preset (project_id); + +CREATE OR REPLACE FUNCTION grida_ciam.set_portal_preset_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_portal_preset_updated_at + BEFORE UPDATE ON grida_ciam.portal_preset + FOR EACH ROW + EXECUTE FUNCTION grida_ciam.set_portal_preset_updated_at(); + +ALTER TABLE grida_ciam.portal_preset ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "access_based_on_project_membership" + ON grida_ciam.portal_preset + USING (public.rls_project(project_id)) + WITH CHECK (public.rls_project(project_id)); + +GRANT ALL ON TABLE grida_ciam.portal_preset TO anon, authenticated, service_role; + +--------------------------------------------------------------------- +-- [grida_ciam_public.portal_preset] +-- Public-facing view +--------------------------------------------------------------------- + +CREATE VIEW grida_ciam_public.portal_preset +WITH (security_invoker = true) +AS +SELECT + id, + created_at, + updated_at, + project_id, + name, + is_primary, + verification_email_template +FROM grida_ciam.portal_preset; + +GRANT ALL ON TABLE grida_ciam_public.portal_preset TO anon, authenticated, service_role; + +--------------------------------------------------------------------- +-- [grida_ciam_public.set_primary_portal_preset] +-- Atomically sets one preset as primary for a project. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_ciam_public.set_primary_portal_preset( + p_project_id bigint, + p_preset_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions, pg_temp +AS $function$ +BEGIN + IF NOT public.rls_project(p_project_id) THEN + RAISE EXCEPTION 'access denied'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM grida_ciam.portal_preset + WHERE id = p_preset_id AND project_id = p_project_id + ) THEN + RAISE EXCEPTION 'preset not found'; + END IF; + + UPDATE grida_ciam.portal_preset + SET is_primary = false + WHERE project_id = p_project_id AND is_primary = true; + + UPDATE grida_ciam.portal_preset + SET is_primary = true + WHERE id = p_preset_id; +END; +$function$; + +GRANT EXECUTE ON FUNCTION grida_ciam_public.set_primary_portal_preset(bigint, uuid) + TO authenticated, service_role; diff --git a/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql b/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql new file mode 100644 index 0000000000..75771e50fb --- /dev/null +++ b/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql @@ -0,0 +1,246 @@ +BEGIN; +SELECT plan(14); + +--------------------------------------------------------------------- +-- Seed fixtures +--------------------------------------------------------------------- + +DO $$ +DECLARE + insider_user_id uuid; + outsider_user_id uuid; + alice_user_id uuid; + test_project_id bigint; + acme_project_id bigint; + preset_a uuid; + preset_b uuid; +BEGIN + SELECT id INTO insider_user_id FROM auth.users WHERE email = 'insider@grida.co'; + SELECT id INTO outsider_user_id FROM auth.users WHERE email = 'random@example.com'; + SELECT id INTO alice_user_id FROM auth.users WHERE email = 'alice@acme.com'; + SELECT id INTO test_project_id FROM public.project WHERE name = 'dev'; + SELECT id INTO acme_project_id FROM public.project + WHERE organization_id = (SELECT id FROM public.organization WHERE name = 'acme') + LIMIT 1; + + PERFORM set_config('test.insider_user_id', insider_user_id::text, false); + PERFORM set_config('test.outsider_user_id', outsider_user_id::text, false); + PERFORM set_config('test.alice_user_id', alice_user_id::text, false); + PERFORM set_config('test.project_id', test_project_id::text, false); + PERFORM set_config('test.acme_project_id', COALESCE(acme_project_id::text, '0'), false); + + -- Create two presets for insider's project + INSERT INTO grida_ciam.portal_preset (project_id, name, is_primary) + VALUES (test_project_id, 'Default', true) + RETURNING id INTO preset_a; + + INSERT INTO grida_ciam.portal_preset (project_id, name, is_primary) + VALUES (test_project_id, 'Secondary', false) + RETURNING id INTO preset_b; + + PERFORM set_config('test.preset_a', preset_a::text, false); + PERFORM set_config('test.preset_b', preset_b::text, false); +END $$; + +-- Reusable helpers (same as existing CIAM tests) +CREATE OR REPLACE FUNCTION test_set_auth(user_email text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + user_id uuid; +BEGIN + SELECT id INTO user_id FROM auth.users WHERE email = user_email; + PERFORM set_config('request.jwt.claim.sub', user_id::text, true); + SET LOCAL ROLE authenticated; +END; +$$; + +CREATE OR REPLACE FUNCTION test_reset_auth() +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config('request.jwt.claim.sub', '', true); + RESET ROLE; +$$; + +--------------------------------------------------------------------- +-- portal_preset read isolation +--------------------------------------------------------------------- + +-- Test 1: Insider can read their presets +SELECT test_set_auth('insider@grida.co'); +SELECT is( + (SELECT COUNT(*) FROM grida_ciam_public.portal_preset + WHERE project_id = current_setting('test.project_id')::bigint), + 2::bigint, + 'Insider should see 2 portal presets for their project' +); +SELECT test_reset_auth(); + +-- Test 2: Outsider cannot see insider's presets +SELECT test_set_auth('random@example.com'); +SELECT is( + (SELECT COUNT(*) FROM grida_ciam_public.portal_preset + WHERE project_id = current_setting('test.project_id')::bigint), + 0::bigint, + 'Outsider should not see portal presets from insider project' +); +SELECT test_reset_auth(); + +-- Test 3: Anon cannot see presets +SET ROLE anon; +SELECT is( + (SELECT COUNT(*) FROM grida_ciam_public.portal_preset + WHERE project_id = current_setting('test.project_id')::bigint), + 0::bigint, + 'Anon should not see portal presets' +); +RESET ROLE; + +-- Test 4: Alice (acme) cannot see insider's presets +SELECT test_set_auth('alice@acme.com'); +SELECT is( + (SELECT COUNT(*) FROM grida_ciam_public.portal_preset + WHERE project_id = current_setting('test.project_id')::bigint), + 0::bigint, + 'Alice (acme) should not see insider project portal presets' +); +SELECT test_reset_auth(); + +-- Test 5: Service role can see all +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 FROM grida_ciam_public.portal_preset + WHERE project_id = current_setting('test.project_id')::bigint + ), + 'Service role should bypass RLS and see portal presets' +); +RESET ROLE; + +--------------------------------------------------------------------- +-- portal_preset write isolation +--------------------------------------------------------------------- + +-- Test 6: Insider can insert a preset into their project +SELECT test_set_auth('insider@grida.co'); +SELECT lives_ok( + $$ + INSERT INTO grida_ciam_public.portal_preset (project_id, name) + VALUES (current_setting('test.project_id')::bigint, 'New Preset') + $$, + 'Insider can insert preset into their project' +); +SELECT test_reset_auth(); + +-- Test 7: Outsider cannot insert a preset into insider's project +SELECT test_set_auth('random@example.com'); +SELECT throws_ok( + $$ + INSERT INTO grida_ciam_public.portal_preset (project_id, name) + VALUES (current_setting('test.project_id')::bigint, 'Hacked') + $$, + NULL, + NULL, + 'Outsider cannot insert preset into insider project' +); +SELECT test_reset_auth(); + +-- Test 8: Insider can update their own preset +SELECT test_set_auth('insider@grida.co'); +SELECT lives_ok( + format( + 'UPDATE grida_ciam_public.portal_preset SET name = %L WHERE id = %L', + 'Renamed', + current_setting('test.preset_b') + ), + 'Insider can update their own preset' +); +SELECT test_reset_auth(); + +-- Test 9: Outsider cannot update insider's preset +SELECT test_set_auth('random@example.com'); +DO $$ +DECLARE + affected int; +BEGIN + EXECUTE format( + 'UPDATE grida_ciam_public.portal_preset SET name = %L WHERE id = %L', + 'Hacked', + current_setting('test.preset_b') + ); + GET DIAGNOSTICS affected = ROW_COUNT; + PERFORM set_config('test.outsider_update_count', affected::text, true); +END $$; +SELECT is( + current_setting('test.outsider_update_count')::int, + 0, + 'Outsider update should affect 0 rows (RLS hides the row)' +); +SELECT test_reset_auth(); + +--------------------------------------------------------------------- +-- set_primary_portal_preset RPC +--------------------------------------------------------------------- + +-- Test 10: Insider can set primary via RPC +SELECT test_set_auth('insider@grida.co'); +SELECT lives_ok( + format( + $$SELECT grida_ciam_public.set_primary_portal_preset(%s, %L)$$, + current_setting('test.project_id')::bigint, + current_setting('test.preset_b') + ), + 'Insider can call set_primary_portal_preset for their project' +); +SELECT test_reset_auth(); + +-- Test 11: After RPC, preset_b is primary and preset_a is not +SET ROLE service_role; +SELECT ok( + (SELECT is_primary FROM grida_ciam.portal_preset + WHERE id = current_setting('test.preset_b')::uuid), + 'preset_b should be primary after RPC' +); +SELECT ok( + NOT (SELECT is_primary FROM grida_ciam.portal_preset + WHERE id = current_setting('test.preset_a')::uuid), + 'preset_a should no longer be primary after RPC' +); +RESET ROLE; + +-- Test 13: Outsider cannot call set_primary_portal_preset for insider's project +SELECT test_set_auth('random@example.com'); +SELECT throws_ok( + format( + $$SELECT grida_ciam_public.set_primary_portal_preset(%s, %L)$$, + current_setting('test.project_id')::bigint, + current_setting('test.preset_a') + ), + NULL, + NULL, + 'Outsider cannot call set_primary_portal_preset for insider project' +); +SELECT test_reset_auth(); + +--------------------------------------------------------------------- +-- view uses security_invoker +--------------------------------------------------------------------- + +-- Test 14: View has security_invoker = true +SELECT is( + (SELECT COUNT(*) + FROM pg_views v + JOIN pg_class c ON c.relname = v.viewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = v.schemaname + WHERE v.schemaname = 'grida_ciam_public' + AND v.viewname = 'portal_preset' + AND c.reloptions IS NOT NULL + AND array_to_string(c.reloptions, ',') LIKE '%security_invoker=true%'), + 1::bigint, + 'portal_preset view should have security_invoker = true' +); + +SELECT * FROM finish(); +ROLLBACK; From 56e85e18729a556f7abd4216f8052484c2722c9f Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 7 Feb 2026 23:09:29 +0900 Subject: [PATCH 03/11] Enhance portal login page customization and schema - 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. --- database/database-generated.types.ts | 9 +- database/database.types.ts | 51 +++-- .../(tenant)/~/[tenant]/(p)/p/login/login.tsx | 46 ++-- .../(tenant)/~/[tenant]/(p)/p/login/page.tsx | 52 ++++- .../(resources)/ciam/presets/[id]/page.tsx | 203 ++++++++++++++++-- ...0260207000000_grida_ciam_portal_preset.sql | 33 ++- supabase/schemas/grida_ciam.sql | 30 ++- 7 files changed, 348 insertions(+), 76 deletions(-) diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index ec826c9c7a..e8202309ee 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -193,6 +193,7 @@ export type Database = { id: string is_primary: boolean name: string + portal_login_page: Json project_id: number updated_at: string verification_email_template: Json @@ -202,6 +203,7 @@ export type Database = { id?: string is_primary?: boolean name: string + portal_login_page?: Json project_id: number updated_at?: string verification_email_template?: Json @@ -211,6 +213,7 @@ export type Database = { id?: string is_primary?: boolean name?: string + portal_login_page?: Json project_id?: number updated_at?: string verification_email_template?: Json @@ -331,6 +334,7 @@ export type Database = { id: string | null is_primary: boolean | null name: string | null + portal_login_page: Json | null project_id: number | null updated_at: string | null verification_email_template: Json | null @@ -340,6 +344,7 @@ export type Database = { id?: string | null is_primary?: boolean | null name?: string | null + portal_login_page?: Json | null project_id?: number | null updated_at?: string | null verification_email_template?: Json | null @@ -349,6 +354,7 @@ export type Database = { id?: string | null is_primary?: boolean | null name?: string | null + portal_login_page?: Json | null project_id?: number | null updated_at?: string | null verification_email_template?: Json | null @@ -5226,4 +5232,5 @@ export const Constants = { pricing_tier: ["free", "v0_pro", "v0_team", "v0_enterprise"], }, }, -} as const \ No newline at end of file +} as const + diff --git a/database/database.types.ts b/database/database.types.ts index 0bc976d75f..d6bd905f18 100644 --- a/database/database.types.ts +++ b/database/database.types.ts @@ -62,6 +62,27 @@ export type PortalPresetVerificationEmailTemplate = { reply_to?: string | null; }; +/** + * `grida_ciam.portal_preset.portal_login_page` + * + * DB-enforced JSON schema for login page text overrides. + * + * The required `template_id` discriminator allows future schema revisions: + * introduce a new template_id value (e.g. "202607-v2") with its own shape, + * add it to the union, and the old DB constraint will reject stale rows, + * forcing an explicit data migration. + */ +export type PortalPresetLoginPage = PortalPresetLoginPage_202602Default; + +export type PortalPresetLoginPage_202602Default = { + template_id: "202602-default"; + email_step_title?: string | null; + email_step_description?: string | null; + email_step_button_label?: string | null; + otp_step_title?: string | null; + otp_step_description?: string | null; +}; + // Override the type for a specific column in a view: export type Database = MergeDeep< DatabaseGenerated, @@ -88,34 +109,20 @@ export type Database = MergeDeep< }; }; portal_preset: { - Row: { - id: string; - created_at: string; - updated_at: string; - project_id: number; - name: string; - is_primary: boolean; + // View mirrors the table 1:1; reference the table type and only + // narrow the two JSONB columns from Json to their enforced shapes. + Row: Omit & { verification_email_template: PortalPresetVerificationEmailTemplate; + portal_login_page: PortalPresetLoginPage; }; - Insert: { - id?: string; - created_at?: string; - updated_at?: string; - project_id: number; - name: string; - is_primary?: boolean; + Insert: Omit & { verification_email_template?: PortalPresetVerificationEmailTemplate; + portal_login_page?: PortalPresetLoginPage; }; - Update: { - id?: string; - created_at?: string; - updated_at?: string; - project_id?: number; - name?: string; - is_primary?: boolean; + Update: Omit & { verification_email_template?: PortalPresetVerificationEmailTemplate; + portal_login_page?: PortalPresetLoginPage; }; - Relationships: []; }; }; }; diff --git a/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx b/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx index 3fb9fcc8aa..a22fad5acc 100644 --- a/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx +++ b/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx @@ -21,6 +21,7 @@ import { Spinner } from "@/components/ui/spinner"; import { template } from "@/utils/template"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import type { PortalPresetLoginPage } from "@app/database"; type Step = "email" | "otp"; @@ -34,36 +35,42 @@ const dictionary = { sending: "Sending...", verification: "Verification", verification_description: - "If you have an account, We have sent a code to {email}. Enter it below.", + 'If you have an account, We have sent a code to {email}. Enter it below.', verifying: "Verifying...", back: "← Back", }, ko: { - // TODO: This is enterprise-specific copy. Replace hardcoding with a proper - // template/i18n engine (e.g. template variables per tenant/campaign). - // title: "계속 하려면 로그인하세요", - title: "Polestar 추천 프로그램 로그인", // TODO: remove - // description: "이메일을 입력하시면 고객 포털 인증 코드를 보내드립니다.", - description: - "Polestar 차량 구매 시 사용하신 Polestar ID (이메일 주소)를 입력하여 로그인하세요.", // TODO: remove + title: "계속 하려면 로그인하세요", + description: "이메일을 입력하시면 고객 포털 인증 코드를 보내드립니다.", email: "이메일", continue_with_email: "이메일로 계속하기", sending: "전송중...", verification: "인증하기", verification_description: - "입력하신 {email}로 인증 코드를 발송하였습니다. 아래에 입력해 주세요. 코드를 수신하지 못한 경우, 정확한 이메일을 입력하였는지 다시 한 번 확인해 주세요.", + '입력하신 {email}로 인증 코드를 발송하였습니다. 아래에 입력해 주세요. 코드를 수신하지 못한 경우, 정확한 이메일을 입력하였는지 다시 한 번 확인해 주세요.', verifying: "인증중...", back: "← 뒤로", }, }; -interface CustomerPropsMinimalCustomizationProps { +/** + * Helper: returns the override value if it is a non-empty string, otherwise the fallback. + */ +function ov(override: string | null | undefined, fallback: string): string { + return typeof override === "string" && override.trim().length > 0 + ? override + : fallback; +} + +interface PortalLoginProps { locale?: string; + overrides?: PortalPresetLoginPage | null; } export default function PortalLogin({ locale = "en", -}: CustomerPropsMinimalCustomizationProps) { + overrides, +}: PortalLoginProps) { const router = useRouter(); const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); @@ -153,6 +160,13 @@ export default function PortalLogin({ const t = dictionary[locale as keyof typeof dictionary]; + // Apply preset overrides (if any) on top of the locale dictionary. + const emailStepTitle = ov(overrides?.email_step_title, t.title); + const emailStepDescription = ov(overrides?.email_step_description, t.description); + const emailStepButtonLabel = ov(overrides?.email_step_button_label, t.continue_with_email); + const otpStepTitle = ov(overrides?.otp_step_title, t.verification); + const otpStepDescription = ov(overrides?.otp_step_description, t.verification_description); + return (
{step === "email" && ( @@ -164,9 +178,9 @@ export default function PortalLogin({
-

{t.title}

+

{emailStepTitle}

- {t.description} + {emailStepDescription}
@@ -183,7 +197,7 @@ export default function PortalLogin({ />
@@ -193,13 +207,13 @@ export default function PortalLogin({ - {t.verification} + {otpStepTitle} diff --git a/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx b/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx index a78865e6a9..555bee2bef 100644 --- a/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx +++ b/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx @@ -2,8 +2,8 @@ import React from "react"; import PortalLogin from "./login"; import { getLocale } from "@/i18n/server"; import Link from "next/link"; -import { createWWWClient } from "@/lib/supabase/server"; -import type { Database } from "@app/database"; +import { createWWWClient, service_role } from "@/lib/supabase/server"; +import type { Database, PortalPresetLoginPage } from "@app/database"; type Params = { tenant: string; @@ -11,7 +11,7 @@ type Params = { type WwwPublicRow = Database["grida_www"]["Views"]["www_public"]["Row"]; -async function fetchPortalTitle(tenant: string) { +async function fetchPortalTitle(tenant: string): Promise { const client = await createWWWClient(); const { data: wwwPublic } = await client @@ -21,12 +21,45 @@ async function fetchPortalTitle(tenant: string) { .single() .returns>(); - const title = - typeof wwwPublic?.title === "string" && wwwPublic.title.trim() - ? wwwPublic.title - : "Customer Portal"; + return typeof wwwPublic?.title === "string" && wwwPublic.title.trim() + ? wwwPublic.title + : "Customer Portal"; +} + +/** + * Resolves tenant name -> project_id -> primary portal preset -> login page overrides. + * Uses service_role because portal_preset requires project_id which is not on www_public. + */ +async function fetchLoginPageOverrides( + tenant: string +): Promise { + // Resolve tenant -> project_id + const { data: www } = await service_role.www + .from("www") + .select("project_id") + .eq("name", tenant) + .single(); + + const projectId = www?.project_id != null ? Number(www.project_id) : null; + if (!projectId) return null; - return title; + // Fetch primary preset + const { data } = await service_role.ciam + .from("portal_preset") + .select("portal_login_page") + .eq("project_id", projectId) + .eq("is_primary", true) + .limit(1); + + if (!data || data.length === 0) return null; + + const raw = data[0].portal_login_page as PortalPresetLoginPage | null; + if (!raw || typeof raw !== "object") return null; + + const hasValue = Object.values(raw).some( + (v) => typeof v === "string" && v.trim().length > 0 + ); + return hasValue ? raw : null; } export default async function CustomerPortalLoginPage({ @@ -37,6 +70,7 @@ export default async function CustomerPortalLoginPage({ const { tenant } = await params; const locale = await getLocale(["en", "ko"]); const title = await fetchPortalTitle(tenant); + const loginPageOverrides = await fetchLoginPageOverrides(tenant); return (
@@ -58,7 +92,7 @@ export default async function CustomerPortalLoginPage({
- +
diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx index ab8b039be5..07b06e18f4 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx @@ -36,13 +36,17 @@ import { import { useDialogState } from "@/components/hooks/use-dialog-state"; import { ArrowLeftIcon, StarIcon } from "lucide-react"; import Link from "next/link"; -import type { Database, PortalPresetVerificationEmailTemplate } from "@app/database"; +import { Label } from "@/components/ui/label"; +import type { + Database, + PortalPresetVerificationEmailTemplate, + PortalPresetLoginPage, +} from "@app/database"; type PortalPresetRow = Database["grida_ciam_public"]["Views"]["portal_preset"]["Row"]; -type FormValues = { - name: string; +type EmailFormValues = { enabled: boolean; from_name: string | null; subject_template: string | null; @@ -50,17 +54,13 @@ type FormValues = { reply_to: string | null; }; -function presetToFormValues(preset: PortalPresetRow): FormValues { - const t = preset.verification_email_template ?? {}; - return { - name: preset.name, - enabled: t.enabled ?? false, - from_name: t.from_name ?? null, - subject_template: t.subject_template ?? null, - body_html_template: t.body_html_template ?? null, - reply_to: t.reply_to ?? null, - }; -} +type LoginPageFormValues = { + email_step_title: string; + email_step_description: string; + email_step_button_label: string; + otp_step_title: string; + otp_step_description: string; +}; export default function PortalPresetEditPage() { const params = useParams<{ id: string; org: string; proj: string }>(); @@ -111,20 +111,25 @@ export default function PortalPresetEditPage() { }; // --- Email template form (isolated) --- + const emailForm = useForm({ + values: preset + ? { + enabled: preset.verification_email_template?.enabled ?? false, + from_name: preset.verification_email_template?.from_name ?? null, + subject_template: preset.verification_email_template?.subject_template ?? null, + body_html_template: preset.verification_email_template?.body_html_template ?? null, + reply_to: preset.verification_email_template?.reply_to ?? null, + } + : undefined, + }); + const { handleSubmit, control, setValue, formState: { isSubmitting, isDirty }, reset, - } = useForm>({ - values: preset - ? (() => { - const { name: _, ...rest } = presetToFormValues(preset); - return rest; - })() - : undefined, - }); + } = emailForm; const enabled = useWatch({ control, name: "enabled" }); const reply_to = useWatch({ control, name: "reply_to" }); @@ -132,7 +137,52 @@ export default function PortalPresetEditPage() { const subject_template = useWatch({ control, name: "subject_template" }); const body_html_template = useWatch({ control, name: "body_html_template" }); - const onSubmit = async (data: Omit) => { + // --- Login page text form (isolated) --- + const loginPageForm = useForm({ + values: preset + ? { + email_step_title: preset.portal_login_page?.email_step_title ?? "", + email_step_description: preset.portal_login_page?.email_step_description ?? "", + email_step_button_label: preset.portal_login_page?.email_step_button_label ?? "", + otp_step_title: preset.portal_login_page?.otp_step_title ?? "", + otp_step_description: preset.portal_login_page?.otp_step_description ?? "", + } + : undefined, + }); + + const onLoginPageSubmit = async (data: LoginPageFormValues) => { + const loginPage: PortalPresetLoginPage = { + template_id: "202602-default", + email_step_title: data.email_step_title || null, + email_step_description: data.email_step_description || null, + email_step_button_label: data.email_step_button_label || null, + otp_step_title: data.otp_step_title || null, + otp_step_description: data.otp_step_description || null, + }; + + const req = Promise.resolve( + client + .from("portal_preset") + .update({ portal_login_page: loginPage as any }) + .eq("id", params.id) + ).then(({ error }) => { + if (error) throw error; + }); + + try { + await toast.promise(req, { + loading: "Saving...", + success: "Login page text saved", + error: "Failed to save", + }); + mutate(); + loginPageForm.reset(data); + } catch { + // toast handles UI + } + }; + + const onSubmit = async (data: EmailFormValues) => { const template: PortalPresetVerificationEmailTemplate = { enabled: data.enabled, from_name: data.from_name, @@ -291,6 +341,113 @@ export default function PortalPresetEditPage() { + {/* Login page text overrides */} + + + Login Page Text +

+ Override the default text shown on the customer portal login + page. Leave a field empty to use the default. +

+
+ +
+
+ Email Step +
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+
+ OTP Step +
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+
+
+ + + +
+ {/* Email template */} Date: Sat, 7 Feb 2026 23:21:32 +0900 Subject: [PATCH 04/11] Refactor portal presets and sidebar components - 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. --- .../(resources)/ciam/presets/[id]/page.tsx | 237 +++++++++++------- .../(resources)/ciam/presets/page.tsx | 4 +- .../(resources)/console-resources-sidebar.tsx | 12 +- 3 files changed, 148 insertions(+), 105 deletions(-) diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx index 07b06e18f4..9d38733b6c 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx @@ -34,9 +34,17 @@ import { DeleteConfirmationSnippet, } from "@/components/dialogs/delete-confirmation-dialog"; import { useDialogState } from "@/components/hooks/use-dialog-state"; -import { ArrowLeftIcon, StarIcon } from "lucide-react"; +import { ArrowLeftIcon, ExternalLink, StarIcon } from "lucide-react"; import Link from "next/link"; -import { Label } from "@/components/ui/label"; +import { previewlink } from "@/lib/internal/url"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; import type { Database, PortalPresetVerificationEmailTemplate, @@ -87,7 +95,7 @@ export default function PortalPresetEditPage() { // --- Name form (isolated) --- const nameForm = useForm<{ name: string }>({ - values: preset ? { name: preset.name } : undefined, + values: preset ? { name: preset.name ?? "" } : undefined, }); const onNameSubmit = async (data: { name: string }) => { @@ -137,7 +145,7 @@ export default function PortalPresetEditPage() { const subject_template = useWatch({ control, name: "subject_template" }); const body_html_template = useWatch({ control, name: "body_html_template" }); - // --- Login page text form (isolated) --- + // --- Login page form (isolated) --- const loginPageForm = useForm({ values: preset ? { @@ -172,7 +180,7 @@ export default function PortalPresetEditPage() { try { await toast.promise(req, { loading: "Saving...", - success: "Login page text saved", + success: "Login page saved", error: "Failed to save", }); mutate(); @@ -275,7 +283,7 @@ export default function PortalPresetEditPage() { className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" > - Back to Presets + Back to Customer Portal
@@ -320,11 +328,20 @@ export default function PortalPresetEditPage() { name="name" control={nameForm.control} render={({ field }) => ( - +
+ Name + + + Preset name + + + +
)} /> @@ -341,97 +358,127 @@ export default function PortalPresetEditPage() { - {/* Login page text overrides */} + {/* Login page overrides */} - Login Page Text -

- Override the default text shown on the customer portal login - page. Leave a field empty to use the default. -

+
+ Login Page + +
-
- Email Step -
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
-
- OTP Step -
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
+
+ Login Page + + Override the default text shown on the customer portal login + page. Leave a field empty to use the default. + + +
+ Email Step + + + Title + ( + + )} + /> + + + Description + ( + + )} + /> + + + Button Label + ( + + )} + /> + + +
+
+ OTP Step + + + Title + ( + + )} + /> + + + Description + ( + + )} + /> + + +
+
+
@@ -459,7 +506,7 @@ export default function PortalPresetEditPage() { control={control} render={({ field }) => ( )} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx index 463b85ba13..8a2952c004 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx @@ -119,7 +119,7 @@ export default function PortalPresetsPage() {
- Portal Presets + Customer Portal

Manage customer portal variants and customize the OTP verification @@ -135,7 +135,7 @@ export default function PortalPresetsPage() { - Create Portal Preset + Create Preset Give your preset a name. You can configure the email template after creation. diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx index b1946324b4..88988995fc 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx @@ -15,7 +15,7 @@ import { import { ResourceTypeIcon } from "@/components/resource-type-icon"; import { DarwinSidebarHeaderDragArea } from "@/host/desktop"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; -import { HomeIcon, MailIcon, ShieldCheckIcon } from "lucide-react"; +import { HomeIcon, ShieldCheckIcon, Store } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -95,13 +95,13 @@ export function ConsoleResourcesSidebar({ Tags - + CIAM - - Portal Presets + + Customer Portal @@ -144,10 +144,6 @@ export function ConsoleResourcesSidebar({ Connections - - - CIAM - From a5aad194bf0798896709600597716c82ebf71130 Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 7 Feb 2026 23:29:51 +0900 Subject: [PATCH 05/11] Add guideline for component directory structure - 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. --- editor/components/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/editor/components/AGENTS.md b/editor/components/AGENTS.md index 757a5d0028..cac2e8d24c 100644 --- a/editor/components/AGENTS.md +++ b/editor/components/AGENTS.md @@ -16,6 +16,7 @@ If you find yourself adding heavy state machines, feature workflows, or global/e - **Avoid global state coupling**: prefer props and local state; do not require editor/workbench global stores just to render. - **Composable styling**: prefer `className` + merge helper (e.g. `cn(...)`) and avoid “closed” styling that can’t be overridden. - **Small surface area**: keep components narrowly-scoped; split when a component becomes a mini-feature. +- **No new directories by default**: do not create new folders under `components/` unless explicitly required. This tree is intentionally curated by project maintainers, and everything here should remain broadly reusable. ## Directory map (highlighted) From bc6e6941084cdd8ae0f675efa0ca70ecd26797f8 Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 7 Feb 2026 23:57:52 +0900 Subject: [PATCH 06/11] Refactor portal login component and introduce new template - 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. --- .../(tenant)/~/[tenant]/(p)/p/login/login.tsx | 205 +----- .../(resources)/ciam/presets/[id]/page.tsx | 608 ++++++++++-------- .../202602-default/portal-login-view.tsx | 236 +++++++ 3 files changed, 593 insertions(+), 456 deletions(-) create mode 100644 editor/theme/templates/portal-login/202602-default/portal-login-view.tsx diff --git a/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx b/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx index a22fad5acc..7270e7c58e 100644 --- a/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx +++ b/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx @@ -1,67 +1,13 @@ "use client"; import React, { useState } from "react"; -import { UserCheck2Icon } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Field, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp"; -import { Spinner } from "@/components/ui/spinner"; -import { template } from "@/utils/template"; +import { PortalLoginView } from "@/theme/templates/portal-login/202602-default/portal-login-view"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import type { PortalPresetLoginPage } from "@app/database"; type Step = "email" | "otp"; -const dictionary = { - en: { - title: "Log in to manage your account", - description: - "Enter your email and we will send you a verification code directly to your customer portal.", - email: "Email", - continue_with_email: "Continue with Email", - sending: "Sending...", - verification: "Verification", - verification_description: - 'If you have an account, We have sent a code to {email}. Enter it below.', - verifying: "Verifying...", - back: "← Back", - }, - ko: { - title: "계속 하려면 로그인하세요", - description: "이메일을 입력하시면 고객 포털 인증 코드를 보내드립니다.", - email: "이메일", - continue_with_email: "이메일로 계속하기", - sending: "전송중...", - verification: "인증하기", - verification_description: - '입력하신 {email}로 인증 코드를 발송하였습니다. 아래에 입력해 주세요. 코드를 수신하지 못한 경우, 정확한 이메일을 입력하였는지 다시 한 번 확인해 주세요.', - verifying: "인증중...", - back: "← 뒤로", - }, -}; - -/** - * Helper: returns the override value if it is a non-empty string, otherwise the fallback. - */ -function ov(override: string | null | undefined, fallback: string): string { - return typeof override === "string" && override.trim().length > 0 - ? override - : fallback; -} - interface PortalLoginProps { locale?: string; overrides?: PortalPresetLoginPage | null; @@ -88,8 +34,6 @@ export default function PortalLogin({ const json = await res.json().catch(() => ({})); return { ok: res.ok, - // Note: endpoint returns ok even when the email isn't registered. - // We store challenge_id if present, but do not depend on it for the email step UI. challenge_id: (json as any)?.challenge_id as string | undefined, }; }; @@ -104,14 +48,13 @@ export default function PortalLogin({ } setIsLoading(true); - sendEmail?.(email) + sendEmail(email) .then(({ ok, challenge_id }) => { if (ok) { setChallengeId(challenge_id ?? null); setStep("otp"); } else { toast.error("Something went wrong"); - return; } }) .finally(() => { @@ -123,9 +66,6 @@ export default function PortalLogin({ setIsLoading(true); setError(""); - // CIAM flow: - // Customer portal access is verified via CIAM OTP challenge verification. - // This intentionally does NOT use Supabase Auth. if (!challengeId) { setIsLoading(false); setError("Invalid or expired OTP"); @@ -158,134 +98,19 @@ export default function PortalLogin({ router.replace(session_url); }; - const t = dictionary[locale as keyof typeof dictionary]; - - // Apply preset overrides (if any) on top of the locale dictionary. - const emailStepTitle = ov(overrides?.email_step_title, t.title); - const emailStepDescription = ov(overrides?.email_step_description, t.description); - const emailStepButtonLabel = ov(overrides?.email_step_button_label, t.continue_with_email); - const otpStepTitle = ov(overrides?.otp_step_title, t.verification); - const otpStepDescription = ov(overrides?.otp_step_description, t.verification_description); - - return ( -

- {step === "email" && ( -
-
-
-
-
- -
-
-

{emailStepTitle}

-
- {emailStepDescription} -
-
-
- - {t.email} - setEmail(e.target.value)} - disabled={isLoading} - required - /> - - -
-
-
- )} - {step === "otp" && ( - - - - {otpStepTitle} - - - - - - - - - - - {error && ( -
- {error} -
- )} - -
- -
-
-
- )} -
- ); -} - -function OTP({ - disabled, - onComplete, -}: { - disabled?: boolean; - onComplete?: (otp: string) => void; -}) { return ( - { - onComplete?.(otp); - }} - > - - - - - - - - - - - - - - - - - - - + setStep("email")} + error={error} + /> ); } diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx index 9d38733b6c..a1ae04ba42 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx @@ -7,26 +7,12 @@ import { createBrowserCIAMClient } from "@/lib/supabase/client"; import { useProject } from "@/scaffolds/workspace"; import { useForm, useWatch, Controller } from "react-hook-form"; import { toast } from "sonner"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { Skeleton } from "@/components/ui/skeleton"; -import { - PreferenceBody, - PreferenceBox, - PreferenceBoxFooter, - PreferenceBoxHeader, - PreferenceDescription, -} from "@/components/preferences"; import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; import MailAppFrame from "@/components/frames/mail-app-frame"; import { @@ -45,6 +31,11 @@ import { FieldLegend, FieldSet, } from "@/components/ui/field"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Safari, SafariToolbar } from "@/components/frames/safari"; +import { PortalLoginView } from "@/theme/templates/portal-login/202602-default/portal-login-view"; +import { Separator } from "@/components/ui/separator"; +import type { ReactNode } from "react"; import type { Database, PortalPresetVerificationEmailTemplate, @@ -70,6 +61,21 @@ type LoginPageFormValues = { otp_step_description: string; }; +function ControlsPreviewLayout({ + controls, + preview, +}: { + controls: ReactNode; + preview?: ReactNode; +}) { + return ( +
+
{controls}
+
{preview ?? null}
+
+ ); +} + export default function PortalPresetEditPage() { const params = useParams<{ id: string; org: string; proj: string }>(); const router = useRouter(); @@ -158,6 +164,21 @@ export default function PortalPresetEditPage() { : undefined, }); + const loginPageOverrides = useWatch({ + control: loginPageForm.control, + }); + const loginPagePreviewOverrides: PortalPresetLoginPage | null = useMemo(() => { + const v = loginPageOverrides; + return { + template_id: "202602-default", + email_step_title: v?.email_step_title?.trim() || null, + email_step_description: v?.email_step_description?.trim() || null, + email_step_button_label: v?.email_step_button_label?.trim() || null, + otp_step_title: v?.otp_step_title?.trim() || null, + otp_step_description: v?.otp_step_description?.trim() || null, + }; + }, [loginPageOverrides]); + const onLoginPageSubmit = async (data: LoginPageFormValues) => { const loginPage: PortalPresetLoginPage = { template_id: "202602-default", @@ -266,7 +287,7 @@ export default function PortalPresetEditPage() { if (isLoading || !preset) { return (
-
+
@@ -276,7 +297,7 @@ export default function PortalPresetEditPage() { return (
-
+
-
+
{/* Preset name */} - - - Preset Name - - - ( -
- Name - - - Preset name - - - -
- )} - /> -
- - - -
- - {/* Login page overrides */} - - -
- Login Page - -
-
- -
-
- Login Page - - Override the default text shown on the customer portal login - page. Leave a field empty to use the default. - - -
- Email Step - - - Title - ( - - )} - /> - - - Description - ( - - )} - /> - - - Button Label - ( - - )} - /> - - -
+ + Preset Name + + (
- OTP Step + Name - - Title - ( - - )} - /> - - - Description - ( - - )} + + Preset name +
-
-
-
-
- - - -
- - {/* Email template */} - - ( - )} /> - } - /> - {enabled ? ( - <> - -
- + {nameForm.formState.isSubmitting ? : "Save"} + + + + } + /> + + + + {/* Login page */} + +
+
+ Login Page + + Override the default text shown on the customer portal login + page. Leave a field empty to use the default. + + +
+ Email Step + + + Title + ( + + )} + /> + + + Description + ( + + )} + /> + + + Button Label + ( + + )} + /> + + +
+
+ OTP Step + + + Title + ( + + )} + /> + + + Description + ( + + )} + /> + + +
+
+
+ +
+ + } + preview={ +
+ + + + Email Step + OTP Step + + + + +
+ +
+
+
+ + + +
+ - ) : ( - <> -

Your verification code is: 123456

-

- Tip: add a body HTML template below to preview the - email content here. -

- - )} - -
+
+ + + +
+ } + /> + + + + {/* Email template */} + + + Verification Email Template + ( + + )} + /> + + {enabled ? (
+ Supported variables:{" "} {"{{email_otp}}"},{" "} {"{{brand_name}}"},{" "} @@ -571,7 +597,7 @@ export default function PortalPresetEditPage() { {"{{expires_in_minutes}}"},{" "} {"{{brand_support_url}}"},{" "} {"{{brand_support_contact}}"}. - + } fields={{ to: { @@ -622,26 +648,76 @@ export default function PortalPresetEditPage() { }, }} /> + -
- - ) : ( - - - Enable the toggle above to customize the verification email. - When disabled, the default Grida verification email is used. - - - )} - - - -
+ ) : ( + + + Enable the toggle above to customize the verification email. + When disabled, the default Grida verification email is used. + + + + )} + + } + preview={ + enabled ? ( +
+ + {body_html_template?.trim() ? ( +
+ ) : ( + <> +

Your verification code is: 123456

+

+ Tip: add a body HTML template to preview the + email content here. +

+ + )} + +
+ ) : null + } + />
{email}. Enter it below.', + verifying: "Verifying...", + back: "← Back", + }, + ko: { + title: "계속 하려면 로그인하세요", + description: "이메일을 입력하시면 고객 포털 인증 코드를 보내드립니다.", + email: "이메일", + continue_with_email: "이메일로 계속하기", + sending: "전송중...", + verification: "인증하기", + verification_description: + '입력하신 {email}로 인증 코드를 발송하였습니다. 아래에 입력해 주세요. 코드를 수신하지 못한 경우, 정확한 이메일을 입력하였는지 다시 한 번 확인해 주세요.', + verifying: "인증중...", + back: "← 뒤로", + }, +}; + +function ov(override: string | null | undefined, fallback: string): string { + return typeof override === "string" && override.trim().length > 0 + ? override + : fallback; +} + +export type PortalLoginViewProps = { + overrides?: PortalPresetLoginPage | null; + step: LoginStep; + locale?: string; + /** Sample email for OTP step description template in view-only mode (e.g. "user@example.com") */ + sampleEmail?: string; + /** When true, renders as static preview (disabled inputs). Default true. */ + viewOnly?: boolean; + // --- Interactive mode (when viewOnly=false) --- + /** Email value for controlled input (email step) */ + email?: string; + /** Callback when email input changes */ + onEmailChange?: (value: string) => void; + /** Callback when email form is submitted */ + onEmailSubmit?: (e: React.FormEvent) => void; + /** Loading state (shows "Sending..." on button for email, "Verifying..." on back for OTP) */ + isLoading?: boolean; + /** Callback when OTP is complete (OTP step) */ + onOtpComplete?: (otp: string) => void; + /** Callback when back button is clicked (OTP step) */ + onBack?: () => void; + /** Error message to show (OTP step) */ + error?: string; +}; + +/** + * Reusable portal login page UI template (202602-default). + * Use for preview (viewOnly=true) or production login flow (viewOnly=false with handlers). + */ +export function PortalLoginView({ + overrides, + step, + locale = "en", + sampleEmail = "user@example.com", + viewOnly = true, + email = "", + onEmailChange, + onEmailSubmit, + isLoading = false, + onOtpComplete, + onBack, + error, +}: PortalLoginViewProps) { + const t = dictionary[locale as keyof typeof dictionary]; + + const emailStepTitle = ov(overrides?.email_step_title, t.title); + const emailStepDescription = ov(overrides?.email_step_description, t.description); + const emailStepButtonLabel = ov(overrides?.email_step_button_label, t.continue_with_email); + const otpStepTitle = ov(overrides?.otp_step_title, t.verification); + const otpStepDescription = ov(overrides?.otp_step_description, t.verification_description); + + const otpEmail = viewOnly ? sampleEmail : email; + + if (step === "email") { + return ( +
+
e.preventDefault() + : onEmailSubmit ?? ((e) => e.preventDefault()) + } + > +
+
+
+
+ +
+
+

{emailStepTitle}

+
+ {emailStepDescription} +
+
+
+ + {t.email} + onEmailChange?.(e.target.value) + } + disabled={viewOnly || isLoading} + readOnly={viewOnly} + required={!viewOnly} + className={viewOnly ? "bg-muted" : undefined} + /> + + +
+
+
+
+ ); + } + + // OTP step + return ( +
+ + + {otpStepTitle} + + + + + + + + onOtpComplete?.(otp)} + > + + + + + + + + + + + + + + + + + + + + + {!viewOnly && error && ( +
+ {error} +
+ )} + +
+ +
+
+
+
+ ); +} From 0f76094b8bdbaecd3843d9e1821e61541ff887c4 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 00:06:01 +0900 Subject: [PATCH 07/11] Refactor email components and replace MailAppFrame with EmailFrame - 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. --- editor/app/(dev)/dev/frames/mail/page.tsx | 77 ++--- editor/app/(dev)/ui/frames/page.tsx | 262 +++++++++++------- .../(resources)/ciam/presets/[id]/page.tsx | 75 +++-- editor/components/frames/email-frame.tsx | 142 ++++++++++ editor/components/frames/mail-app-frame.tsx | 230 --------------- ...ification-respondent-email-preferences.tsx | 76 +++-- 6 files changed, 421 insertions(+), 441 deletions(-) create mode 100644 editor/components/frames/email-frame.tsx delete mode 100644 editor/components/frames/mail-app-frame.tsx diff --git a/editor/app/(dev)/dev/frames/mail/page.tsx b/editor/app/(dev)/dev/frames/mail/page.tsx index c02a96d232..3ec25f5eae 100644 --- a/editor/app/(dev)/dev/frames/mail/page.tsx +++ b/editor/app/(dev)/dev/frames/mail/page.tsx @@ -1,42 +1,45 @@ -import MailAppFrame from "@/components/frames/mail-app-frame"; +import { + EmailFrame, + EmailFrameSubject, + EmailFrameSender, + EmailFrameBody, +} from "@/components/frames/email-frame"; export default function MailFramePage() { return ( - -

Dear team,

-

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

-

Some of the key highlights include:

-
    -
  • Improved email search and filtering
  • -
  • Enhanced email templates and signatures
  • -
  • Seamless integration with our project management tools
  • -
-

- Weve been working hard to deliver these improvements, and were confident - they will have a positive impact on your daily workflow. Please let me - know if you have any questions or feedback. -

-

- Best regards, -
- Jared -

-
+
+ + New Feature Update + + +

Dear team,

+

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

+

Some of the key highlights include:

+
    +
  • Improved email search and filtering
  • +
  • Enhanced email templates and signatures
  • +
  • Seamless integration with our project management tools
  • +
+

+ Weve been working hard to deliver these improvements, and were + confident they will have a positive impact on your daily workflow. + Please let me know if you have any questions or feedback. +

+

+ Best regards, +
+ Jared +

+
+
+
); } diff --git a/editor/app/(dev)/ui/frames/page.tsx b/editor/app/(dev)/ui/frames/page.tsx index af347d5b51..c85149c6f7 100644 --- a/editor/app/(dev)/ui/frames/page.tsx +++ b/editor/app/(dev)/ui/frames/page.tsx @@ -1,8 +1,22 @@ "use client"; import React from "react"; +import { + ArchiveIcon, + ForwardIcon, + ReplyIcon, + StarIcon, + TrashIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { Safari, SafariToolbar } from "@/components/frames/safari"; -import MailAppFrame from "@/components/frames/mail-app-frame"; +import { + EmailFrame, + EmailFrameSubject, + EmailFrameSender, + EmailFrameBody, +} from "@/components/frames/email-frame"; export default function FramesPage() { return ( @@ -164,6 +178,88 @@ export default function FramesPage() {

+
+

Email Frame (composable)

+

+ Composable email preview: EmailFrame, Subject, Sender, Body. + Used inside Mail App Frame and standalone. +

+
+
+

+ With actions +

+ + + + + + + + } + > + Your invoice for January 2026 + + + +

Hi there,

+

+ Please find attached your invoice for January 2026. The + total amount due is $2,400.00 and payment is due by + February 15, 2026. +

+

+ If you have any questions about this invoice, feel free + to reply to this email or reach out to our billing team. +

+

+ Best regards, +
+ Sarah Chen +

+
+
+
+ +
+

+ Minimal +

+ + + +

+ [acme/dashboard] Pull request #142 was merged by + @sarah-chen. +

+
+
+
+
+

Safari

@@ -175,50 +271,40 @@ export default function FramesPage() {

-

Mail (sidebar hidden)

+

Mail (email preview)

- Email client frame for previewing email templates + Email frame for previewing email templates

- -

Your verification code

-

- Hi Alice, use the following code to verify your identity: -

-

- 123456 -

-

This code expires in 10 minutes.

-
-

- If you did not request this, you can safely ignore this - email. -

-
+ + Your verification code + + +

Your verification code

+

+ Hi Alice, use the following code to verify your identity: +

+

+ 123456 +

+

This code expires in 10 minutes.

+
+

+ If you did not request this, you can safely ignore this + email. +

+
+
@@ -229,57 +315,47 @@ export default function FramesPage() { Email body scrolls when content exceeds the frame height

- -

Thanks for registering!

-

We received your submission for the Annual Conference.

-

Your registration number: #042

-

What happens next?

-
    -
  • You will receive a confirmation email within 24 hours
  • -
  • Our team will review your application
  • -
  • If approved, you will get your ticket via email
  • -
-

Event details

-

- Date: March 15, 2026 -
- Location: Convention Center, Hall A
- Time: 9:00 AM - 5:00 PM -

-

Important notes

-

- Please bring a valid ID and your ticket (digital or printed) - to the event. Doors open at 8:30 AM for registration. -

-

- If you have any dietary requirements, please let us know at - least 48 hours before the event. -

-

We look forward to seeing you there!

-
-

- This is an automated message. If you did not submit this - form, please contact support@example.com. -

-
+ + Thanks for your submission + + +

Thanks for registering!

+

We received your submission for the Annual Conference.

+

Your registration number: #042

+

What happens next?

+
    +
  • You will receive a confirmation email within 24 hours
  • +
  • Our team will review your application
  • +
  • If approved, you will get your ticket via email
  • +
+

Event details

+

+ Date: March 15, 2026 +
+ Location: Convention Center, Hall A
+ Time: 9:00 AM - 5:00 PM +

+

Important notes

+

+ Please bring a valid ID and your ticket (digital or printed) + to the event. Doors open at 8:30 AM for registration. +

+

+ If you have any dietary requirements, please let us know at + least 48 hours before the event. +

+

We look forward to seeing you there!

+
+

+ This is an automated message. If you did not submit this + form, please contact support@example.com. +

+
+
diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx index a1ae04ba42..ef912f4805 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx @@ -14,7 +14,12 @@ import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { Skeleton } from "@/components/ui/skeleton"; import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; -import MailAppFrame from "@/components/frames/mail-app-frame"; +import { + EmailFrame, + EmailFrameSubject, + EmailFrameSender, + EmailFrameBody, +} from "@/components/frames/email-frame"; import { DeleteConfirmationAlertDialog, DeleteConfirmationSnippet, @@ -675,45 +680,35 @@ export default function PortalPresetEditPage() { preview={ enabled ? (
- - {body_html_template?.trim() ? ( -
- ) : ( - <> -

Your verification code is: 123456

-

- Tip: add a body HTML template to preview the - email content here. -

- - )} - + + + {subject_template?.trim() || "Your verification code"} + + + + {body_html_template?.trim() ? ( +
+ ) : ( + <> +

+ Your verification code is: 123456 +

+

+ Tip: add a body HTML template to preview the email + content here. +

+ + )} + +
) : null } diff --git a/editor/components/frames/email-frame.tsx b/editor/components/frames/email-frame.tsx new file mode 100644 index 0000000000..c1568a6ffa --- /dev/null +++ b/editor/components/frames/email-frame.tsx @@ -0,0 +1,142 @@ +import * as React from "react"; +import { cn } from "@/components/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +/* ─── Root ─── */ + +function EmailFrame({ + children, + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +/* ─── Subject bar ─── */ + +interface EmailFrameSubjectProps extends React.HTMLAttributes { + actions?: React.ReactNode; +} + +function EmailFrameSubject({ + children, + actions, + className, + ...props +}: EmailFrameSubjectProps) { + return ( +
+ + {children} + + {actions && ( +
{actions}
+ )} +
+ ); +} + +/* ─── Sender ─── */ + +interface EmailFrameSenderProps extends React.HTMLAttributes { + name: string; + email: string; + avatar?: string; + date?: React.ReactNode; + to?: React.ReactNode; +} + +function EmailFrameSender({ + name, + email, + avatar, + date, + to, + className, + ...props +}: EmailFrameSenderProps) { + const initials = name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + return ( +
+ + {avatar && ( + + )} + + {initials} + + +
+
+ + {name} + + {date && ( + + {date} + + )} +
+

{email}

+ {to && ( +

+ {"to "} + {to} +

+ )} +
+
+ ); +} + +/* ─── Body ─── */ + +function EmailFrameBody({ + children, + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export { + EmailFrame, + EmailFrameSubject, + EmailFrameSender, + EmailFrameBody, +}; diff --git a/editor/components/frames/mail-app-frame.tsx b/editor/components/frames/mail-app-frame.tsx deleted file mode 100644 index a964167c4e..0000000000 --- a/editor/components/frames/mail-app-frame.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client"; - -import React, { useMemo } from "react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenuTrigger, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenu, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { AvatarImage, AvatarFallback, Avatar } from "@/components/ui/avatar"; -import { cn } from "@/components/lib/utils"; -import { GridaLogo } from "../grida-logo"; -import { - ArchiveIcon, - InboxIcon, - SearchIcon, - TagIcon, - Trash2Icon, -} from "lucide-react"; - -export default function MailAppFrame({ - sidebarHidden, - children, - messages, - message, -}: React.PropsWithChildren<{ - sidebarHidden?: boolean; - messages: { title: string; from: string; at: string }[]; - message: { - from: { - name: string; - email: string; - avatar: string; - }; - title: string; - at: string; - }; -}>) { - const today = useMemo(() => new Date(), []); - - return ( -
-
-
-
- - - Mail - -
-
- -
-
-
-
-
-
-
- - -
-
- - - - - - Universe - - Settings - Support - - Logout - - -
-
-
-
-
-
-

Inbox

-
- {today.toLocaleDateString()} -
-
-
-
- {messages.map((msg, i) => ( - - ))} - {/* - - - */} -
-
-
-
-
-
-
-

{message.title}

-
- {message.at} -
-
-
-
- - - {message.from.avatar} - -
-
{message.from.name}
-
- {message.from.email} -
-
-
-
- {children} -
-
-
-
-
-
-
-
- ); -} - -function MessageItem({ - title, - from, - at, -}: { - title: string; - from: string; - at: string; -}) { - return ( -
-
-
-
{from}
-
{at}
-
-
{title}
-
-
- ); -} diff --git a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx index 6762795b14..a47883f92d 100644 --- a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx +++ b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx @@ -17,7 +17,12 @@ import { Controller, useForm, useWatch } from "react-hook-form"; import { useEditorState } from "@/scaffolds/editor"; import { Badge } from "@/components/ui/badge"; import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; -import MailAppFrame from "@/components/frames/mail-app-frame"; +import { + EmailFrame, + EmailFrameSubject, + EmailFrameSender, + EmailFrameBody, +} from "@/components/frames/email-frame"; type FormValues = { enabled: boolean; @@ -126,46 +131,35 @@ export function NotificationRespondentEmailPreferences() { } /> -
- - {body_html_template?.trim() ? ( -
- ) : ( - <> -

Thanks for your submission.

-

- Tip: add a body HTML template below to preview the email - content here. -

- - )} - +
+ + + {subject_template?.trim() || "Your submission has been received"} + + + + {body_html_template?.trim() ? ( +
+ ) : ( + <> +

Thanks for your submission.

+

+ Tip: add a body HTML template below to preview the email + content here. +

+ + )} + +
Date: Sun, 8 Feb 2026 00:26:10 +0900 Subject: [PATCH 08/11] Implement email display name sanitization and add tests - 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. --- .../[tenant]/api/p/access/with-email/route.ts | 9 ++-- editor/utils/sanitize.test.ts | 41 +++++++++++++++++++ editor/utils/sanitize.ts | 12 ++++++ ...0260207000000_grida_ciam_portal_preset.sql | 3 +- supabase/schemas/grida_ciam.sql | 3 +- ...test_grida_ciam_portal_preset_rls_test.sql | 39 +++++++++++++++++- 6 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 editor/utils/sanitize.test.ts create mode 100644 editor/utils/sanitize.ts diff --git a/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts b/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts index 30d3e1dd6f..aae773388e 100644 --- a/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts +++ b/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts @@ -11,6 +11,8 @@ import { select_lang } from "@/i18n/utils"; import { getLocale } from "@/i18n/server"; import { renderPortalVerificationEmail } from "@/services/ciam/portal-verification-email"; import type { PortalPresetVerificationEmailTemplate } from "@app/database"; +import { sanitize_email_display_name } from "@/utils/sanitize"; + // TODO: add rate limiting export async function POST( req: NextRequest, @@ -171,8 +173,9 @@ export async function POST( }, }); - const fromName = - preset_template.from_name?.trim() || brand_name; + 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({ @@ -192,7 +195,7 @@ export async function POST( ); const { error } = await resend.emails.send({ - from: `${brand_name} `, + from: `${sanitize_email_display_name(brand_name)} `, to: emailNormalized, subject: subject(emailLang, { brand_name, diff --git a/editor/utils/sanitize.test.ts b/editor/utils/sanitize.test.ts new file mode 100644 index 0000000000..0b93a037c0 --- /dev/null +++ b/editor/utils/sanitize.test.ts @@ -0,0 +1,41 @@ +import { sanitize_email_display_name } from "./sanitize"; + +describe("sanitize_email_display_name", () => { + it("passes through a clean name unchanged", () => { + expect(sanitize_email_display_name("Acme Corp")).toBe("Acme Corp"); + }); + + it("strips double quotes", () => { + expect(sanitize_email_display_name('"Acme" Corp')).toBe("Acme Corp"); + }); + + it("strips angle brackets", () => { + expect(sanitize_email_display_name("Acme ")).toBe("Acme evil"); + }); + + it("strips backslashes", () => { + expect(sanitize_email_display_name("Acme\\Corp")).toBe("AcmeCorp"); + }); + + it("strips control characters", () => { + expect(sanitize_email_display_name("Acme\x00\x0d\x0aCorp")).toBe("AcmeCorp"); + }); + + it("collapses whitespace", () => { + expect(sanitize_email_display_name("Acme Corp")).toBe("Acme Corp"); + }); + + it("trims leading and trailing whitespace", () => { + expect(sanitize_email_display_name(" Acme Corp ")).toBe("Acme Corp"); + }); + + it("handles a combination of unsafe characters", () => { + expect( + sanitize_email_display_name(' "My" \\ \n Name ') + ).toBe("My Brand Name"); + }); + + it("returns empty string for all-unsafe input", () => { + expect(sanitize_email_display_name('"<>\\\x00')).toBe(""); + }); +}); diff --git a/editor/utils/sanitize.ts b/editor/utils/sanitize.ts new file mode 100644 index 0000000000..aec763ac0c --- /dev/null +++ b/editor/utils/sanitize.ts @@ -0,0 +1,12 @@ +/** + * Sanitize a display name for the RFC 5322 "From" header. + * Strips double quotes, angle brackets, backslashes, control characters, + * and collapses runs of whitespace into a single space. + */ +export function sanitize_email_display_name(name: string): string { + return name + .replace(/["<>\\]/g, "") + .replace(/[\x00-\x1f\x7f]/g, "") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql b/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql index 30360e1c7a..124231d51a 100644 --- a/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql +++ b/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql @@ -140,7 +140,7 @@ CREATE OR REPLACE FUNCTION grida_ciam_public.set_primary_portal_preset( RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public, extensions, pg_temp +SET search_path = pg_catalog, public AS $function$ BEGIN -- Enforce project membership (same check as RLS). @@ -167,5 +167,6 @@ BEGIN END; $function$; +REVOKE EXECUTE ON FUNCTION grida_ciam_public.set_primary_portal_preset(bigint, uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION grida_ciam_public.set_primary_portal_preset(bigint, uuid) TO authenticated, service_role; diff --git a/supabase/schemas/grida_ciam.sql b/supabase/schemas/grida_ciam.sql index b773eabe0e..de28a1ca41 100644 --- a/supabase/schemas/grida_ciam.sql +++ b/supabase/schemas/grida_ciam.sql @@ -819,7 +819,7 @@ CREATE OR REPLACE FUNCTION grida_ciam_public.set_primary_portal_preset( RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public, extensions, pg_temp +SET search_path = pg_catalog, public AS $function$ BEGIN IF NOT public.rls_project(p_project_id) THEN @@ -843,5 +843,6 @@ BEGIN END; $function$; +REVOKE EXECUTE ON FUNCTION grida_ciam_public.set_primary_portal_preset(bigint, uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION grida_ciam_public.set_primary_portal_preset(bigint, uuid) TO authenticated, service_role; diff --git a/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql b/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql index 75771e50fb..ae4fd6f948 100644 --- a/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql +++ b/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(14); +SELECT plan(16); --------------------------------------------------------------------- -- Seed fixtures @@ -224,11 +224,46 @@ SELECT throws_ok( ); SELECT test_reset_auth(); +--------------------------------------------------------------------- +-- portal_preset delete isolation +--------------------------------------------------------------------- + +-- Test 14: Insider can delete their own preset +SELECT test_set_auth('insider@grida.co'); +SELECT lives_ok( + format( + 'DELETE FROM grida_ciam_public.portal_preset WHERE id = %L', + current_setting('test.preset_b') + ), + 'Insider can delete their own preset' +); +SELECT test_reset_auth(); + +-- Test 15: Outsider cannot delete insider's preset +SELECT test_set_auth('random@example.com'); +DO $$ +DECLARE + affected int; +BEGIN + EXECUTE format( + 'DELETE FROM grida_ciam_public.portal_preset WHERE id = %L', + current_setting('test.preset_a') + ); + GET DIAGNOSTICS affected = ROW_COUNT; + PERFORM set_config('test.outsider_delete_count', affected::text, true); +END $$; +SELECT is( + current_setting('test.outsider_delete_count')::int, + 0, + 'Outsider delete should affect 0 rows (RLS hides the row)' +); +SELECT test_reset_auth(); + --------------------------------------------------------------------- -- view uses security_invoker --------------------------------------------------------------------- --- Test 14: View has security_invoker = true +-- Test 16: View has security_invoker = true SELECT is( (SELECT COUNT(*) FROM pg_views v From bcbaa53d9741bb4c5646c199938f1b289089990c Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 00:42:00 +0900 Subject: [PATCH 09/11] chore: ui --- .../[proj]/[id]/connect/channels/page.tsx | 447 ++++++++++++++---- ...ification-respondent-email-preferences.tsx | 245 ---------- 2 files changed, 360 insertions(+), 332 deletions(-) delete mode 100644 editor/scaffolds/settings/notification-respondent-email-preferences.tsx diff --git a/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx b/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx index 1314ec50d3..4a5ce44144 100644 --- a/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx @@ -1,17 +1,11 @@ "use client"; -import { BirdLogo, KakaoTalkLogo, WhatsAppLogo } from "@/components/logos"; +import { useMemo, useState, type ReactNode } from "react"; +import React from "react"; import Link from "next/link"; -import { - PreferenceBody, - PreferenceBox, - PreferenceBoxHeader, - Sector, - SectorBlocks, - SectorDescription, - SectorHeader, - SectorHeading, -} from "@/components/preferences"; +import { toast } from "sonner"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { BirdLogo, KakaoTalkLogo, WhatsAppLogo } from "@/components/logos"; import { Table, TableBody, @@ -51,56 +45,347 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { useState } from "react"; -import { toast } from "sonner"; -import React from "react"; +import { Switch } from "@/components/ui/switch"; +import { Spinner } from "@/components/ui/spinner"; +import { Separator } from "@/components/ui/separator"; import { MessageCircleIcon } from "lucide-react"; import { useEditorState } from "@/scaffolds/editor"; -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { NotificationRespondentEmailPreferences } from "@/scaffolds/settings/notification-respondent-email-preferences"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; +import { PrivateEditorApi } from "@/lib/private"; +import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; +import { + EmailFrame, + EmailFrameSubject, + EmailFrameSender, + EmailFrameBody, +} from "@/components/frames/email-frame"; const SMS_DEFAULT_ORIGINATOR = process.env .NEXT_PUBLIC_BIRD_SMS_DEFAULT_ORIGINATOR as string; +function ControlsPreviewLayout({ + controls, + preview, +}: { + controls: ReactNode; + preview?: ReactNode; +}) { + return ( +
+
{controls}
+
+ {preview ?? null} +
+
+ ); +} + +type EmailFormValues = { + enabled: boolean; + from_name: string | null; + subject_template: string | null; + body_html_template: string | null; + reply_to: string | null; +}; + export default function ConnectChannels() { - const [state] = useEditorState(); + const [state, dispatch] = useEditorState(); const { form } = state; + const isCiamEnabled = useMemo(() => { + return form.fields.some((f) => f.type === "challenge_email"); + }, [form.fields]); + + const emailInitial = form.notification_respondent_email; + + const emailForm = useForm({ + defaultValues: { + enabled: emailInitial.enabled, + from_name: emailInitial.from_name, + subject_template: emailInitial.subject_template, + body_html_template: emailInitial.body_html_template, + reply_to: emailInitial.reply_to, + }, + }); + + const { + handleSubmit: handleEmailSubmit, + control: emailControl, + setValue: setEmailValue, + formState: { isSubmitting: emailSubmitting, isDirty: emailDirty }, + reset: resetEmail, + } = emailForm; + + const emailEnabled = useWatch({ control: emailControl, name: "enabled" }); + const reply_to = useWatch({ control: emailControl, name: "reply_to" }); + const from_name = useWatch({ control: emailControl, name: "from_name" }); + const subject_template = useWatch({ + control: emailControl, + name: "subject_template", + }); + const body_html_template = useWatch({ + control: emailControl, + name: "body_html_template", + }); + + const onEmailSubmit = async (data: EmailFormValues) => { + const req = PrivateEditorApi.Settings.updateNotificationRespondentEmail({ + form_id: form.form_id, + enabled: data.enabled, + from_name: data.from_name, + subject_template: data.subject_template, + body_html_template: data.body_html_template, + reply_to: data.reply_to, + }).then(() => { + dispatch({ + type: "editor/form/notification_respondent_email/preferences", + enabled: data.enabled, + from_name: data.from_name, + subject_template: data.subject_template, + body_html_template: data.body_html_template, + reply_to: data.reply_to, + }); + }); + + try { + await toast.promise(req, { + loading: "Saving...", + success: "Saved", + error: "Failed", + }); + resetEmail(data); + } catch { + // toast.promise handles UI + } + }; + + const emailInputDisabled = !emailEnabled || !isCiamEnabled; + return ( -
- - - +
+
+

Channels Pro - - +

+

Connect with your customers through SMS and Email. - - - - - - +

+
+ +
+ {/* Email notifications */} + + + + Respondent Email Notifications + Pro + + ( + + )} + /> + + + Send a confirmation email after a successful submission (CIAM + / verified email only). + + {emailEnabled ? ( + + + This notification requires CIAM email verification. + Add a challenge_email field to enable + verified respondent email sending. + + ) : null + } + helper={ + + Supported variables: {`{{form_title}}`},{" "} + {`{{response.idx}}`},{" "} + {`{{fields.}}`}. + + } + fields={{ + to: { + state: "disabled", + value: "Respondent (verified email)", + }, + replyTo: { + state: "on", + value: reply_to ?? "", + disabled: emailInputDisabled, + placeholder: "support@yourdomain.com", + onValueChange: (v: string) => + setEmailValue("reply_to", v || null, { + shouldDirty: true, + }), + }, + subject: { + state: "on", + value: subject_template ?? "", + disabled: emailInputDisabled, + placeholder: "Thanks, {{fields.first_name}}", + onValueChange: (v: string) => + setEmailValue("subject_template", v || null, { + shouldDirty: true, + }), + }, + fromName: { + state: "on", + value: from_name ?? "", + disabled: emailInputDisabled, + placeholder: "Grida Forms", + onValueChange: (v: string) => + setEmailValue("from_name", v || null, { + shouldDirty: true, + }), + }, + from: { + state: "disabled", + value: `${from_name?.trim() || "Grida Forms"} `, + }, + bodyHtml: { + state: "on", + value: body_html_template ?? "", + disabled: emailInputDisabled, + placeholder: + "

Thanks

\n

We received your submission for {{form_title}}.

", + onValueChange: (v: string) => + setEmailValue("body_html_template", v || null, { + shouldDirty: true, + }), + }, + }} + /> + + + ) : ( + + + Enable the toggle above to customize the respondent email + notification. + + + + )} + + } + preview={ +
+ + + {subject_template?.trim() || + "Your submission has been received"} + + + + {body_html_template?.trim() ? ( +
+ ) : ( + <> +

Thanks for your submission.

+

+ Tip: add a body HTML template below to preview the + email content here. +

+ + )} + + +
+ } + /> + + + + {/* SMS notifications */} + + SMS Notifications Coming soon - - } - description={ - <> + + SMS notifications are not ready yet. This section is currently disabled. - - } - /> - -
-
+ + + Originator + + +
+ + +
+ + } + preview={ +
+
-
- - Originator - - -
-
- - -
- - - - + } + /> + + + + {/* WhatsApp */} + + WhatsApp Add-on - - } - /> - - Contact us to enable WhatsApp for your project. - - - - + + + Contact us to enable WhatsApp for your project. + + + } + /> + + + + {/* Kakao Talk */} + + Kakao Talk Enterprise - - } - /> - - Contact us to enable Kakao Talk for your enterprise account. - - - - -
+ + + Contact us to enable Kakao Talk for your enterprise account. + + + } + /> +
+
); } diff --git a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx deleted file mode 100644 index a47883f92d..0000000000 --- a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client"; - -import React, { useMemo } from "react"; -import { - PreferenceBody, - PreferenceBox, - PreferenceBoxFooter, - PreferenceBoxHeader, - PreferenceDescription, -} from "@/components/preferences"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { Spinner } from "@/components/ui/spinner"; -import { PrivateEditorApi } from "@/lib/private"; -import { toast } from "sonner"; -import { Controller, useForm, useWatch } from "react-hook-form"; -import { useEditorState } from "@/scaffolds/editor"; -import { Badge } from "@/components/ui/badge"; -import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; -import { - EmailFrame, - EmailFrameSubject, - EmailFrameSender, - EmailFrameBody, -} from "@/components/frames/email-frame"; - -type FormValues = { - enabled: boolean; - from_name: string | null; - subject_template: string | null; - body_html_template: string | null; - reply_to: string | null; -}; - -export function NotificationRespondentEmailPreferences() { - const [state, dispatch] = useEditorState(); - const { form } = state; - - const isCiamEnabled = useMemo(() => { - return form.fields.some((f) => f.type === "challenge_email"); - }, [form.fields]); - - const initial = form.notification_respondent_email; - - const { - handleSubmit, - control, - setValue, - formState: { isSubmitting, isDirty }, - reset, - } = useForm({ - defaultValues: { - enabled: initial.enabled, - from_name: initial.from_name, - subject_template: initial.subject_template, - body_html_template: initial.body_html_template, - reply_to: initial.reply_to, - }, - }); - - const enabled = useWatch({ control, name: "enabled" }); - const reply_to = useWatch({ control, name: "reply_to" }); - const from_name = useWatch({ control, name: "from_name" }); - const subject_template = useWatch({ control, name: "subject_template" }); - const body_html_template = useWatch({ control, name: "body_html_template" }); - - const onSubmit = async (data: FormValues) => { - const req = PrivateEditorApi.Settings.updateNotificationRespondentEmail({ - form_id: form.form_id, - enabled: data.enabled, - from_name: data.from_name, - subject_template: data.subject_template, - body_html_template: data.body_html_template, - reply_to: data.reply_to, - }).then(() => { - dispatch({ - type: "editor/form/notification_respondent_email/preferences", - enabled: data.enabled, - from_name: data.from_name, - subject_template: data.subject_template, - body_html_template: data.body_html_template, - reply_to: data.reply_to, - }); - }); - - try { - await toast.promise(req, { - loading: "Saving...", - success: "Saved", - error: "Failed", - }); - reset(data); - } catch (e) { - // toast.promise handles UI - } - }; - - const disabled = !enabled; - const inputDisabled = disabled || !isCiamEnabled; - - return ( - - - Respondent email notifications - Pro - - } - description={ - <> - Send a confirmation email after a successful submission (CIAM / - verified email only). - - } - actions={ - ( -
- -
- )} - /> - } - /> - -
- - - {subject_template?.trim() || "Your submission has been received"} - - - - {body_html_template?.trim() ? ( -
- ) : ( - <> -

Thanks for your submission.

-

- Tip: add a body HTML template below to preview the email - content here. -

- - )} - - -
-
- - This notification requires CIAM email verification. Add a{" "} - challenge_email field to enable verified - respondent email sending. - - ) : null - } - helper={ - - Supported variables: {"{{form_title}}"},{" "} - {"{{response.idx}}"},{" "} - {"{{fields.}}"}. - - } - fields={{ - to: { state: "disabled", value: "Respondent (verified email)" }, - replyTo: { - state: "on", - value: reply_to ?? "", - disabled: inputDisabled, - placeholder: "support@yourdomain.com", - onValueChange: (v: string) => - setValue("reply_to", v || null, { - shouldDirty: true, - }), - }, - subject: { - state: "on", - value: subject_template ?? "", - disabled: inputDisabled, - placeholder: "Thanks, {{fields.first_name}}", - onValueChange: (v: string) => - setValue("subject_template", v || null, { - shouldDirty: true, - }), - }, - fromName: { - state: "on", - value: from_name ?? "", - disabled: inputDisabled, - placeholder: "Grida Forms", - onValueChange: (v: string) => - setValue("from_name", v || null, { shouldDirty: true }), - }, - from: { - state: "disabled", - value: `${from_name?.trim() || "Grida Forms"} `, - }, - bodyHtml: { - state: "on", - value: body_html_template ?? "", - disabled: inputDisabled, - placeholder: - "

Thanks

\n

We received your submission for {{form_title}}.

", - onValueChange: (v: string) => - setValue("body_html_template", v || null, { - shouldDirty: true, - }), - }, - }} - /> - - - - - - - ); -} From 49cf6a34b7f718217ac5ef8f27f6f214f64db346 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 00:46:58 +0900 Subject: [PATCH 10/11] mv --- .../(resources)/ciam/{presets => portal}/[id]/page.tsx | 4 ++-- .../(console)/(resources)/ciam/{presets => portal}/page.tsx | 0 .../(console)/(resources)/console-resources-sidebar.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/{presets => portal}/[id]/page.tsx (99%) rename editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/{presets => portal}/page.tsx (100%) diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx similarity index 99% rename from editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx rename to editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx index ef912f4805..420d1b4cd2 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/[id]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx @@ -281,13 +281,13 @@ export default function PortalPresetEditPage() { return false; } toast.success("Preset deleted"); - router.push(`/${params.org}/${params.proj}/ciam/presets`); + router.push(`/${params.org}/${params.proj}/ciam/portal`); return true; }, [client, params, router] ); - const basePath = `/${params.org}/${params.proj}/ciam/presets`; + const basePath = `/${params.org}/${params.proj}/ciam/portal`; if (isLoading || !preset) { return ( diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx similarity index 100% rename from editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/presets/page.tsx rename to editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx index 88988995fc..b68de16430 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/console-resources-sidebar.tsx @@ -99,7 +99,7 @@ export function ConsoleResourcesSidebar({ CIAM - + Customer Portal From b73b6da24ffe6c310fc1c30bac96068b84a5c021 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 00:47:34 +0900 Subject: [PATCH 11/11] chore: style --- .../[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx | 4 ++-- .../[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx index 420d1b4cd2..4419d2cfcd 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx @@ -292,7 +292,7 @@ export default function PortalPresetEditPage() { if (isLoading || !preset) { return (
-
+
@@ -302,7 +302,7 @@ export default function PortalPresetEditPage() { return (
-
+
-
+