From b6546953698935a15e51048dd90be8291cf1e89b Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Sat, 25 Jul 2026 15:14:48 +0530 Subject: [PATCH 1/3] feat: refactor settings page into tabbed layout with dedicated integrations page --- .../src/components/Auth/SocialAuthConfig.jsx | 22 +- .../Settings/IntegrationsSettings.jsx | 483 ++++++++++++++++++ .../src/pages/ProjectSettings.jsx | 262 +++++----- 3 files changed, 640 insertions(+), 127 deletions(-) create mode 100644 apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx diff --git a/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx b/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx index 25e9b18f6..154e685e3 100644 --- a/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx +++ b/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx @@ -1,7 +1,11 @@ import React from 'react'; -import { Settings } from 'lucide-react'; +import { Settings, ExternalLink } from 'lucide-react'; +import { useNavigate, useParams } from 'react-router-dom'; const SocialAuthConfig = ({ authProviders, onOpenModal, setSelectedProvider }) => { + const navigate = useNavigate(); + const { projectId } = useParams(); + return (
@@ -13,9 +17,19 @@ const SocialAuthConfig = ({ authProviders, onOpenModal, setSelectedProvider }) = Configure third-party login providers for your application.

- +
+ + +
diff --git a/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx new file mode 100644 index 000000000..111e5b113 --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx @@ -0,0 +1,483 @@ +import React, { useState, useEffect } from 'react'; +import { + Database, Shield, Mail, HardDrive, CheckCircle, Server, Copy, + Key, Lock, Check, ExternalLink, RefreshCw +} from 'lucide-react'; +import api from '../../utils/api'; +import toast from 'react-hot-toast'; +import { API_URL } from '../../config'; + +const inputStyle = { + width: '100%', + padding: '7px 10px', + background: 'var(--color-bg-input)', + border: '1px solid var(--color-border)', + borderRadius: '4px', + color: '#fff', + fontSize: '0.8rem', +}; + +function FormField({ label, hint, children }) { + return ( +
+ {label && ( + + )} + {children} + {hint && {hint}} +
+ ); +} + +function IntegrationSectionCard({ title, description, icon: Icon, iconColor = 'var(--color-primary)', accentColor, children, style = {} }) { + return ( +
+ {accentColor && ( +
+ )} +
+ {title && ( +
+
+ {Icon && } +

{title}

+
+ {description && ( +

+ {description} +

+ )} +
+ )} + {children} +
+
+ ); +} + +export default function IntegrationsSettings(props) { + const { + project, + projectId, + onProjectUpdate, + role, + DatabaseConfigForm: DatabaseForm, + StorageConfigForm: StorageForm, + MailTemplatesForm: MailTemplates, + hasResendKey, + resendKeyValue, + setResendKeyValue, + resendFromEmailValue, + setResendFromEmailValue, + resendKeyLoading, + handleResendKeySave, + } = props; + + const isViewer = role === 'viewer'; + const PUBLIC_API_URL = API_URL || 'https://api.urbackend.com'; + + // Auth Providers State + const [authProviders, setAuthProviders] = useState({ + github: { enabled: false, clientId: '', clientSecret: '', hasClientSecret: false }, + google: { enabled: false, clientId: '', clientSecret: '', hasClientSecret: false } + }); + const [isSavingProviders, setIsSavingProviders] = useState(false); + const [activeAuthTab, setActiveAuthTab] = useState('github'); + const [copiedUrl, setCopiedUrl] = useState(null); + + const siteUrl = project?.siteUrl || ''; + const githubCallbackUrl = `${PUBLIC_API_URL}/api/userAuth/social/github/callback`; + const googleCallbackUrl = `${PUBLIC_API_URL}/api/userAuth/social/google/callback`; + + useEffect(() => { + if (project?.authProviders) { + const providers = project.authProviders; + Promise.resolve().then(() => setAuthProviders(providers)); + } + }, [project?.authProviders]); + + const handleProviderFieldChange = (provider, field, value) => { + setAuthProviders((prev) => ({ + ...prev, + [provider]: { ...prev[provider], [field]: value } + })); + }; + + const handleSaveProviders = async () => { + setIsSavingProviders(true); + try { + const payload = { + github: { + enabled: !!authProviders.github.enabled, + clientId: authProviders.github.clientId, + ...(authProviders.github.clientSecret ? { clientSecret: authProviders.github.clientSecret } : {}) + }, + google: { + enabled: !!authProviders.google.enabled, + clientId: authProviders.google.clientId, + ...(authProviders.google.clientSecret ? { clientSecret: authProviders.google.clientSecret } : {}) + } + }; + const res = await api.patch(`/api/projects/${projectId}/auth/providers`, payload); + setAuthProviders(res.data.authProviders); + if (onProjectUpdate) { + onProjectUpdate(prev => ({ ...prev, authProviders: res.data.authProviders })); + } + toast.success('Auth provider settings saved!'); + } catch (err) { + toast.error(err.response?.data?.message || 'Failed to save auth provider settings'); + } finally { + setIsSavingProviders(false); + } + }; + + const copyToClipboard = (text, type) => { + navigator.clipboard.writeText(text); + setCopiedUrl(type); + toast.success('Callback URL copied!'); + setTimeout(() => setCopiedUrl(null), 2000); + }; + + return ( +
+ + {/* 1. DATABASES SECTION */} + +
+ {/* Primary MongoDB */} + + + {/* Redis (Coming Soon) */} +
+
+
+ +
+

Redis Cache

+

+ High performance memory cache & pub/sub engine. +

+
+
+ + Coming Soon + +
+
+
+
+ + {/* 2. AUTH INTEGRATIONS SECTION */} + +
+ + {/* Site URL warning check */} + {!siteUrl && ( +
+ ⚠️ Site URL is not set: Please set your app's frontend URL in the General Settings so OAuth redirects to /auth/callback correctly. +
+ )} + + {/* Auth Provider Selector Tabs */} +
+ {[ + { id: 'github', label: 'GitHub OAuth', enabled: authProviders.github.enabled, configured: authProviders.github.hasClientSecret }, + { id: 'google', label: 'Google OAuth', enabled: authProviders.google.enabled, configured: authProviders.google.hasClientSecret } + ].map((prov) => ( + + ))} +
+ + {/* Active Provider Form */} + {activeAuthTab === 'github' && ( +
+
+
+

GitHub OAuth Integration

+

+ Allow users to log in with their GitHub account. +

+
+ +
+ + +
+ + +
+
+ +
+ + handleProviderFieldChange('github', 'clientId', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + + + + Client Secret{' '} + + • {authProviders.github.hasClientSecret ? 'Configured (encrypted)' : 'Not set'} + + + } + > + handleProviderFieldChange('github', 'clientSecret', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + +
+
+ )} + + {activeAuthTab === 'google' && ( +
+
+
+

Google OAuth Integration

+

+ Allow users to log in with Google accounts. +

+
+ +
+ + +
+ + +
+
+ +
+ + handleProviderFieldChange('google', 'clientId', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + + + + Client Secret{' '} + + • {authProviders.google.hasClientSecret ? 'Configured (encrypted)' : 'Not set'} + + + } + > + handleProviderFieldChange('google', 'clientSecret', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + +
+
+ )} + + {!isViewer && ( +
+ +
+ )} +
+
+ + {/* 3. MAIL INTEGRATIONS SECTION */} + +
+ {/* Provider Selection Info */} +
+
+ Active Provider: Resend.com (BYOK) + + SendGrid, AWS SES & urMail Engine support are in development. + +
+
+ + {/* Resend BYOK Card */} +
+

Resend.com API Key

+

+ Upload a per-project Resend API key to send mail from your custom domain. +

+
+ + Resend API Key{' '} + + · {hasResendKey ? 'Configured' : 'Not configured'} + + + }> + setResendKeyValue(e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + + Blank defaults to onboarding@resend.dev}> + setResendFromEmailValue(e.target.value)} + style={inputStyle} + disabled={isViewer} + /> + +
+ {!isViewer && ( +
+ +
+ )} +
+ + {/* Mail Templates Sub-form */} + +
+
+ + {/* 4. STORAGE INTEGRATIONS SECTION */} + + + + +
+ ); +} diff --git a/apps/web-dashboard/src/pages/ProjectSettings.jsx b/apps/web-dashboard/src/pages/ProjectSettings.jsx index 7b6f25162..6c29b34a5 100644 --- a/apps/web-dashboard/src/pages/ProjectSettings.jsx +++ b/apps/web-dashboard/src/pages/ProjectSettings.jsx @@ -1,15 +1,16 @@ import { useState, useEffect, useMemo } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import api from "../utils/api"; import { useAuth } from "../context/AuthContext"; import toast from "react-hot-toast"; import { Trash2, AlertTriangle, Save, CheckCircle, Copy, Server, Globe, Plus, X, - Settings, HardDrive, Database, Mail, Shield, Eye, Pencil + Settings, HardDrive, Database, Mail, Shield, Eye, Pencil, Sliders } from "lucide-react"; import { API_URL } from "../config"; import ConfirmationModal from "./ConfirmationModal"; import SectionHeader from "../components/Dashboard/SectionHeader"; +import IntegrationsSettings from "../components/Settings/IntegrationsSettings"; /* ─── Reusable compact form-field wrapper ─── */ function FormField({ label, hint, children }) { @@ -61,6 +62,10 @@ export default function ProjectSettings() { const { projectId } = useParams(); const { user } = useAuth(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + + const [activeTab, setActiveTab] = useState(tabParam === 'integrations' ? 'integrations' : 'general'); const [showModal, setShowModal] = useState(false); const [project, setProject] = useState(null); @@ -75,6 +80,17 @@ export default function ProjectSettings() { const [siteUrl, setSiteUrl] = useState(""); const [renaming, setRenaming] = useState(false); + useEffect(() => { + if (tabParam === 'integrations' || tabParam === 'general') { + Promise.resolve().then(() => setActiveTab(tabParam)); + } + }, [tabParam]); + + const handleTabChange = (tab) => { + setActiveTab(tab); + setSearchParams({ tab }); + }; + useEffect(() => { const fetchProject = async () => { try { @@ -190,7 +206,7 @@ export default function ProjectSettings() {
{/* Page header */} -
+
@@ -202,87 +218,75 @@ export default function ProjectSettings() {
-
- - {/* General Information */} -
- - -
- - setNewName(e.target.value)} - style={inputStyle} - disabled={isViewer} - /> - - Used by Social Auth to redirect to /auth/callback} - > - setSiteUrl(e.target.value)} - placeholder="https://your-app.com" - style={inputStyle} - disabled={isViewer} - /> - -
- {!isViewer && ( -
- -
- )} -
-
+ {/* Top Navigation Tabs */} +
+ + + +
- {/* Mail */} -
- -
- -

- Upload a per-project Resend API key to send mail from your own account. The key is encrypted at rest and never exposed after saving. -

-
- - Resend API Key{' '} - - · {hasResendKey ? 'Configured' : 'Not configured'} - - - }> + {/* TAB CONTENT */} + {activeTab === 'general' ? ( +
+ {/* General Information */} +
+ + +
+ setResendKeyValue(e.target.value)} - style={{ ...inputStyle, fontFamily: 'monospace' }} + value={newName} + onChange={(e) => setNewName(e.target.value)} + style={inputStyle} disabled={isViewer} /> - Blank defaults to onboarding@resend.dev}> + Used by Social Auth to redirect to /auth/callback} + > setResendFromEmailValue(e.target.value)} + value={siteUrl} + onChange={(e) => setSiteUrl(e.target.value)} + placeholder="https://your-app.com" style={inputStyle} disabled={isViewer} /> @@ -291,67 +295,79 @@ export default function ProjectSettings() { {!isViewer && (
)} - -
-
- {/* External Configuration */} -
- -
+ {/* Allowed Domains (CORS) */} +
+ - -
-
- {/* Danger Zone */} - {isOwner && ( -
- -
-
- -

Delete Project

-
-

- This will permanently delete {project?.name} and all associated data including collections, files, and users. This action cannot be undone. -

-
- Type {project?.name} to confirm}> - setDeleteConfirm(e.target.value)} - style={{ ...inputStyle, border: '1px solid rgba(234,84,85,0.3)' }} - /> - - + {/* Danger Zone */} + {isOwner && ( +
+ +
+
+ +

Delete Project

+
+

+ This will permanently delete {project?.name} and all associated data including collections, files, and users. This action cannot be undone. +

+
+ Type {project?.name} to confirm}> + setDeleteConfirm(e.target.value)} + style={{ ...inputStyle, border: '1px solid rgba(234,84,85,0.3)' }} + /> + + +
-
- )} -
+ )} +
+ ) : ( + /* INTEGRATIONS TAB */ + + )} {showModal && ( Date: Sat, 25 Jul 2026 15:51:25 +0530 Subject: [PATCH 2/3] fix: address code review comments for settings & integrations page refactor --- .../Settings/AllowedDomainsForm.jsx | 109 ++ .../Settings/DatabaseConfigForm.jsx | 140 +++ .../Settings/IntegrationsSettings.jsx | 92 +- .../components/Settings/MailTemplatesForm.jsx | 475 +++++++++ .../components/Settings/StorageConfigForm.jsx | 184 ++++ .../components/Settings/formPrimitives.jsx | 34 + apps/web-dashboard/src/config.js | 4 +- .../src/pages/ProjectSettings.jsx | 952 +----------------- apps/web-dashboard/src/utils/styles.js | 10 + 9 files changed, 1002 insertions(+), 998 deletions(-) create mode 100644 apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx create mode 100644 apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx create mode 100644 apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx create mode 100644 apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx create mode 100644 apps/web-dashboard/src/components/Settings/formPrimitives.jsx create mode 100644 apps/web-dashboard/src/utils/styles.js diff --git a/apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx b/apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx new file mode 100644 index 000000000..954f05a3b --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from "react"; +import api from "../../utils/api"; +import toast from "react-hot-toast"; +import { Globe, Plus, AlertTriangle, X } from "lucide-react"; +import { SettingsCard } from "./formPrimitives"; +import { inputStyle } from "../../utils/styles"; + +export default function AllowedDomainsForm({ project, projectId, onProjectUpdate, role }) { + const isViewer = role === 'viewer'; + const [domains, setDomains] = useState(project?.allowedDomains || []); + const [newDomain, setNewDomain] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (project?.allowedDomains) { + Promise.resolve().then(() => setDomains(project.allowedDomains)); + } + }, [project?.allowedDomains]); + + const handleUpdate = async (updatedDomains) => { + setLoading(true); + try { + await api.patch(`/api/projects/${projectId}/allowed-domains`, { domains: updatedDomains }); + toast.success("Allowed domains updated!"); + setDomains(updatedDomains); + onProjectUpdate((prev) => ({ ...prev, allowedDomains: updatedDomains })); + } catch (err) { + toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to update allowed domains"); + } finally { + setLoading(false); + } + }; + + const addDomain = () => { + let domain = newDomain.trim(); + if (!domain) return; + if (domain !== "*" && domain.endsWith("/")) domain = domain.slice(0, -1); + if (domains.includes(domain)) return toast.error("Domain already added"); + const updated = domain === "*" ? ["*"] : [...domains.filter(d => d !== "*"), domain]; + handleUpdate(updated); + setNewDomain(""); + }; + + const removeDomain = (d) => handleUpdate(domains.filter(x => x !== d)); + + return ( + +

+ Restrict which websites can send requests using your Publishable API Key.{' '} + Use * to allow all, or specify like https://example.com. +

+ + {!isViewer && ( +
+ setNewDomain(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addDomain(); } }} + style={{ ...inputStyle, flex: 1 }} + /> + +
+ )} + + {domains.length === 0 ? ( +
+ No domains configured — your publishable key won't work on the web. Add * to allow all. +
+ ) : ( +
+ {domains.map((domain) => ( +
+
+ {domain === "*" ? ( + + ALLOW ALL (*) + + ) : ( + {domain} + )} +
+ {!isViewer && ( + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx new file mode 100644 index 000000000..a47f6e763 --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from "react"; +import api from "../../utils/api"; +import toast from "react-hot-toast"; +import { Database, CheckCircle, Server, Copy } from "lucide-react"; +import ConfirmationModal from "../../pages/ConfirmationModal"; +import { SettingsCard } from "./formPrimitives"; +import { inputStyle } from "../../utils/styles"; + +export default function DatabaseConfigForm({ project, projectId, onProjectUpdate, role }) { + const isViewer = role === 'viewer'; + const [dbUri, setDbUri] = useState(""); + const [loading, setLoading] = useState(false); + const isConfigured = project?.resources?.db?.isExternal || false; + const [showForm, setShowForm] = useState(!isConfigured); + const [showRemoveModal, setShowRemoveModal] = useState(false); + const [serverIp, setServerIp] = useState(null); + + useEffect(() => { + let isMounted = true; + Promise.resolve().then(() => setShowForm(!(project?.resources?.db?.isExternal || false))); + const fetchIp = async () => { + try { + const res = await api.get(`/api/server-ip`); + if (isMounted) setServerIp(res.data.ip); + } + catch (e) { console.error("Failed to fetch server IP", e); } + }; + fetchIp(); + return () => { isMounted = false; }; + }, [project]); + + const copyIp = async () => { + if (serverIp && navigator?.clipboard) { + try { + await navigator.clipboard.writeText(serverIp); + toast.success("Server IP copied!"); + } catch { + toast.error("Failed to copy server IP"); + } + } + }; + + const handleUpdate = async () => { + if (!dbUri) return toast.error("Database URI is required"); + setLoading(true); + try { + await api.patch(`/api/projects/${projectId}/byod-config`, { dbUri }); + toast.success("Database configuration updated!"); + setShowForm(false); + setDbUri(""); + } catch (err) { + const errorMsg = err.response?.data?.message || err.response?.data?.error || "Failed to update DB config"; + if (errorMsg.includes("whitelist Server IP")) { + toast.error(
Access Denied!
{errorMsg}
, { duration: 6000 }); + } else { + toast.error(errorMsg); + } + } finally { + setLoading(false); + } + }; + + const executeRemove = async () => { + try { + await api.delete(`/api/projects/${projectId}/byod-config/db`, { data: { projectId } }); + toast.success("External database configuration removed!"); + onProjectUpdate(prev => ({ ...prev, resources: { ...prev.resources, db: { ...prev.resources.db, isExternal: false } } })); + setShowForm(true); + } catch (err) { + toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to remove DB config"); + } finally { + setLoading(false); + } + }; + + return ( + + {showRemoveModal && ( + { executeRemove(); setShowRemoveModal(false); }} + onCancel={() => setShowRemoveModal(false)} + /> + )} +

Connect your own MongoDB cluster for full data ownership.

+ + {isConfigured && !showForm ? ( +
+
+ Connected to external MongoDB +
+ {!isViewer && ( +
+ + +
+ )} +
+ ) : ( +
+
+ + setDbUri(e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> +
+
+
+ + Public IP: {serverIp || "..."} + {serverIp && ( + + )} +
+ {!isViewer && ( +
+ {isConfigured && } + +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx index 111e5b113..3b5508b3b 100644 --- a/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx +++ b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx @@ -1,35 +1,15 @@ import React, { useState, useEffect } from 'react'; import { - Database, Shield, Mail, HardDrive, CheckCircle, Server, Copy, - Key, Lock, Check, ExternalLink, RefreshCw + Database, Shield, Mail, HardDrive, Check, Copy, Server } from 'lucide-react'; import api from '../../utils/api'; import toast from 'react-hot-toast'; -import { API_URL } from '../../config'; - -const inputStyle = { - width: '100%', - padding: '7px 10px', - background: 'var(--color-bg-input)', - border: '1px solid var(--color-border)', - borderRadius: '4px', - color: '#fff', - fontSize: '0.8rem', -}; - -function FormField({ label, hint, children }) { - return ( -
- {label && ( - - )} - {children} - {hint && {hint}} -
- ); -} +import { PUBLIC_API_URL } from '../../config'; +import { FormField } from './formPrimitives'; +import { inputStyle } from '../../utils/styles'; +import DatabaseConfigForm from './DatabaseConfigForm'; +import StorageConfigForm from './StorageConfigForm'; +import MailTemplatesForm from './MailTemplatesForm'; function IntegrationSectionCard({ title, description, icon: Icon, iconColor = 'var(--color-primary)', accentColor, children, style = {} }) { return ( @@ -57,26 +37,20 @@ function IntegrationSectionCard({ title, description, icon: Icon, iconColor = 'v ); } -export default function IntegrationsSettings(props) { - const { - project, - projectId, - onProjectUpdate, - role, - DatabaseConfigForm: DatabaseForm, - StorageConfigForm: StorageForm, - MailTemplatesForm: MailTemplates, - hasResendKey, - resendKeyValue, - setResendKeyValue, - resendFromEmailValue, - setResendFromEmailValue, - resendKeyLoading, - handleResendKeySave, - } = props; - +export default function IntegrationsSettings({ + project, + projectId, + onProjectUpdate, + role, + hasResendKey, + resendKeyValue, + setResendKeyValue, + resendFromEmailValue, + setResendFromEmailValue, + resendKeyLoading, + handleResendKeySave, +}) { const isViewer = role === 'viewer'; - const PUBLIC_API_URL = API_URL || 'https://api.urbackend.com'; // Auth Providers State const [authProviders, setAuthProviders] = useState({ @@ -133,11 +107,19 @@ export default function IntegrationsSettings(props) { } }; - const copyToClipboard = (text, type) => { - navigator.clipboard.writeText(text); - setCopiedUrl(type); - toast.success('Callback URL copied!'); - setTimeout(() => setCopiedUrl(null), 2000); + const copyToClipboard = async (text, type) => { + if (!navigator?.clipboard) { + toast.error('Clipboard access is not available.'); + return; + } + try { + await navigator.clipboard.writeText(text); + setCopiedUrl(type); + toast.success('Callback URL copied!'); + setTimeout(() => setCopiedUrl(null), 2000); + } catch { + toast.error('Failed to copy to clipboard.'); + } }; return ( @@ -153,7 +135,7 @@ export default function IntegrationsSettings(props) { >
{/* Primary MongoDB */} - + {/* Redis (Coming Soon) */}
@@ -256,6 +238,7 @@ export default function IntegrationsSettings(props) { />
{/* Mail Templates Sub-form */} - +
@@ -475,7 +459,7 @@ export default function IntegrationsSettings(props) { iconColor="#34d399" accentColor="#34d399" > - +
diff --git a/apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx b/apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx new file mode 100644 index 000000000..a1814f459 --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx @@ -0,0 +1,475 @@ +import React, { useState, useEffect, useMemo } from "react"; +import api from "../../utils/api"; +import toast from "react-hot-toast"; +import { Mail, Eye, Pencil, Trash2, Plus, Save, X } from "lucide-react"; +import ConfirmationModal from "../../pages/ConfirmationModal"; +import { SettingsCard, FormField } from "./formPrimitives"; +import { inputStyle } from "../../utils/styles"; + +export default function MailTemplatesForm({ projectId, role }) { + const isViewer = role === 'viewer'; + const [templates, setTemplates] = useState([]); + const [globalTemplates, setGlobalTemplates] = useState([]); + const [loading, setLoading] = useState(true); + + const [editorOpen, setEditorOpen] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + + const [saving, setSaving] = useState(false); + const [activeTemplate, setActiveTemplate] = useState(null); + const [editingId, setEditingId] = useState(null); + + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const [form, setForm] = useState({ key: "", name: "", subject: "", html: "", text: "" }); + const [variablesText, setVariablesText] = useState('{\n "name": "John"\n}'); + + const fetchTemplates = async () => { + setLoading(true); + try { + const [projectRes, globalRes] = await Promise.all([ + api.get(`/api/projects/${projectId}/mail/templates`), + api.get(`/api/projects/${projectId}/mail/templates/global`), + ]); + setTemplates(projectRes.data?.data?.templates || []); + setGlobalTemplates(globalRes.data?.data?.templates || []); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to load templates"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + let isMounted = true; + Promise.resolve().then(() => { + if (isMounted) fetchTemplates(); + }); + return () => { isMounted = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const fetchTemplateDetails = async (templateId) => { + const res = await api.get(`/api/projects/${projectId}/mail/templates/${templateId}`); + return res.data?.data?.template; + }; + + const openCreate = () => { + setEditingId(null); + setActiveTemplate(null); + setForm({ key: "", name: "", subject: "", html: "", text: "" }); + setEditorOpen(true); + }; + + const openEdit = async (t) => { + try { + const full = await fetchTemplateDetails(t.id); + setEditingId(t.id); + setActiveTemplate(full); + setForm({ + key: full?.key || "", + name: full?.name || "", + subject: full?.subject || "", + html: full?.html || "", + text: full?.text || "", + }); + setEditorOpen(true); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to load template"); + } + }; + + const openPreview = async (t) => { + try { + const full = await fetchTemplateDetails(t.id); + setActiveTemplate(full); + setPreviewOpen(true); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to load template"); + } + }; + + const closeEditor = () => { + setEditorOpen(false); + setSaving(false); + }; + + const closePreview = () => { + setPreviewOpen(false); + }; + + const handleSave = async () => { + if (!form.name.trim()) return toast.error("Template name is required"); + if (!form.subject.trim()) return toast.error("Subject is required"); + const hasBody = (form.html && form.html.trim()) || (form.text && form.text.trim()); + if (!hasBody) return toast.error("Provide at least one of html or text"); + + setSaving(true); + try { + if (editingId) { + const payload = { + name: form.name, + subject: form.subject, + html: form.html, + text: form.text, + }; + const nextKey = String(form.key ?? "").trim(); + const prevKey = String(activeTemplate?.key ?? "").trim(); + if (nextKey && nextKey !== prevKey) { + payload.key = nextKey; + } + + await api.patch(`/api/projects/${projectId}/mail/templates/${editingId}`, payload); + toast.success("Template updated"); + } else { + await api.post(`/api/projects/${projectId}/mail/templates`, { + key: form.key, + name: form.name, + subject: form.subject, + html: form.html, + text: form.text, + }); + toast.success("Template created"); + } + + closeEditor(); + await fetchTemplates(); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to save template"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const templateId = confirmDeleteId; + if (!templateId) return; + try { + await api.delete(`/api/projects/${projectId}/mail/templates/${templateId}`); + toast.success("Template deleted"); + setConfirmDeleteId(null); + await fetchTemplates(); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to delete template"); + } + }; + + const formatDate = (iso) => { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString(); + } catch { + return String(iso); + } + }; + + const { variablesError, preview } = useMemo(() => { + const escapeHtml = (value) => { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + const getVarByPath = (vars, path) => { + if (!vars || typeof vars !== "object") return ""; + const parts = String(path || "") + .split(".") + .map((p) => p.trim()) + .filter(Boolean); + + let cur = vars; + for (const p of parts) { + if (cur && typeof cur === "object" && p in cur) { + cur = cur[p]; + } else { + return ""; + } + } + return cur ?? ""; + }; + + const renderTemplateString = (template, vars, { mode }) => { + if (typeof template !== "string" || !template) return template; + const isHtml = mode === "html"; + + let out = template.replace(/\{\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}\}/g, (_, key) => { + const v = getVarByPath(vars, key); + return String(v ?? ""); + }); + + out = out.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, key) => { + const v = getVarByPath(vars, key); + const s = String(v ?? ""); + return isHtml ? escapeHtml(s) : s; + }); + + return out; + }; + + let vars = {}; + let nextVariablesError = null; + + try { + vars = variablesText && variablesText.trim() ? JSON.parse(variablesText) : {}; + if (vars === null || typeof vars !== "object" || Array.isArray(vars)) { + nextVariablesError = "Variables must be a JSON object"; + vars = {}; + } + } catch { + nextVariablesError = "Invalid JSON"; + vars = {}; + } + + const nextPreview = activeTemplate + ? { + subject: renderTemplateString(activeTemplate.subject || "", vars, { mode: "text" }), + html: renderTemplateString(activeTemplate.html || "", vars, { mode: "html" }), + text: renderTemplateString(activeTemplate.text || "", vars, { mode: "text" }), + } + : { subject: "", html: "", text: "" }; + + return { variablesError: nextVariablesError, preview: nextPreview }; + }, [variablesText, activeTemplate]); + + return ( + <> + {confirmDeleteId && ( + setConfirmDeleteId(null)} + /> + )} + + +

+ Create reusable subjects/bodies and preview how they render with variables. Use {'{{name}}'} (escaped) or {'{{{name}}}'} (raw) inside HTML. +

+ + {loading ? ( +
+ ) : ( + <> + {/* Global templates (read-only) */} +
+
+
Global templates
+
Use templateName = key
+
+ + {globalTemplates.length === 0 ? ( +
+ No global templates available. +
+ ) : ( +
+ {globalTemplates.map((t) => ( +
+
+
+ {t.name} + {t.key && ( + {t.key} + )} +
+
{formatDate(t.updatedAt)}
+
+
+ {t.subject} +
+
+ +
+
+ ))} +
+ )} +
+ +
+ + {/* Project templates (editable) */} +
+
Project templates
+ {!isViewer && ( + + )} +
+ + {templates.length === 0 ? ( +
+ No project templates yet. +
+ ) : ( +
+ {templates.map((t) => ( +
+
+
+ {t.name} + {t.key && ( + {t.key} + )} +
+
{formatDate(t.updatedAt)}
+
+
+ {t.subject} +
+
+ + {!isViewer && ( + <> + + + + )} +
+
+ ))} +
+ )} + + )} + + + {/* Editor Modal */} + {editorOpen && ( +
+
e.stopPropagation()} + > + + +
+

{editingId ? 'Edit Template' : 'New Template'}

+

+ Use variables like {'{{user.name}}'}. In HTML, {'{{{rawHtml}}}'} inserts raw. +

+
+ +
+ Use this in API/SDK as templateName. If blank, it’s auto-generated.}> + { + const nextKey = e.target.value; + setForm((p) => ({ + ...p, + key: nextKey.trim() === "" ? undefined : nextKey, + })); + }} + style={{ ...inputStyle, fontFamily: 'monospace' }} + /> + + + setForm((p) => ({ ...p, name: e.target.value }))} style={inputStyle} /> + +
+ + setForm((p) => ({ ...p, subject: e.target.value }))} style={inputStyle} /> + +
+
+ +
+ +