Skip to content

Commit 530edbb

Browse files
Merge pull request #365 from geturbackend/feature/settings-integrations-page
feat: refactor settings page into tabbed layout with dedicated integr…
2 parents e506280 + d0581aa commit 530edbb

11 files changed

Lines changed: 1599 additions & 1066 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Main apps:
88
- `apps/dashboard-api`: admin/project management API for the dashboard
99
- `apps/public-api`: public project API for data, auth, storage
1010
- `apps/web-dashboard`: React/Vite dashboard
11+
- `apps/consumer`: Consumer Worker for background jobs and webhooks
12+
- `apps/python-service`: Python AI Microservice (FastAPI/Groq)
1113
- `packages/common`: shared models, validation, middleware, encryption, DB/model utilities
1214

1315
SDKs:
@@ -18,6 +20,13 @@ SDKs:
1820

1921
Workspace scripts are defined in [package.json](/package.json).
2022

23+
## Production Deployment Endpoints & Domains
24+
25+
- **Frontend Landing**: `https://urbackend.bitbros.in`
26+
- **Dashboard App**: `https://app.urbackend.bitbros.in`
27+
- **Dashboard API (Internal/Admin)**: `https://api.urbackend.bitbros.in`
28+
- **Public API (SDK & User Auth)**: `https://api.ub.bitbros.in`
29+
2130
## Important project rules
2231

2332
1. Do not treat `users` like a normal collection.
@@ -151,6 +160,12 @@ cd apps/web-dashboard
151160
npm run build
152161
```
153162

163+
Run python service tests:
164+
```bash
165+
cd apps/python-service
166+
pytest
167+
```
168+
154169
Run SDK tests:
155170
```bash
156171
# JS Core SDK
@@ -169,6 +184,7 @@ pytest
169184
Before shipping auth, RLS, or schema changes:
170185
- run `apps/public-api` tests
171186
- run `apps/dashboard-api` tests
187+
- run `apps/python-service` tests
172188
- run `@urbackend/sdk` tests
173189
- run `@urbackend/react` tests
174190
- run `apps/web-dashboard` lint

apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import React from 'react';
2-
import { Settings } from 'lucide-react';
2+
import { Settings, ExternalLink } from 'lucide-react';
3+
import { useNavigate, useParams } from 'react-router-dom';
34

45
const SocialAuthConfig = ({ authProviders, onOpenModal, setSelectedProvider }) => {
6+
const navigate = useNavigate();
7+
const { projectId } = useParams();
8+
59
return (
610
<div className="glass-card" style={{ padding: '1.25rem', borderRadius: '12px', marginBottom: '2rem' }}>
711
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '1.25rem' }}>
@@ -13,9 +17,19 @@ const SocialAuthConfig = ({ authProviders, onOpenModal, setSelectedProvider }) =
1317
Configure third-party login providers for your application.
1418
</p>
1519
</div>
16-
<button className="btn btn-primary" onClick={onOpenModal} style={{ height: '32px', fontSize: '0.75rem' }}>
17-
Open Settings
18-
</button>
20+
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
21+
<button
22+
className="btn btn-secondary"
23+
onClick={() => navigate(`/project/${projectId}/settings?tab=integrations`)}
24+
style={{ height: '32px', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: '5px' }}
25+
>
26+
<ExternalLink size={13} />
27+
Integrations
28+
</button>
29+
<button className="btn btn-primary" onClick={onOpenModal} style={{ height: '32px', fontSize: '0.75rem' }}>
30+
Open Settings
31+
</button>
32+
</div>
1933
</div>
2034

