diff --git a/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts b/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts index 47bcaac8ed..3be46c9325 100644 --- a/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts +++ b/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts @@ -8,6 +8,7 @@ import TenantCIAMEmailVerification, { import { otp6 } from "@/lib/crypto/otp"; import { service_role } from "@/lib/supabase/server"; import { select_lang } from "@/i18n/utils"; +import { getLocale } from "@/i18n/server"; import { challengeEmailStateKey, loadChallengeEmailContext, @@ -153,12 +154,14 @@ export async function POST( : undefined; const brand_support_contact = publisher.includes("@") ? publisher : undefined; - const langCandidate = - www && typeof www.lang === "string" && www.lang ? www.lang : formDoc.lang; - const emailLang: CIAMVerificationEmailLang = select_lang( - langCandidate, - supported_languages, - "en" + // Prefer the per-form document language when set; otherwise fall back to tenant/published `www.lang`. + // (Treat empty strings as "unset".) + const langCandidate = formDoc.lang?.trim() || www?.lang?.trim() || null; + // Prefer the visitor's device language. If unsupported, fall back to the form/tenant default. + const fallback_lang = select_lang(langCandidate, supported_languages, "en"); + const emailLang: CIAMVerificationEmailLang = await getLocale( + [...supported_languages], + fallback_lang ); const { error: resend_err } = await resend.emails.send({ from: `${brand_name} `, diff --git a/editor/app/(api)/(public)/v1/submit/[id]/route.ts b/editor/app/(api)/(public)/v1/submit/[id]/route.ts index 957826e874..a371281819 100644 --- a/editor/app/(api)/(public)/v1/submit/[id]/route.ts +++ b/editor/app/(api)/(public)/v1/submit/[id]/route.ts @@ -128,6 +128,15 @@ async function submit({ formdata: FormData | URLSearchParams | Map; meta: SessionMeta; }) { + function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; + } + + function readStringProp(obj: unknown, key: string): string | null { + if (!isRecord(obj)) return null; + const v = obj[key]; + return typeof v === "string" && v ? v : null; + } // console.log("form_id", form_id); // check for mandatory meta @@ -204,6 +213,54 @@ async function submit({ : null; // customer handling + // NOTE: challenge_email can bind a verified customer to the response_session. + // When that happens, we must reuse that customer instead of creating a new + // fingerprint-based customer (which causes duplicate customers for one respondent). + + const challenge_email_fields = fields.filter( + (f) => f.type === "challenge_email" + ); + + // These are also used later for persistence/binding. + let session_raw: Record | null = null; + let session_customer_id: string | null = null; + let verified_customer_id: string | null = null; + + if (challenge_email_fields.length > 0 && meta.session) { + const { data: response_session, error: response_session_err } = + await service_role.forms + .from("response_session") + .select("id, raw, customer_id") + .eq("id", meta.session) + .eq("form_id", form_id) + .single(); + + if (response_session_err || !response_session) { + console.error("submit/err/session", response_session_err); + return error(ERR.SERVICE_ERROR.code, { form_id }, meta); + } + + session_raw = + (response_session.raw as Record | null) ?? null; + session_customer_id = response_session.customer_id ?? null; + + // If verification succeeded, the challenge state stores `customer_uid`. + // Use it as an authoritative identity fallback (even when response_session.customer_id + // wasn't bound due to stricter binding rules). + for (const f of challenge_email_fields) { + const key = `__challenge_email__${f.id}`; + const obj = session_raw?.[key]; + const state = readStringProp(obj, "state"); + const customer_uid = readStringProp(obj, "customer_uid"); + if (state === "challenge-success" && customer_uid) { + verified_customer_id = customer_uid; + break; + } + } + } + + const effective_session_customer_id = + session_customer_id ?? verified_customer_id ?? null; const _gf_customer_uuid: string | null = qval( formdata.get(SYSTEM_GF_CUSTOMER_UUID_KEY) as string @@ -227,16 +284,50 @@ async function submit({ // console.log("/submit::_gf_customer_uuid:", _gf_customer_uuid); - const customer = await upsert_customer_with({ - project_id: form_reference.project_id, - uuid: _gf_customer_uuid, - hints: { - _fp_fingerprintjs_visitorid, - email: _gf_customer_email || undefined, - phone: _gf_customer_phone || undefined, - name: _gf_customer_name || undefined, - }, - }); + // Only create/upsert a customer when we do not already have a trusted customer id. + // This prevents duplicate customer rows for challenge_email flows. + const customer = effective_session_customer_id + ? null + : await upsert_customer_with({ + project_id: form_reference.project_id, + uuid: _gf_customer_uuid, + hints: { + _fp_fingerprintjs_visitorid, + email: _gf_customer_email || undefined, + phone: _gf_customer_phone || undefined, + name: _gf_customer_name || undefined, + }, + }); + + // Best-effort: if we have a verified/session customer, enrich it with submitted hints. + // Avoid writing empty values; keep this non-blocking. + if (effective_session_customer_id) { + const patch: { + last_seen_at: string; + email?: string; + phone?: string; + name?: string; + } = { last_seen_at: new Date().toISOString() }; + if (_gf_customer_email) patch.email = _gf_customer_email; + if (_gf_customer_phone) patch.phone = _gf_customer_phone; + if (_gf_customer_name) patch.name = _gf_customer_name; + + try { + const { error: cus_update_err } = await service_role.workspace + .from("customer") + .update(patch) + .eq("uid", effective_session_customer_id) + .eq("project_id", form_reference.project_id); + if (cus_update_err) { + console.error( + "customer::session_customer_update_error:", + cus_update_err + ); + } + } catch (e) { + console.error("customer::session_customer_update_unexpected_error:", e); + } + } // console.log("/submit::customer:", customer); @@ -285,7 +376,7 @@ async function submit({ // TODO: this also needs to be migrated to db constraints const max_access_by_customer_error = await validate_max_access_by_customer({ form_id, - customer_id: customer?.uid, + customer_id: effective_session_customer_id ?? customer?.uid ?? null, is_max_form_responses_by_customer_enabled, max_form_responses_by_customer, }); @@ -325,36 +416,10 @@ async function submit({ // ================================================== // challenge_email gating (session-scoped verification) // ================================================== - const challenge_email_fields = fields.filter( - (f) => f.type === "challenge_email" - ); const required_challenge_email_fields = challenge_email_fields.filter( (f) => f.required ); - // These are also used later for persistence/binding. - let session_raw: Record | null = null; - let session_customer_id: string | null = null; - - if (challenge_email_fields.length > 0 && meta.session) { - const { data: response_session, error: response_session_err } = - await service_role.forms - .from("response_session") - .select("id, raw, customer_id") - .eq("id", meta.session) - .eq("form_id", form_id) - .single(); - - if (response_session_err || !response_session) { - console.error("submit/err/session", response_session_err); - return error(ERR.SERVICE_ERROR.code, { form_id }, meta); - } - - session_raw = - (response_session.raw as Record | null) ?? null; - session_customer_id = response_session.customer_id ?? null; - } - if (required_challenge_email_fields.length > 0) { if (!meta.session || !session_raw) { return error(ERR.CHALLENGE_EMAIL_NOT_VERIFIED.code, { form_id }, meta); @@ -508,7 +573,8 @@ async function submit({ ip: meta.ip, // Prefer explicit customer from the submit payload; otherwise use session binding // (set only by trusted verification flows). - customer_id: customer?.uid ?? session_customer_id, + customer_id: + effective_session_customer_id ?? customer?.uid ?? session_customer_id, x_referer: meta.referer, x_useragent: meta.useragent, x_ipinfo: ipinfo_data as {}, @@ -545,7 +611,8 @@ async function submit({ form_id, ...({ fingerprint: _fp_fingerprintjs_visitorid, - customer_id: customer?.uid, + customer_id: + effective_session_customer_id ?? customer?.uid ?? undefined, session_id: meta.session || undefined, } satisfies FormLinkURLParams["alreadyresponded"]), }, diff --git a/editor/app/(api)/private/editor/[form_id]/fields/route.ts b/editor/app/(api)/private/editor/[form_id]/fields/route.ts index e6ec201be4..c26b656065 100644 --- a/editor/app/(api)/private/editor/[form_id]/fields/route.ts +++ b/editor/app/(api)/private/editor/[form_id]/fields/route.ts @@ -65,7 +65,7 @@ export async function POST( autocomplete: init.autocomplete, data: safe_data_field({ type: init.type, - data: init.data as any, + data: init.data, }) as any, accept: init.accept, multiple: init.multiple, @@ -323,9 +323,16 @@ function safe_data_field({ data, }: { type: FormInputType; - data?: FormFieldDataSchema; + data?: FormFieldDataSchema | null; }): FormFieldDataSchema | undefined | null { switch (type) { + case "tel": { + // Keep `data` as an object (if provided) so we can safely persist tel configs + // such as `default_country` without losing shape. + if (!data) return data; + if (typeof data === "object") return data; + return {}; + } case "payment": { // TODO: enhance the schema validation with external libraries if (!data || !(data as PaymentFieldData).type) { diff --git a/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx b/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx index 35d33f1f2b..a78865e6a9 100644 --- a/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx +++ b/editor/app/(tenant)/~/[tenant]/(p)/p/login/page.tsx @@ -1,15 +1,66 @@ 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"; -export default async function CustomerPortalLoginPage() { +type Params = { + tenant: string; +}; + +type WwwPublicRow = Database["grida_www"]["Views"]["www_public"]["Row"]; + +async function fetchPortalTitle(tenant: string) { + const client = await createWWWClient(); + + const { data: wwwPublic } = await client + .from("www_public") + .select("title") + .eq("name", tenant) + .single() + .returns>(); + + const title = + typeof wwwPublic?.title === "string" && wwwPublic.title.trim() + ? wwwPublic.title + : "Customer Portal"; + + return title; +} + +export default async function CustomerPortalLoginPage({ + params, +}: { + params: Promise; +}) { + const { tenant } = await params; const locale = await getLocale(["en", "ko"]); + const title = await fetchPortalTitle(tenant); return ( -
-
- -
+
+
+
+ + + {title} + + +
+
+
+
+ +
+
); } diff --git a/editor/app/(tenant)/~/[tenant]/(p)/p/session/[token]/page.tsx b/editor/app/(tenant)/~/[tenant]/(p)/p/session/[token]/page.tsx index f7bd26ea46..e83ba8f39a 100644 --- a/editor/app/(tenant)/~/[tenant]/(p)/p/session/[token]/page.tsx +++ b/editor/app/(tenant)/~/[tenant]/(p)/p/session/[token]/page.tsx @@ -7,6 +7,12 @@ import type { Database } from "@app/database"; import type { PostgrestError } from "@supabase/supabase-js"; import CampaignReferrerCard from "../west-campaign-referrer-card"; +// TODO(portal): The portal session page is not design-complete yet, but is used in production. +// Today enterprise customers typically only have one active campaign, so we temporarily redirect +// directly to the campaign page when there's only a single campaign in the portal session. +// Remove this once the session page UX is finalized and multi-campaign is common. +const TMP_SHOULD_REDIRECT_WHEN_SINGLE = true; + type Params = { token: string; }; @@ -124,6 +130,20 @@ export default async function CustomerPortalSessionPage({ console.error("[ciam]/referrer error", referrer_err); } + if ( + TMP_SHOULD_REDIRECT_WHEN_SINGLE && + iam_referrers && + iam_referrers.length === 1 + ) { + const r = iam_referrers[0]; + const path = r?.campaign?.www_route_path; + const code = r?.code; + if (typeof path === "string" && path && typeof code === "string" && code) { + // FIXME: tenant url + return redirect(`${path}/t/${code}`); + } + } + console.info("[ciam]/portal session redeemed", { tokenPreview, tokenLength: token.length, diff --git a/editor/app/(tenant)/~/[tenant]/api/ciam/auth/challenge/with-email/route.ts b/editor/app/(tenant)/~/[tenant]/api/ciam/auth/challenge/with-email/route.ts index c3ff5761c5..1b1dd122af 100644 --- a/editor/app/(tenant)/~/[tenant]/api/ciam/auth/challenge/with-email/route.ts +++ b/editor/app/(tenant)/~/[tenant]/api/ciam/auth/challenge/with-email/route.ts @@ -8,6 +8,7 @@ import TenantCIAMEmailVerification, { } from "@/theme/templates-email/ciam-verifiaction/default"; import { otp6 } from "@/lib/crypto/otp"; import { select_lang } from "@/i18n/utils"; +import { getLocale } from "@/i18n/server"; /** * POST /api/ciam/auth/challenge/with-email @@ -118,10 +119,11 @@ export async function POST( ? publisher : undefined; - const emailLang: CIAMVerificationEmailLang = select_lang( - www.lang, - supported_languages, - "en" + // 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: CIAMVerificationEmailLang = await getLocale( + [...supported_languages], + fallback_lang ); const { error: resend_err } = await resend.emails.send({ from: `${brand_name} `, 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 454140f61d..39d4432e7b 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 @@ -8,6 +8,7 @@ import TenantCustomerPortalAccessEmailVerification, { } from "@/theme/templates-email/customer-portal-verification/default"; import { otp6 } from "@/lib/crypto/otp"; import { select_lang } from "@/i18n/utils"; +import { getLocale } from "@/i18n/server"; // TODO: add rate limiting export async function POST( req: NextRequest, @@ -132,10 +133,11 @@ export async function POST( : undefined; const brand_support_contact = publisher.includes("@") ? publisher : undefined; - const emailLang: CustomerPortalVerificationEmailLang = select_lang( - www.lang, - supported_languages, - "en" + // 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({ diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx index 1931a24323..0e9b477b60 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx @@ -19,6 +19,8 @@ import { useForm } from "react-hook-form"; import { Skeleton } from "@/components/ui/skeleton"; import { FaviconEditor } from "@/scaffolds/www-theme-config/components/favicon"; import { SiteDomainsSection } from "./section-domain"; +import type { PostgrestError } from "@supabase/supabase-js"; +import type { Database } from "@app/database"; type ProjectWWW = { id: string; @@ -26,6 +28,7 @@ type ProjectWWW = { project_id: number; title: string | null; description: string | null; + lang: string; og_image: string | null; favicon: { src: string; @@ -33,6 +36,9 @@ type ProjectWWW = { } | null; }; +type ProjectWWWUpdate = Partial; +type WWWUpdateResult = { error: PostgrestError | null }; + function useSiteSettings() { const project = useProject(); const client = useMemo(() => createBrowserWWWClient(), []); @@ -51,7 +57,7 @@ function useSiteSettings() { }); const update = useCallback( - async (payload: Partial) => { + async (payload: ProjectWWWUpdate): Promise => { const task = await client .from("www") .update(payload) @@ -206,6 +212,7 @@ export default function ProjectWWWSettingsPage() { defaultValues={{ title: data.title, description: data.description, + lang: data.lang, }} update={update} /> @@ -256,14 +263,20 @@ function FormSiteGeneral({ }: { url: string; defaultValues: SiteGeneral; - update: (payload: SiteGeneral) => Promise; + update: (payload: SiteGeneral) => Promise; }) { const form = useForm({ defaultValues, }); const onSubmit = form.handleSubmit(async (values) => { - return await update(values); + const res = await update(values); + // Reset dirty state to the saved values (react-hook-form best practice) + // so the Save button disables again until the next change. + if (!res.error) { + form.reset(values); + } + return res; }); const { isSubmitting, isDirty } = form.formState; @@ -278,9 +291,10 @@ function FormSiteGeneral({ url={url} disabled={isSubmitting} value={form.watch()} - onValueChange={({ title, description }) => { + onValueChange={({ title, description, lang }) => { form.setValue("title", title, { shouldDirty: true }); form.setValue("description", description, { shouldDirty: true }); + form.setValue("lang", lang, { shouldDirty: true }); }} /> diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-general.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-general.tsx index d419433476..873e0fdd69 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-general.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-general.tsx @@ -1,13 +1,30 @@ "use client"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { DotsVerticalIcon } from "@radix-ui/react-icons"; export type SiteGeneral = { title: string | null; description: string | null; + /** + * Default/fallback language for public/tenant-facing experiences (e.g. verification emails). + * Keep this a short language code like "en", "ko". + */ + lang: string; }; export function SiteGeneralSection({ @@ -23,9 +40,9 @@ export function SiteGeneralSection({ }) { return (
-
-
- + + + Site Title -
-
- + + + + Site Description