diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts
index 059e0eb7d4..e8202309ee 100644
--- a/database/database-generated.types.ts
+++ b/database/database-generated.types.ts
@@ -187,6 +187,39 @@ export type Database = {
}
Relationships: []
}
+ portal_preset: {
+ Row: {
+ created_at: string
+ id: string
+ is_primary: boolean
+ name: string
+ portal_login_page: Json
+ project_id: number
+ updated_at: string
+ verification_email_template: Json
+ }
+ Insert: {
+ created_at?: string
+ id?: string
+ is_primary?: boolean
+ name: string
+ portal_login_page?: Json
+ project_id: number
+ updated_at?: string
+ verification_email_template?: Json
+ }
+ Update: {
+ created_at?: string
+ id?: string
+ is_primary?: boolean
+ name?: string
+ portal_login_page?: Json
+ project_id?: number
+ updated_at?: string
+ verification_email_template?: Json
+ }
+ Relationships: []
+ }
}
Views: {
[_ in never]: never
@@ -295,6 +328,39 @@ export type Database = {
}
Relationships: []
}
+ portal_preset: {
+ Row: {
+ created_at: string | null
+ 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
+ }
+ Insert: {
+ created_at?: string | null
+ 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
+ }
+ Update: {
+ created_at?: string | null
+ 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
+ }
+ Relationships: []
+ }
}
Functions: {
create_customer_otp_challenge: {
@@ -337,6 +403,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: {
diff --git a/database/database.types.ts b/database/database.types.ts
index f8e582af6d..d6bd905f18 100644
--- a/database/database.types.ts
+++ b/database/database.types.ts
@@ -49,6 +49,40 @@ 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;
+};
+
+/**
+ * `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,
@@ -74,6 +108,22 @@ export type Database = MergeDeep<
tags?: string[] | null;
};
};
+ portal_preset: {
+ // 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: Omit & {
+ verification_email_template?: PortalPresetVerificationEmailTemplate;
+ portal_login_page?: PortalPresetLoginPage;
+ };
+ Update: Omit & {
+ verification_email_template?: PortalPresetVerificationEmailTemplate;
+ portal_login_page?: PortalPresetLoginPage;
+ };
+ };
};
};
grida_library: {
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 777de1ca05..c85149c6f7 100644
--- a/editor/app/(dev)/ui/frames/page.tsx
+++ b/editor/app/(dev)/ui/frames/page.tsx
@@ -1,7 +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 {
+ EmailFrame,
+ EmailFrameSubject,
+ EmailFrameSender,
+ EmailFrameBody,
+} from "@/components/frames/email-frame";
export default function FramesPage() {
return (
@@ -163,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
+
+
+
+
+
+ Reply
+
+
+
+ Forward
+
+
+
+ Archive
+
+
+
+ Delete
+
+ >
+ }
+ >
+ 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
@@ -173,11 +270,95 @@ export default function FramesPage() {
+
+
Mail (email preview)
+
+ Email frame for previewing email templates
+
+
+
+ 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.
+
+
+
+
+
+
+
+ Mail (long content, scrollable)
+
+
+ Email body scrolls when content exceeds the frame height
+
+
+
+ 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.
+
+
+
+
+
-
- More frame styles (Mail, Messages, etc.) are available in the
- components library.
-
diff --git a/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx b/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx
index 3fb9fcc8aa..7270e7c58e 100644
--- a/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx
+++ b/editor/app/(tenant)/~/[tenant]/(p)/p/login/login.tsx
@@ -1,69 +1,22 @@
"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: {
- // 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
- email: "이메일",
- continue_with_email: "이메일로 계속하기",
- sending: "전송중...",
- verification: "인증하기",
- verification_description:
- "입력하신 {email} 로 인증 코드를 발송하였습니다. 아래에 입력해 주세요. 코드를 수신하지 못한 경우, 정확한 이메일을 입력하였는지 다시 한 번 확인해 주세요.",
- verifying: "인증중...",
- back: "← 뒤로",
- },
-};
-
-interface CustomerPropsMinimalCustomizationProps {
+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("");
@@ -81,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,
};
};
@@ -97,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(() => {
@@ -116,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");
@@ -151,127 +98,19 @@ export default function PortalLogin({
router.replace(session_url);
};
- const t = dictionary[locale as keyof typeof dictionary];
-
- return (
-
- {step === "email" && (
-
- )}
- {step === "otp" && (
-
-
-
- {t.verification}
-
-
-
-
-
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
-
- setStep("email")}
- >
- {isLoading ? (
- <>
-
- {t.verifying}
- >
- ) : (
- <>{t.back}>
- )}
-
-
-
-
- )}
-
- );
-}
-
-function OTP({
- disabled,
- onComplete,
-}: {
- disabled?: boolean;
- onComplete?: (otp: string) => void;
-}) {
return (
- {
- onComplete?.(otp);
- }}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ setStep("email")}
+ error={error}
+ />
);
}
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/(tenant)/~/[tenant]/api/p/access/with-email/route.ts b/editor/app/(tenant)/~/[tenant]/api/p/access/with-email/route.ts
index 39d4432e7b..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
@@ -9,6 +9,10 @@ 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";
+import { sanitize_email_display_name } from "@/utils/sanitize";
+
// TODO: add rate limiting
export async function POST(
req: NextRequest,
@@ -133,29 +137,81 @@ 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 = sanitize_email_display_name(
+ preset_template.from_name?.trim() || brand_name
+ );
+ const replyTo = preset_template.reply_to?.trim() || undefined;
+
+ const { error } = await resend.emails.send({
+ from: `${fromName} `,
+ 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: `${sanitize_email_display_name(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/portal/[id]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx
new file mode 100644
index 0000000000..4419d2cfcd
--- /dev/null
+++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/[id]/page.tsx
@@ -0,0 +1,736 @@
+"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 { 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 { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring";
+import {
+ EmailFrame,
+ EmailFrameSubject,
+ EmailFrameSender,
+ EmailFrameBody,
+} from "@/components/frames/email-frame";
+import {
+ DeleteConfirmationAlertDialog,
+ DeleteConfirmationSnippet,
+} from "@/components/dialogs/delete-confirmation-dialog";
+import { useDialogState } from "@/components/hooks/use-dialog-state";
+import { ArrowLeftIcon, ExternalLink, StarIcon } from "lucide-react";
+import Link from "next/link";
+import { previewlink } from "@/lib/internal/url";
+import {
+ Field,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ 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,
+ PortalPresetLoginPage,
+} from "@app/database";
+
+type PortalPresetRow =
+ Database["grida_ciam_public"]["Views"]["portal_preset"]["Row"];
+
+type EmailFormValues = {
+ enabled: boolean;
+ from_name: string | null;
+ subject_template: string | null;
+ body_html_template: string | null;
+ reply_to: string | null;
+};
+
+type LoginPageFormValues = {
+ email_step_title: string;
+ email_step_description: string;
+ email_step_button_label: string;
+ otp_step_title: string;
+ 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();
+ 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 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,
+ } = emailForm;
+
+ 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" });
+
+ // --- Login page 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 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",
+ 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 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,
+ 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/portal`);
+ return true;
+ },
+ [client, params, router]
+ );
+
+ const basePath = `/${params.org}/${params.proj}/ciam/portal`;
+
+ if (isLoading || !preset) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {/* Preset name */}
+
+ Preset Name
+
+ (
+
+ Name
+
+
+ Preset name
+
+
+
+
+ )}
+ />
+
+ {nameForm.formState.isSubmitting ? : "Save"}
+
+
+
+ }
+ />
+
+
+
+ {/* Login page */}
+
+
+
+ }
+ preview={
+
+
+
+
+ Email Step
+ OTP Step
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {/* Email template */}
+
+
+ Verification Email Template
+ (
+
+ )}
+ />
+
+ {enabled ? (
+
+ ) : (
+
+
+ Enable the toggle above to customize the verification email.
+ When disabled, the default Grida verification email is used.
+
+
+ {isSubmitting ? : "Save"}
+
+
+ )}
+
+ }
+ preview={
+ enabled ? (
+
+
+
+ {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
+ }
+ />
+
+
+
+ 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/portal/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/page.tsx
new file mode 100644
index 0000000000..063bb3522e
--- /dev/null
+++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/ciam/portal/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 (
+
+
+
+
+ {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 && (
+ {
+ e.preventDefault();
+ handleSetPrimary(preset.id);
+ }}
+ >
+ Set as 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..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
@@ -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, ShieldCheckIcon, Store } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -95,10 +95,14 @@ export function ConsoleResourcesSidebar({
Tags
-
+
CIAM
+
+
+ Customer Portal
+
@@ -140,10 +144,6 @@ export function ConsoleResourcesSidebar({
Connections
-
-
- CIAM
-
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 (
-
-
-
-
+
+
+
+
+ {/* Email notifications */}
+
+
+
+ Respondent Email Notifications
+ Pro
+
+ (
+
+ )}
+ />
+
+
+ Send a confirmation email after a successful submission (CIAM
+ / verified email only).
+
+ {emailEnabled ? (
+
+ ) : (
+
+
+ Enable the toggle above to customize the respondent email
+ notification.
+
+
+ {emailSubmitting ? : "Save"}
+
+
+ )}
+
+ }
+ 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
+
+
+
+
+
+
+ {SMS_DEFAULT_ORIGINATOR} (default)
+
+
+
+
+
+
+ }
+ preview={
+
+
-
-
- Originator
-
-
-
-
-
-
- {SMS_DEFAULT_ORIGINATOR} (default)
-
-
-
-
-
-
-
-
-
-
+ }
+ />
+
+
+
+ {/* 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/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)
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 fbebda8305..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
-
-
-
-
-
-
- Inbox
-
-
-
- Archive
-
-
-
- Trash
-
-
-
- Labels
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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 (
-
- );
-}
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
deleted file mode 100644
index d6a660881a..0000000000
--- a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx
+++ /dev/null
@@ -1,246 +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 MailAppFrame from "@/components/frames/mail-app-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={
- (
-
-
-
- )}
- />
- }
- />
-
-
-
- {body_html_template?.trim() ? (
-
- ) : (
- <>
- Thanks for your submission.
-
- Tip: add a body HTML template below to preview the email
- content here.
-
- >
- )}
-
-
-
-
-
-
- {isSubmitting ? : "Save"}
-
-
-
- );
-}
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/editor/theme/templates/portal-login/202602-default/portal-login-view.tsx b/editor/theme/templates/portal-login/202602-default/portal-login-view.tsx
new file mode 100644
index 0000000000..b87351ae2e
--- /dev/null
+++ b/editor/theme/templates/portal-login/202602-default/portal-login-view.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import React 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 type { PortalPresetLoginPage } from "@app/database";
+
+export type LoginStep = "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: "← 뒤로",
+ },
+};
+
+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 (
+
+ );
+ }
+
+ // OTP step
+ return (
+
+
+
+ {otpStepTitle}
+
+
+
+
+
+
+
+ onOtpComplete?.(otp)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!viewOnly && error && (
+
+ {error}
+
+ )}
+
+
+
+ {!viewOnly && isLoading ? (
+ <>
+
+ {t.verifying}
+ >
+ ) : (
+ t.back
+ )}
+
+
+
+
+
+ );
+}
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
new file mode 100644
index 0000000000..124231d51a
--- /dev/null
+++ b/supabase/migrations/20260207000000_grida_ciam_portal_preset.sql
@@ -0,0 +1,172 @@
+-- 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).
+-- Also stores optional login-page text overrides per preset.
+
+---------------------------------------------------------------------
+-- [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,
+ portal_login_page jsonb NOT NULL DEFAULT '{"template_id":"202602-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
+ )
+ );
+
+-- JSON schema guard for portal login page text overrides.
+-- The required "template_id" field acts as a version discriminator; future
+-- schema revisions introduce a new template_id value + a new constraint,
+-- making stale rows fail validation and forcing an explicit migration.
+ALTER TABLE grida_ciam.portal_preset
+ ADD CONSTRAINT portal_preset_portal_login_page_check
+ CHECK (
+ extensions.jsonb_matches_schema(
+ '{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["template_id"],
+ "properties": {
+ "template_id": { "const": "202602-default" },
+ "email_step_title": { "type": ["string", "null"] },
+ "email_step_description": { "type": ["string", "null"] },
+ "email_step_button_label": { "type": ["string", "null"] },
+ "otp_step_title": { "type": ["string", "null"] },
+ "otp_step_description": { "type": ["string", "null"] }
+ }
+ }'::json,
+ portal_login_page
+ )
+ );
+
+-- 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,
+ portal_login_page
+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 = pg_catalog, public
+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$;
+
+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 d96242cc3b..de28a1ca41 100644
--- a/supabase/schemas/grida_ciam.sql
+++ b/supabase/schemas/grida_ciam.sql
@@ -695,3 +695,154 @@ $$;
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 and login page text 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,
+ portal_login_page jsonb NOT NULL DEFAULT '{"template_id":"202602-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
+ )
+ );
+
+ALTER TABLE grida_ciam.portal_preset
+ ADD CONSTRAINT portal_preset_portal_login_page_check
+ CHECK (
+ extensions.jsonb_matches_schema(
+ '{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["template_id"],
+ "properties": {
+ "template_id": { "const": "202602-default" },
+ "email_step_title": { "type": ["string", "null"] },
+ "email_step_description": { "type": ["string", "null"] },
+ "email_step_button_label": { "type": ["string", "null"] },
+ "otp_step_title": { "type": ["string", "null"] },
+ "otp_step_description": { "type": ["string", "null"] }
+ }
+ }'::json,
+ portal_login_page
+ )
+ );
+
+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,
+ portal_login_page
+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 = pg_catalog, public
+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$;
+
+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
new file mode 100644
index 0000000000..ae4fd6f948
--- /dev/null
+++ b/supabase/tests/test_grida_ciam_portal_preset_rls_test.sql
@@ -0,0 +1,281 @@
+BEGIN;
+SELECT plan(16);
+
+---------------------------------------------------------------------
+-- 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();
+
+---------------------------------------------------------------------
+-- 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 16: 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;