2135
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '0.75rem' }}>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React, { useState, useEffect } from "react";
2+
import api from "../../utils/api";
3+
import toast from "react-hot-toast";
4+
import { Globe, Plus, AlertTriangle, X } from "lucide-react";
5+
import { SettingsCard } from "./formPrimitives";
6+
import { inputStyle } from "../../utils/styles";
7+
8+
export default function AllowedDomainsForm({ project, projectId, onProjectUpdate, role }) {
9+
const isViewer = role === 'viewer';
10+
const [domains, setDomains] = useState(project?.allowedDomains || []);
11+
const [newDomain, setNewDomain] = useState("");
12+
const [loading, setLoading] = useState(false);
13+
14+
useEffect(() => {
15+
if (project?.allowedDomains) {
16+
Promise.resolve().then(() => setDomains(project.allowedDomains));
17+
}
18+
}, [project?.allowedDomains]);
19+
20+
const handleUpdate = async (updatedDomains) => {
21+
setLoading(true);
22+
try {
23+
await api.patch(`/api/projects/${projectId}/allowed-domains`, { domains: updatedDomains });
24+
toast.success("Allowed domains updated!");
25+
setDomains(updatedDomains);
26+
onProjectUpdate((prev) => ({ ...prev, allowedDomains: updatedDomains }));
27+
} catch (err) {
28+
toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to update allowed domains");
29+
} finally {
30+
setLoading(false);
31+
}
32+
};
33+
34+
const addDomain = () => {
35+
let domain = newDomain.trim();
36+
if (!domain) return;
37+
if (domain !== "*" && domain.endsWith("/")) domain = domain.slice(0, -1);
38+
if (domains.includes(domain)) return toast.error("Domain already added");
39+
const updated = domain === "*" ? ["*"] : [...domains.filter(d => d !== "*"), domain];
40+
handleUpdate(updated);
41+
setNewDomain("");
42+
};
43+
44+
const removeDomain = (d) => handleUpdate(domains.filter(x => x !== d));
45+
46+
return (
47+
<SettingsCard title="Allowed Domains (CORS)" icon={Globe} iconColor="#6366f1" accentColor="#6366f1">
48+
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
49+
Restrict which websites can send requests using your <strong>Publishable API Key</strong>.{' '}
50+
Use <code>*</code> to allow all, or specify like <code>https://example.com</code>.
51+
</p>
52+
53+
{!isViewer && (
54+
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
55+
<input
56+
type="text"
57+
className="input-field"
58+
placeholder="https://mywebsite.com or *.mywebsite.com"
59+
value={newDomain}
60+
onChange={(e) => setNewDomain(e.target.value)}
61+
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addDomain(); } }}
62+
style={{ ...inputStyle, flex: 1 }}
63+
/>
64+
<button
65+
onClick={addDomain}
66+
className="btn btn-secondary"
67+
disabled={loading || !newDomain.trim()}
68+
style={{ height: '30px', fontSize: '0.75rem', padding: '0 12px', gap: '4px', flexShrink: 0 }}
69+
>
70+
<Plus size={12} /> Add
71+
</button>
72+
</div>
73+
)}
74+
75+
{domains.length === 0 ? (
76+
<div style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.72rem', background: 'rgba(255,255,255,0.02)', borderRadius: '6px', border: '1px dashed var(--color-border)' }}>
77+
No domains configured — your publishable key won't work on the web. Add <code>*</code> to allow all.
78+
</div>
79+
) : (
80+
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
81+
{domains.map((domain) => (
82+
<div key={domain} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px' }}>
83+
<div style={{ fontSize: '0.75rem' }}>
84+
{domain === "*" ? (
85+
<span style={{ color: '#10b981', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '5px', fontSize: '0.72rem' }}>
86+
<AlertTriangle size={11} color="#10b981" /> ALLOW ALL (*)
87+
</span>
88+
) : (
89+
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem' }}>{domain}</span>
90+
)}
91+
</div>
92+
{!isViewer && (
93+
<button
94+
onClick={() => removeDomain(domain)}
95+
disabled={loading}
96+
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', padding: '2px', display: 'flex', alignItems: 'center', borderRadius: '3px' }}
97+
onMouseOver={(e) => e.currentTarget.style.color = '#ea5455'}
98+
onMouseOut={(e) => e.currentTarget.style.color = 'var(--color-text-muted)'}
99+
>
100+
<X size={13} />
101+
</button>
102+
)}
103+
</div>
104+
))}
105+
</div>
106+
)}
107+
</SettingsCard>
108+
);
109+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import React, { useState, useEffect } from "react";
2+
import api from "../../utils/api";
3+
import toast from "react-hot-toast";
4+
import { Database, CheckCircle, Server, Copy } from "lucide-react";
5+
import ConfirmationModal from "../../pages/ConfirmationModal";
6+
import { SettingsCard } from "./formPrimitives";
7+
import { inputStyle } from "../../utils/styles";
8+
9+
export default function DatabaseConfigForm({ project, projectId, onProjectUpdate, role }) {
10+
const isViewer = role === 'viewer';
11+
const [dbUri, setDbUri] = useState("");
12+
const [loading, setLoading] = useState(false);
13+
const isConfigured = project?.resources?.db?.isExternal || false;
14+
const [showForm, setShowForm] = useState(!isConfigured);
15+
const [showRemoveModal, setShowRemoveModal] = useState(false);
16+
const [serverIp, setServerIp] = useState(null);
17+
18+
useEffect(() => {
19+
let isMounted = true;
20+
Promise.resolve().then(() => setShowForm(!(project?.resources?.db?.isExternal || false)));
21+
const fetchIp = async () => {
22+
try {
23+
const res = await api.get(`/api/server-ip`);
24+
if (isMounted) setServerIp(res.data.ip);
25+
}
26+
catch (e) { console.error("Failed to fetch server IP", e); }
27+
};
28+
fetchIp();
29+
return () => { isMounted = false; };
30+
}, [project]);
31+
32+
const copyIp = async () => {
33+
if (serverIp && navigator?.clipboard) {
34+
try {
35+
await navigator.clipboard.writeText(serverIp);
36+
toast.success("Server IP copied!");
37+
} catch {
38+
toast.error("Failed to copy server IP");
39+
}
40+
}
41+
};
42+
43+
const handleUpdate = async () => {
44+
if (!dbUri) return toast.error("Database URI is required");
45+
setLoading(true);
46+
try {
47+
await api.patch(`/api/projects/${projectId}/byod-config`, { dbUri });
48+
toast.success("Database configuration updated!");
49+
setShowForm(false);
50+
setDbUri("");
51+
} catch (err) {
52+
const errorMsg = err.response?.data?.message || err.response?.data?.error || "Failed to update DB config";
53+
if (errorMsg.includes("whitelist Server IP")) {
54+
toast.error(<div><b>Access Denied!</b><br />{errorMsg}</div>, { duration: 6000 });
55+
} else {
56+
toast.error(errorMsg);
57+
}
58+
} finally {
59+
setLoading(false);
60+
}
61+
};
62+
63+
const executeRemove = async () => {
64+
try {
65+
await api.delete(`/api/projects/${projectId}/byod-config/db`, { data: { projectId } });
66+
toast.success("External database configuration removed!");
67+
onProjectUpdate(prev => ({ ...prev, resources: { ...prev.resources, db: { ...prev.resources.db, isExternal: false } } }));
68+
setShowForm(true);
69+
} catch (err) {
70+
toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to remove DB config");
71+
} finally {
72+
setLoading(false);
73+
}
74+
};
75+
76+
return (
77+
<SettingsCard title="Database (MongoDB)" icon={Database} iconColor="var(--color-primary)" accentColor="var(--color-primary)">
78+
{showRemoveModal && (
79+
<ConfirmationModal
80+
open={showRemoveModal}
81+
title="Remove Database Config"
82+
message="Remove the external database configuration? This will switch back to the internal database."
83+
onConfirm={() => { executeRemove(); setShowRemoveModal(false); }}
84+
onCancel={() => setShowRemoveModal(false)}
85+
/>
86+
)}
87+
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: '0.75rem' }}>Connect your own MongoDB cluster for full data ownership.</p>
88+
89+
{isConfigured && !showForm ? (
90+
<div style={{ background: 'rgba(16,185,129,0.08)', padding: '10px 12px', borderRadius: '6px', border: '1px solid rgba(16,185,129,0.2)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
91+
<div style={{ display: 'flex', alignItems: 'center', gap: '7px', color: '#10B981', fontWeight: 600, fontSize: '0.78rem' }}>
92+
<CheckCircle size={13} /> Connected to external MongoDB
93+
</div>
94+
{!isViewer && (
95+
<div style={{ display: 'flex', gap: '6px' }}>
96+
<button className="btn btn-secondary" onClick={() => setShowForm(true)} style={{ height: '26px', fontSize: '0.7rem', padding: '0 10px' }}>Update URI</button>
97+
<button className="btn btn-danger" onClick={() => setShowRemoveModal(true)} disabled={loading} style={{ height: '26px', fontSize: '0.7rem', padding: '0 10px' }}>
98+
{loading ? "Removing..." : "Remove"}
99+
</button>
100+
</div>
101+
)}
102+
</div>
103+
) : (
104+
<div>
105+
<div className="form-group" style={{ marginBottom: '10px' }}>
106+
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>MongoDB Connection URI</label>
107+
<input
108+
type="password"
109+
className="input-field"
110+
placeholder="mongodb+srv://user:pass@cluster.mongodb.net/..."
111+
value={dbUri}
112+
onChange={(e) => setDbUri(e.target.value)}
113+
style={{ ...inputStyle, fontFamily: 'monospace' }}
114+
disabled={isViewer}
115+
/>
116+
</div>
117+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '10px', borderTop: '1px solid var(--color-border)' }}>
118+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>
119+
<Server size={11} />
120+
<span>Public IP: <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 5px', borderRadius: '3px', fontSize: '0.68rem' }}>{serverIp || "..."}</code></span>
121+
{serverIp && (
122+
<button onClick={copyIp} aria-label="Copy server IP" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--color-primary)', padding: 0 }}>
123+
<Copy size={11} />
124+
</button>
125+
)}
126+
</div>
127+
{!isViewer && (
128+
<div style={{ display: 'flex', gap: '8px' }}>
129+
{isConfigured && <button className="btn btn-secondary" onClick={() => setShowForm(false)} style={{ height: '28px', fontSize: '0.72rem', padding: '0 10px' }}>Cancel</button>}
130+
<button onClick={handleUpdate} className="btn btn-primary" disabled={loading} style={{ height: '28px', fontSize: '0.72rem', padding: '0 12px' }}>
131+
{loading ? "Connecting..." : "Connect Database"}
132+
</button>
133+
</div>
134+
)}
135+
</div>
136+
</div>
137+
)}
138+
</SettingsCard>
139+
);
140+
}

0 commit comments

Comments
 (0)