diff --git a/AGENTS.md b/AGENTS.md
index 125f0b7ec..0620a18f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,8 @@ Main apps:
- `apps/dashboard-api`: admin/project management API for the dashboard
- `apps/public-api`: public project API for data, auth, storage
- `apps/web-dashboard`: React/Vite dashboard
+- `apps/consumer`: Consumer Worker for background jobs and webhooks
+- `apps/python-service`: Python AI Microservice (FastAPI/Groq)
- `packages/common`: shared models, validation, middleware, encryption, DB/model utilities
SDKs:
@@ -18,6 +20,13 @@ SDKs:
Workspace scripts are defined in [package.json](/package.json).
+## Production Deployment Endpoints & Domains
+
+- **Frontend Landing**: `https://urbackend.bitbros.in`
+- **Dashboard App**: `https://app.urbackend.bitbros.in`
+- **Dashboard API (Internal/Admin)**: `https://api.urbackend.bitbros.in`
+- **Public API (SDK & User Auth)**: `https://api.ub.bitbros.in`
+
## Important project rules
1. Do not treat `users` like a normal collection.
@@ -151,6 +160,12 @@ cd apps/web-dashboard
npm run build
```
+Run python service tests:
+```bash
+cd apps/python-service
+pytest
+```
+
Run SDK tests:
```bash
# JS Core SDK
@@ -169,6 +184,7 @@ pytest
Before shipping auth, RLS, or schema changes:
- run `apps/public-api` tests
- run `apps/dashboard-api` tests
+- run `apps/python-service` tests
- run `@urbackend/sdk` tests
- run `@urbackend/react` tests
- run `apps/web-dashboard` lint
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 (
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
new file mode 100644
index 000000000..3b5508b3b
--- /dev/null
+++ b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx
@@ -0,0 +1,467 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Database, Shield, Mail, HardDrive, Check, Copy, Server
+} from 'lucide-react';
+import api from '../../utils/api';
+import toast from 'react-hot-toast';
+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 (
+
+ {accentColor && (
+
+ )}
+
+ {title && (
+
+
+ {Icon && }
+
{title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ )}
+ {children}
+
+
+ );
+}
+
+export default function IntegrationsSettings({
+ project,
+ projectId,
+ onProjectUpdate,
+ role,
+ hasResendKey,
+ resendKeyValue,
+ setResendKeyValue,
+ resendFromEmailValue,
+ setResendFromEmailValue,
+ resendKeyLoading,
+ handleResendKeySave,
+}) {
+ const isViewer = role === 'viewer';
+
+ // 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 = 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 (
+
+
+ {/* 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/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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Preview Modal */}
+ {previewOpen && activeTemplate && (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+ Preview: {activeTemplate.name}
+
+
+ Updated: {formatDate(activeTemplate.updatedAt)}
+
+
+
+
+
+
+
+
+
+ {!preview.html?.trim() && (
+
+ No HTML found for this template. (Text templates won’t render in the preview iframe.)
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx
new file mode 100644
index 000000000..35414ba4d
--- /dev/null
+++ b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx
@@ -0,0 +1,184 @@
+import React, { useState, useEffect } from "react";
+import api from "../../utils/api";
+import toast from "react-hot-toast";
+import { HardDrive, CheckCircle } from "lucide-react";
+import ConfirmationModal from "../../pages/ConfirmationModal";
+import { SettingsCard } from "./formPrimitives";
+import { inputStyle } from "../../utils/styles";
+
+const INITIAL_STORAGE_CONFIG = {
+ storageProvider: "supabase",
+ storageUrl: "", storageKey: "",
+ s3AccessKeyId: "", s3SecretAccessKey: "", s3Region: "", s3Endpoint: "", s3Bucket: "", publicUrlHost: "",
+};
+
+export default function StorageConfigForm({ project, projectId, onProjectUpdate, role }) {
+ const isViewer = role === 'viewer';
+ const [config, setConfig] = useState(INITIAL_STORAGE_CONFIG);
+ const [loading, setLoading] = useState(false);
+ const isConfigured = project?.resources?.storage?.isExternal || false;
+ const [showForm, setShowForm] = useState(!isConfigured);
+ const [showRemoveModal, setShowRemoveModal] = useState(false);
+
+ useEffect(() => {
+ Promise.resolve().then(() => setShowForm(!(project?.resources?.storage?.isExternal || false)));
+ }, [project?.resources?.storage?.isExternal]);
+
+ useEffect(() => {
+ Promise.resolve().then(() => {
+ if (config.storageProvider === "supabase") {
+ setConfig(prev => ({ ...prev, s3AccessKeyId: "", s3SecretAccessKey: "", s3Region: "", s3Endpoint: "", s3Bucket: "", publicUrlHost: "" }));
+ } else if (["s3", "cloudflare_r2"].includes(config.storageProvider)) {
+ setConfig(prev => ({ ...prev, storageUrl: "", storageKey: "" }));
+ }
+ });
+ }, [config.storageProvider]);
+
+ const handleChange = (e) => setConfig({ ...config, [e.target.name]: e.target.value });
+
+ const handleUpdate = async () => {
+ if (config.storageProvider === "supabase" && (!config.storageUrl || !config.storageKey)) return toast.error("URL and Key are required");
+ if (config.storageProvider === "s3" && (!config.s3AccessKeyId || !config.s3SecretAccessKey || !config.s3Region || !config.s3Bucket)) return toast.error("S3 keys, region, and bucket are required");
+ if (config.storageProvider === "cloudflare_r2" && (!config.s3AccessKeyId || !config.s3SecretAccessKey || !config.s3Endpoint || !config.s3Bucket || !config.publicUrlHost)) return toast.error("R2 keys, endpoint, bucket, and publicUrlHost are required");
+
+ setLoading(true);
+ try {
+ await api.patch(`/api/projects/${projectId}/byod-config`, config);
+ toast.success("Storage configuration updated!");
+ setShowForm(false);
+ setConfig(INITIAL_STORAGE_CONFIG);
+ } catch (err) {
+ toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to update Storage config");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const executeRemove = async () => {
+ setLoading(true);
+ try {
+ await api.delete(`/api/projects/${projectId}/byod-config/storage`, { data: { projectId } });
+ toast.success("External storage configuration removed!");
+ onProjectUpdate(prev => ({ ...prev, resources: { ...prev.resources, storage: { ...prev.resources.storage, isExternal: false } } }));
+ setConfig(INITIAL_STORAGE_CONFIG);
+ setShowForm(true);
+ } catch (err) {
+ toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to remove Storage config");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {showRemoveModal && (
+ { executeRemove(); setShowRemoveModal(false); }}
+ onCancel={() => setShowRemoveModal(false)}
+ />
+ )}
+ Connect your own Supabase, AWS S3, or Cloudflare R2 storage bucket.
+
+ {isConfigured && !showForm ? (
+
+
+ External storage connected
+
+ {!isViewer && (
+
+
+
+
+ )}
+
+ ) : (
+
+
+
+
+
+
+
+ {config.storageProvider === "supabase" && (
+
+ )}
+
+ {(config.storageProvider === "s3" || config.storageProvider === "cloudflare_r2") && (
+ <>
+
+
+
+
+
+ Custom domain or CDN (e.g. CloudFront, R2 Dev Domain)
+
+ >
+ )}
+
+
+ {!isViewer && (
+
+ {isConfigured && }
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web-dashboard/src/components/Settings/formPrimitives.jsx b/apps/web-dashboard/src/components/Settings/formPrimitives.jsx
new file mode 100644
index 000000000..de96100f4
--- /dev/null
+++ b/apps/web-dashboard/src/components/Settings/formPrimitives.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+
+export function FormField({ label, hint, children }) {
+ return (
+
+ {label && (
+
+ )}
+ {children}
+ {hint && {hint}}
+
+ );
+}
+
+export function SettingsCard({ title, icon: Icon, iconColor, accentColor, children, style = {} }) {
+ return (
+
+ {accentColor && (
+
+ )}
+
+ {title && (
+
+ {Icon && }
+
{title}
+
+ )}
+ {children}
+
+
+ );
+}
diff --git a/apps/web-dashboard/src/config.js b/apps/web-dashboard/src/config.js
index e62260f6d..ab2221179 100644
--- a/apps/web-dashboard/src/config.js
+++ b/apps/web-dashboard/src/config.js
@@ -1,4 +1,4 @@
// frontend/src/config.js
-export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:1234';
-export const PUBLIC_API_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:1235';
+export const API_URL = import.meta.env.VITE_API_URL || 'https://api.urbackend.bitbros.in';
+export const PUBLIC_API_URL = import.meta.env.VITE_PUBLIC_API_URL || 'https://api.ub.bitbros.in';
export const ADMIN_EMAIL = import.meta.env.VITE_ADMIN_EMAIL || '';
\ No newline at end of file
diff --git a/apps/web-dashboard/src/pages/ProjectSettings.jsx b/apps/web-dashboard/src/pages/ProjectSettings.jsx
index 7b6f25162..3086d9e8f 100644
--- a/apps/web-dashboard/src/pages/ProjectSettings.jsx
+++ b/apps/web-dashboard/src/pages/ProjectSettings.jsx
@@ -1,66 +1,26 @@
-import { useState, useEffect, useMemo } from "react";
-import { useParams, useNavigate } from "react-router-dom";
+import { useState, useEffect } from "react";
+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
+ Trash2, AlertTriangle, Settings, Database, Sliders
} from "lucide-react";
-import { API_URL } from "../config";
import ConfirmationModal from "./ConfirmationModal";
import SectionHeader from "../components/Dashboard/SectionHeader";
-
-/* ─── Reusable compact form-field wrapper ─── */
-function FormField({ label, hint, children }) {
- return (
-
- {label && (
-
- )}
- {children}
- {hint && {hint}}
-
- );
-}
-
-/* ─── Compact input style ─── */
-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',
-};
-
-/* ─── Section card wrapper ─── */
-function SettingsCard({ title, icon: Icon, iconColor, accentColor, children, style = {} }) {
- return (
-
- {accentColor && (
-
- )}
-
- {title && (
-
- {Icon && }
-
{title}
-
- )}
- {children}
-
-
- );
-}
+import IntegrationsSettings from "../components/Settings/IntegrationsSettings";
+import AllowedDomainsForm from "../components/Settings/AllowedDomainsForm";
+import { FormField, SettingsCard } from "../components/Settings/formPrimitives";
+import { inputStyle } from "../utils/styles";
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 +35,21 @@ 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((prev) => {
+ const next = new URLSearchParams(prev);
+ next.set('tab', tab);
+ return next;
+ }, { replace: true });
+ };
+
useEffect(() => {
const fetchProject = async () => {
try {
@@ -156,7 +131,6 @@ export default function ProjectSettings() {
if (loading) return (
- {/* Page header Skeleton */}
@@ -165,7 +139,6 @@ export default function ProjectSettings() {
- {/* Sections Skeletons */}
{[1, 2, 3].map(i => (
@@ -190,7 +163,7 @@ export default function ProjectSettings() {
{/* Page header */}
-
+
@@ -202,87 +175,75 @@ export default function ProjectSettings() {
-
-
- {/* General Information */}
-
+ {/* 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 */}
+
- {/* External Configuration */}
-
-
-
+ {/* Allowed Domains (CORS) */}
+
-
- {/* Danger Zone */}
- {isOwner && (
-
-
-
-
-
- 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 && (
+
+
+
+
+
+ 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 && (
);
}
-
-/* ─────────────────────────────────────────────────────────────── */
-/* MailTemplatesForm */
-/* ─────────────────────────────────────────────────────────────── */
-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); // full template
- 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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Preview Modal */}
- {previewOpen && activeTemplate && (
-
-
e.stopPropagation()}
- >
-
-
-
-
- Preview: {activeTemplate.name}
-
-
- Updated: {formatDate(activeTemplate.updatedAt)}
-
-
-
-
-
-
-
-
-
- {!preview.html?.trim() && (
-
- No HTML found for this template. (Text templates won’t render in the preview iframe.)
-
- )}
-
-
-
-
-
-
-
-
- )}
- >
- );
-}
-
-/* ─────────────────────────────────────────────────────────────── */
-/* DatabaseConfigForm */
-/* ─────────────────────────────────────────────────────────────── */
-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 = () => {
- if (serverIp) { navigator.clipboard.writeText(serverIp); toast.success("Server IP copied!"); }
- };
-
- 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 && }
-
-
- )}
-
-
- )}
-
- );
-}
-
-/* ─────────────────────────────────────────────────────────────── */
-/* StorageConfigForm */
-/* ─────────────────────────────────────────────────────────────── */
-const INITIAL_STORAGE_CONFIG = {
- storageProvider: "supabase",
- storageUrl: "", storageKey: "",
- s3AccessKeyId: "", s3SecretAccessKey: "", s3Region: "", s3Endpoint: "", s3Bucket: "", publicUrlHost: "",
-};
-
-function StorageConfigForm({ project, projectId, onProjectUpdate, role }) {
- const isViewer = role === 'viewer';
- const [config, setConfig] = useState(INITIAL_STORAGE_CONFIG);
- const [loading, setLoading] = useState(false);
- const isConfigured = project?.resources?.storage?.isExternal || false;
- const [showForm, setShowForm] = useState(!isConfigured);
- const [showRemoveModal, setShowRemoveModal] = useState(false);
-
- useEffect(() => {
- Promise.resolve().then(() => setShowForm(!(project?.resources?.storage?.isExternal || false)));
- }, [project?.resources?.storage?.isExternal]);
-
- useEffect(() => {
- Promise.resolve().then(() => {
- if (config.storageProvider === "supabase") {
- setConfig(prev => ({ ...prev, s3AccessKeyId: "", s3SecretAccessKey: "", s3Region: "", s3Endpoint: "", s3Bucket: "", publicUrlHost: "" }));
- } else if (["s3", "cloudflare_r2"].includes(config.storageProvider)) {
- setConfig(prev => ({ ...prev, storageUrl: "", storageKey: "" }));
- }
- });
- }, [config.storageProvider]);
-
- const handleChange = (e) => setConfig({ ...config, [e.target.name]: e.target.value });
-
- const handleUpdate = async () => {
- if (config.storageProvider === "supabase" && (!config.storageUrl || !config.storageKey)) return toast.error("URL and Key are required");
- if (config.storageProvider === "s3" && (!config.s3AccessKeyId || !config.s3SecretAccessKey || !config.s3Region || !config.s3Bucket)) return toast.error("S3 keys, region, and bucket are required");
- if (config.storageProvider === "cloudflare_r2" && (!config.s3AccessKeyId || !config.s3SecretAccessKey || !config.s3Endpoint || !config.s3Bucket || !config.publicUrlHost)) return toast.error("R2 keys, endpoint, bucket, and publicUrlHost are required");
-
- setLoading(true);
- try {
- await api.patch(`/api/projects/${projectId}/byod-config`, config);
- toast.success("Storage configuration updated!");
- setShowForm(false);
- setConfig(INITIAL_STORAGE_CONFIG);
- } catch (err) {
- toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to update Storage config");
- } finally {
- setLoading(false);
- }
- };
-
- const executeRemove = async () => {
- setLoading(true);
- try {
- await api.delete(`/api/projects/${projectId}/byod-config/storage`, { data: { projectId } });
- toast.success("External storage configuration removed!");
- onProjectUpdate(prev => ({ ...prev, resources: { ...prev.resources, storage: { ...prev.resources.storage, isExternal: false } } }));
- setConfig(INITIAL_STORAGE_CONFIG);
- setShowForm(true);
- } catch (err) {
- toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to remove Storage config");
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
- {showRemoveModal && (
- { executeRemove(); setShowRemoveModal(false); }}
- onCancel={() => setShowRemoveModal(false)}
- />
- )}
- Connect your own Supabase, AWS S3, or Cloudflare R2 storage bucket.
-
- {isConfigured && !showForm ? (
-
-
- External storage connected
-
- {!isViewer && (
-
-
-
-
- )}
-
- ) : (
-
-
-
-
-
-
-
- {config.storageProvider === "supabase" && (
-
- )}
-
- {(config.storageProvider === "s3" || config.storageProvider === "cloudflare_r2") && (
- <>
-
-
-
-
-
- Custom domain or CDN (e.g. CloudFront, R2 Dev Domain)
-
- >
- )}
-
-
- {!isViewer && (
-
- {isConfigured && }
-
-
- )}
-
- )}
-
- );
-}
-
-/* ─────────────────────────────────────────────────────────────── */
-/* AllowedDomainsForm */
-/* ─────────────────────────────────────────────────────────────── */
-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/utils/styles.js b/apps/web-dashboard/src/utils/styles.js
new file mode 100644
index 000000000..ea8b19f52
--- /dev/null
+++ b/apps/web-dashboard/src/utils/styles.js
@@ -0,0 +1,10 @@
+/* Shared input style object */
+export 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',
+};