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 +

+ + + + + + + + } + > + 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" && ( -
-
-
-
-
- -
-
-

{t.title}

-
- {t.description} -
-
-
- - {t.email} - setEmail(e.target.value)} - disabled={isLoading} - required - /> - - -
-
-
- )} - {step === "otp" && ( - - - - {t.verification} - - - - - - - - - - - {error && ( -
- {error} -
- )} - -
- -
-
-
- )} -
- ); -} - -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 ( +
+
+
+ + + Back to Customer Portal + +
+
+

{preset.name}

+ {preset.is_primary && ( + + + Primary + + )} +
+
+ {!preset.is_primary && ( + + )} + +
+
+
+ +
+ {/* Preset name */} + + Preset Name + + ( +
+ Name + + + Preset name + + + +
+ )} + /> + +
+ + } + /> + + + + {/* 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 + + + + +
+ +
+
+
+ + + +
+ +
+
+
+
+
+ } + /> + + + + {/* Email template */} + + + Verification Email Template + ( + + )} + /> + + {enabled ? ( +
+ + 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. + + + + )} + + } + 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 ( +
+
+
+
+ + Customer Portal + +

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

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

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

- - )} - -
-
- - 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, - onValueChange: (v: string) => - setValue("reply_to", v || null, { - shouldDirty: true, - }), - }, - subject: { - state: "on", - value: subject_template ?? "", - disabled: inputDisabled, - onValueChange: (v: string) => - setValue("subject_template", v || null, { - shouldDirty: true, - }), - }, - fromName: { - state: "on", - value: from_name ?? "", - disabled: inputDisabled, - 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, - onValueChange: (v: string) => - setValue("body_html_template", v || null, { - shouldDirty: true, - }), - }, - }} - /> - - - - - - - ); -} 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 ( +
+
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} +
+ )} + +
+ +
+
+
+
+ ); +} 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;