From 90721f32640d1d60812fcbe89e01976fa9236931 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Mon, 27 Jul 2026 11:57:07 +0530 Subject: [PATCH 1/8] feat: Add clone project template functionality --- .../project.controller.cloneTemplate.test.js | 98 ++++++++++++++++++ .../src/controllers/project.controller.js | 33 ++++++- .../components/Settings/StorageConfigForm.jsx | 29 ++++-- apps/web-dashboard/src/pages/Templates.jsx | 89 ++++++++++++++++- mintlify/docs/docs.json | 2 +- packages/common/src/index.js | 2 + packages/common/src/utils/input.validation.js | 1 + packages/common/src/utils/storage.manager.js | 30 +++--- packages/common/src/utils/templates.js | 99 +++++++++++++++++++ 9 files changed, 359 insertions(+), 24 deletions(-) create mode 100644 apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js create mode 100644 packages/common/src/utils/templates.js diff --git a/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js b/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js new file mode 100644 index 000000000..40e2da538 --- /dev/null +++ b/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js @@ -0,0 +1,98 @@ +process.env.REDIS_URL = "redis://localhost:6379"; +jest.mock('marked', () => ({ + marked: { parse: jest.fn() } +})); +const mongoose = require("mongoose"); +jest.mock("ioredis", () => { + return jest.fn().mockImplementation(() => ({ + on: jest.fn(), + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + })); +}); +const { createProject } = require("../controllers/project.controller"); +const { Project, PROJECT_TEMPLATES } = require("@urbackend/common"); + +jest.mock("@urbackend/common", () => { + const original = jest.requireActual("@urbackend/common"); + return { + ...original, + generateApiKey: jest.fn(() => "test_api_key"), + hashApiKey: jest.fn(() => "hashed_key"), + encrypt: jest.fn(() => "encrypted_key"), + markDeveloperOnboardingStep: jest.fn().mockResolvedValue(), + }; +}); + +jest.mock("../utils/emitEvent", () => ({ + emitEvent: jest.fn(), +})); + +describe("Project Controller - Clone Template", () => { + let req, res; + + beforeEach(() => { + req = { + body: {}, + user: { _id: new mongoose.Types.ObjectId(), isVerified: true }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + jest.clearAllMocks(); + }); + + afterEach(async () => { + await Project.deleteMany({}); + }); + + beforeAll(async () => { + await mongoose.connect(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/urbackend_test_clone_template"); + }); + + afterAll(async () => { + await mongoose.connection.close(); + }); + + it("should create a project with template configuration", async () => { + req.body = { + name: "Test Clone Project", + templateId: "sdk-kanban", + }; + + await createProject(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + + const createdProject = await Project.findOne({ name: "Test Clone Project" }); + expect(createdProject).toBeDefined(); + + // Check if auth is enabled + expect(createdProject.isAuthEnabled).toBe(true); + + // Check collections + const collectionNames = createdProject.collections.map(c => c.name); + expect(collectionNames).toContain("boards"); + expect(collectionNames).toContain("tasks"); + expect(collectionNames).toContain("users"); // auto added for auth + + const boardsCol = createdProject.collections.find(c => c.name === "boards"); + expect(boardsCol.rls).toBe("private"); + }); + + it("should ignore invalid templateId and create a normal project", async () => { + req.body = { + name: "Normal Project", + templateId: "invalid-template", + }; + + await createProject(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + const createdProject = await Project.findOne({ name: "Normal Project" }); + expect(createdProject.collections).toHaveLength(0); + expect(createdProject.isAuthEnabled).toBe(false); + }); +}); diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 173239b4f..1910abc00 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -40,7 +40,7 @@ const { verifyUploadedFile } = require("@urbackend/common"); const { getPublicIp } = require("@urbackend/common"); const { clearCompiledModel } = require("@urbackend/common"); const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common"); -const { getProjectAccessQuery, getProjectRole, Invitation } = require("@urbackend/common"); +const { getProjectAccessQuery, getProjectRole, Invitation, PROJECT_TEMPLATES } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); const MAX_FILE_SIZE = 10 * 1024 * 1024; const SAFETY_MAX_BYTES = 100 * 1024 * 1024; @@ -268,7 +268,7 @@ const bestEffortDeleteUploadedObject = async (project, filePath) => { module.exports.createProject = async (req, res) => { const executeOperation = async (session) => { - const { name, description, siteUrl } = createProjectSchema.parse(req.body); + const { name, description, siteUrl, templateId } = createProjectSchema.parse(req.body); if (req.projectLimit !== undefined) { @@ -295,7 +295,7 @@ module.exports.createProject = async (req, res) => { const newProject = new Project({ name, - description, + description: description || "", owner: req.user._id, publishableKey: rawPublishableKey, secretKey: hashedSecretKey, @@ -304,6 +304,33 @@ module.exports.createProject = async (req, res) => { jwtSecret: rawJwtSecret, siteUrl: siteUrl || "", }); + + if (templateId && PROJECT_TEMPLATES[templateId]) { + const template = PROJECT_TEMPLATES[templateId]; + if (template.isAuthEnabled) { + newProject.isAuthEnabled = true; + // The default users collection is appended later or explicitly, let's see. + // Wait, does 'users' collection get automatically added if isAuthEnabled is set? + // Let's add it explicitly if not present. + } + if (template.collections && template.collections.length > 0) { + newProject.collections = template.collections.map(col => ({ + name: col.name, + model: col.model, + rls: col.rls + })); + } + if (newProject.isAuthEnabled && !newProject.collections.some(c => c.name === 'users')) { + newProject.collections.push({ + name: 'users', + rls: 'private', + model: [ + { key: 'email', type: 'String', required: true, unique: true }, + { key: 'password', type: 'String', required: true } + ] + }); + } + } const saveOpts = session ? { session } : {}; await newProject.save(saveOpts); diff --git a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx index 35414ba4d..4d424f4cf 100644 --- a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx +++ b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx @@ -28,7 +28,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, Promise.resolve().then(() => { if (config.storageProvider === "supabase") { setConfig(prev => ({ ...prev, s3AccessKeyId: "", s3SecretAccessKey: "", s3Region: "", s3Endpoint: "", s3Bucket: "", publicUrlHost: "" })); - } else if (["s3", "cloudflare_r2"].includes(config.storageProvider)) { + } else if (["s3", "cloudflare_r2", "gcs"].includes(config.storageProvider)) { setConfig(prev => ({ ...prev, storageUrl: "", storageKey: "" })); } }); @@ -40,6 +40,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, 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"); + if (config.storageProvider === "gcs" && (!config.s3AccessKeyId || !config.s3SecretAccessKey || !config.s3Bucket)) return toast.error("GCS HMAC keys and bucket are required"); setLoading(true); try { @@ -80,7 +81,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, onCancel={() => setShowRemoveModal(false)} /> )} -

Connect your own Supabase, AWS S3, or Cloudflare R2 storage bucket.

+

Connect your own Supabase, AWS S3, Cloudflare R2, or Google Cloud Storage (GCS) bucket.

{isConfigured && !showForm ? (
@@ -111,6 +112,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, +
@@ -128,7 +130,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, )} - {(config.storageProvider === "s3" || config.storageProvider === "cloudflare_r2") && ( + {["s3", "cloudflare_r2", "gcs"].includes(config.storageProvider) && ( <>
@@ -140,6 +142,11 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate,
+ ) : config.storageProvider === "gcs" ? ( +
+ + +
) : (
@@ -149,11 +156,15 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate,
- - + +
- +
@@ -162,8 +173,10 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, Public URL Host / CDN Domain{' '} {config.storageProvider === "cloudflare_r2" ? "(Required)" : "(Optional)"} - - Custom domain or CDN (e.g. CloudFront, R2 Dev Domain) + + + {config.storageProvider === "gcs" ? "Custom domain or default GCS public host" : "Custom domain or CDN (e.g. CloudFront, R2 Dev Domain)"} +
)} diff --git a/apps/web-dashboard/src/pages/Templates.jsx b/apps/web-dashboard/src/pages/Templates.jsx index 965d7b3d4..4b340feb9 100644 --- a/apps/web-dashboard/src/pages/Templates.jsx +++ b/apps/web-dashboard/src/pages/Templates.jsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Copy, ExternalLink, CheckCircle, ArrowLeft, Github, Code, Rocket, FileJson, Server, Smartphone, LayoutGrid } from 'lucide-react'; +import { Copy, ExternalLink, CheckCircle, ArrowLeft, Github, Code, Rocket, FileJson, Server, Smartphone, LayoutGrid, CopyPlus, Loader2 } from 'lucide-react'; import toast from 'react-hot-toast'; +import api from '../utils/api'; const TEMPLATES = [ { @@ -152,6 +153,11 @@ function Templates() { const [selectedSdk, setSelectedSdk] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [cloneTemplate, setCloneTemplate] = useState(null); + const [projectName, setProjectName] = useState(''); + const [projectDescription, setProjectDescription] = useState(''); + const [isCloning, setIsCloning] = useState(false); + const sdks = ['all', ...new Set(TEMPLATES.map(t => t.sdk))]; const filteredTemplates = TEMPLATES.filter(t => { @@ -172,6 +178,29 @@ function Templates() { .catch(() => toast.error('Failed to copy command')); }; + const handleCloneProject = async (e) => { + e.preventDefault(); + if (!projectName.trim() || !cloneTemplate) return; + + setIsCloning(true); + try { + const res = await api.post(`/api/projects`, { + name: projectName, + description: projectDescription, + templateId: cloneTemplate.id, + }); + toast.success('Project cloned successfully!'); + navigate(`/project/${res.data._id}`); + } catch (error) { + toast.error(error.response?.data?.error || 'Failed to clone project'); + } finally { + setIsCloning(false); + setCloneTemplate(null); + setProjectName(''); + setProjectDescription(''); + } + }; + return (
{template.deployUrl && (
+ + {/* Clone Modal */} + {cloneTemplate && ( +
+
+

+ Clone {cloneTemplate.name} +

+
+
+ + setProjectName(e.target.value)} + className="input-field" + style={{ width: '100%', padding: '10px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-input)', color: 'var(--color-text-main)' }} + placeholder="My Awesome App" + required + autoFocus + /> +
+
+ + setProjectDescription(e.target.value)} + className="input-field" + style={{ width: '100%', padding: '10px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-input)', color: 'var(--color-text-main)' }} + placeholder="A short description..." + /> +
+
+ + +
+
+
+
+ )} ); } diff --git a/mintlify/docs/docs.json b/mintlify/docs/docs.json index 886791480..93b595299 100644 --- a/mintlify/docs/docs.json +++ b/mintlify/docs/docs.json @@ -15,7 +15,7 @@ "favicon": "https://media.brand.dev/98c8a636-fed2-4095-a2d6-1ed768aca5dd.png", "seo": { "metatags": { - "google-site-verification": "o5KNQ9R-ummCVpBGTiPWtBLvDPdVm-uxMuIQXmj3mlU" + "google-site-verification": "7EMPCEhVMV-pX3iYSiRSjNI6d5aLL2csbeDQySbOMV4" } }, "navbar": { diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 443d39420..4e879c09a 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -131,6 +131,7 @@ const { } = require("./utils/onboarding"); const { getProjectAccessQuery, getProjectRole } = require("./utils/projectAccess"); const { generatePAT, hashToken, encodeBase62 } = require("./utils/token.utils"); +const { PROJECT_TEMPLATES } = require("./utils/templates"); module.exports = { connectDB, @@ -254,4 +255,5 @@ module.exports = { generatePAT, hashToken, encodeBase62, + PROJECT_TEMPLATES, }; diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index cdcf073ed..7ccca40d8 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -102,6 +102,7 @@ module.exports.updateOnboardingSchema = z module.exports.createProjectSchema = z.object({ name: z.string().min(1, "Project name is required"), description: z.string().optional(), + templateId: z.string().optional(), siteUrl: z.preprocess( (val) => (val === "" || val === null ? undefined : val), z diff --git a/packages/common/src/utils/storage.manager.js b/packages/common/src/utils/storage.manager.js index 3166b8da0..70009e048 100644 --- a/packages/common/src/utils/storage.manager.js +++ b/packages/common/src/utils/storage.manager.js @@ -97,6 +97,9 @@ function createS3Adapter(config) { } }; } + if (config.storageProvider === 'gcs') { + return { data: { publicUrl: `https://storage.googleapis.com/${activeBucket}/${path}` } }; + } return { data: { publicUrl: `https://${activeBucket}.s3.${config.region}.amazonaws.com/${path}` } }; } }; @@ -136,9 +139,12 @@ async function getStorage(project) { } client = createClient(config.storageUrl, config.storageKey); } - else if (provider === 's3' || provider === 'cloudflare_r2') { + else if (provider === 's3' || provider === 'cloudflare_r2' || provider === 'gcs') { if (!config.accessKeyId || !config.secretAccessKey || !config.bucket) { - throw new Error("Incomplete S3/R2 storage config"); + throw new Error("Incomplete S3/R2/GCS storage config"); + } + if (provider === 'gcs' && !config.endpoint) { + config.endpoint = 'https://storage.googleapis.com'; } client = createS3Adapter(config); } else { @@ -189,10 +195,11 @@ async function getPresignedUploadUrl(project, filePath, contentType, size) { return { signedUrl: data.signedUrl, token: data.token }; } - // S3 or Cloudflare R2 + // S3, Cloudflare R2, or Google Cloud Storage + const endpoint = config.endpoint || (provider === "gcs" ? "https://storage.googleapis.com" : undefined); const s3Client = new S3Client({ region: config.region || "auto", - endpoint: config.endpoint, + endpoint, forcePathStyle: true, credentials: { accessKeyId: config.accessKeyId, @@ -249,10 +256,11 @@ async function verifyUploadedFile(project, filePath) { return actualSize; } - // S3 / R2 — just ask "does this object exist and what's its size?" + // S3 / R2 / GCS — just ask "does this object exist and what's its size?" + const endpoint = config.endpoint || (provider === "gcs" ? "https://storage.googleapis.com" : undefined); const s3Client = new S3Client({ region: config.region || "auto", - endpoint: config.endpoint, + endpoint, forcePathStyle: true, credentials: { accessKeyId: config.accessKeyId, @@ -343,11 +351,11 @@ async function getS3CompatibleStorage(project) { }; } - // AWS S3 / CLOUDFLARE R2 - if (provider === "s3" || provider === "cloudflare_r2") { - + // AWS S3 / CLOUDFLARE R2 / GCS + if (provider === "s3" || provider === "cloudflare_r2" || provider === "gcs") { + const targetEndpoint = config.endpoint || (provider === "gcs" ? "https://storage.googleapis.com" : undefined); if ( - !config.endpoint || + !targetEndpoint || !config.accessKeyId || !config.secretAccessKey ) { @@ -358,7 +366,7 @@ async function getS3CompatibleStorage(project) { const s3Client = new S3Client({ region: config.region || "auto", - endpoint: config.endpoint, + endpoint: targetEndpoint, forcePathStyle: true, credentials: { accessKeyId: config.accessKeyId, diff --git a/packages/common/src/utils/templates.js b/packages/common/src/utils/templates.js new file mode 100644 index 000000000..59ee68921 --- /dev/null +++ b/packages/common/src/utils/templates.js @@ -0,0 +1,99 @@ +module.exports.PROJECT_TEMPLATES = { + 'sdk-kanban': { + name: 'Kanban Board', + description: 'Full-featured Kanban board with drag-and-drop tasks.', + isAuthEnabled: true, + collections: [ + { + name: 'boards', + rls: 'private', + model: [ + { key: 'title', type: 'String', required: true } + ] + }, + { + name: 'tasks', + rls: 'private', + model: [ + { key: 'boardId', type: 'Ref', required: true }, + { key: 'title', type: 'String', required: true }, + { key: 'status', type: 'String', required: true, default: 'todo' } + ] + } + ] + }, + 'social-demo': { + name: 'Social Media (X Clone)', + description: 'A Twitter/X.com clone.', + isAuthEnabled: true, + collections: [ + { + name: 'profiles', + rls: 'public-read', + model: [ + { key: 'handle', type: 'String', required: true, unique: true }, + { key: 'bio', type: 'String' }, + { key: 'avatarUrl', type: 'String' } + ] + }, + { + name: 'posts', + rls: 'public-read', + model: [ + { key: 'content', type: 'String', required: true }, + { key: 'authorId', type: 'Ref', required: true }, + { key: 'imageUrls', type: 'Array' } + ] + }, + { + name: 'comments', + rls: 'public-read', + model: [ + { key: 'postId', type: 'Ref', required: true }, + { key: 'content', type: 'String', required: true }, + { key: 'authorId', type: 'Ref', required: true } + ] + }, + { + name: 'likes', + rls: 'public-read', + model: [ + { key: 'postId', type: 'Ref', required: true }, + { key: 'userId', type: 'Ref', required: true } + ] + }, + { + name: 'follows', + rls: 'public-read', + model: [ + { key: 'followerId', type: 'Ref', required: true }, + { key: 'followingId', type: 'Ref', required: true } + ] + } + ] + }, + 'react-sdk-demo': { + name: 'React SDK Demo', + description: 'A modern React app showcasing auth components.', + isAuthEnabled: true, + collections: [] + }, + 'python-sdk-demo': { + name: 'Python SDK Demo', + description: 'A complete Python CLI demo.', + isAuthEnabled: true, + collections: [] + }, + 'quickstart-ts': { + name: 'TypeScript Quickstart', + description: 'Scaffold a new TypeScript project.', + isAuthEnabled: true, + collections: [] + }, + 'quickstart-python': { + name: 'Python Quickstart', + description: 'Scaffold a new Python project.', + isAuthEnabled: true, + collections: [] + } +}; From 0c9d78d5ef0f2d49cb845d748ce57c40ab86d0bf Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Mon, 27 Jul 2026 13:20:37 +0530 Subject: [PATCH 2/8] fix: resolve Mintlify CI docs.json seo error and clean response format --- .../project.controller.cloneTemplate.test.js | 4 +++- .../src/controllers/project.controller.js | 20 ++++++++++--------- .../components/Settings/StorageConfigForm.jsx | 7 ++++++- apps/web-dashboard/src/pages/Templates.jsx | 19 ++++++++++++------ mintlify/docs/docs.json | 1 + packages/common/src/utils/storage.manager.js | 2 +- 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js b/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js index 40e2da538..ecb174b58 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js @@ -1,7 +1,7 @@ process.env.REDIS_URL = "redis://localhost:6379"; jest.mock('marked', () => ({ marked: { parse: jest.fn() } -})); +}), { virtual: true }); const mongoose = require("mongoose"); jest.mock("ioredis", () => { return jest.fn().mockImplementation(() => ({ @@ -29,6 +29,8 @@ jest.mock("../utils/emitEvent", () => ({ emitEvent: jest.fn(), })); +jest.setTimeout(15000); + describe("Project Controller - Clone Template", () => { let req, res; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 1910abc00..7e7034954 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -279,9 +279,7 @@ module.exports.createProject = async (req, res) => { ); if (currentCount >= req.projectLimit) { - const error = new Error(`Project limit reached (${req.projectLimit}). Please upgrade your plan to create more projects.`); - error.status = 403; - throw error; + throw new AppError(`Project limit reached (${req.projectLimit}). Please upgrade your plan to create more projects.`, 403); } } @@ -366,7 +364,7 @@ module.exports.createProject = async (req, res) => { console.error('[onboarding] Failed to mark projectCreated:', err.message); }); emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); - return res.status(201).json(projectObj); + return res.status(201).json({ success: true, data: projectObj, message: "Project created successfully" }); } catch (err) { if (session) { try { await session.abortTransaction(); } catch (e) {} @@ -380,15 +378,19 @@ module.exports.createProject = async (req, res) => { console.error('[onboarding] Failed to mark projectCreated:', err.message); }); emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); - return res.status(201).json(projectObj); + return res.status(201).json({ success: true, data: projectObj, message: "Project created successfully" }); } catch (retryErr) { - if (retryErr instanceof z.ZodError) return res.status(400).json({ error: retryErr.issues }); - return res.status(retryErr.status || 500).json({ error: retryErr.message }); + if (retryErr instanceof z.ZodError) return res.status(400).json({ success: false, message: "Validation failed", error: retryErr.issues }); + const statusCode = retryErr.status || (retryErr instanceof AppError ? retryErr.statusCode : 500); + const message = statusCode === 500 ? "Internal server error" : retryErr.message; + return res.status(statusCode).json({ success: false, message: message }); } } - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues }); - return res.status(err.status || 500).json({ error: err.message }); + if (err instanceof z.ZodError) return res.status(400).json({ success: false, message: "Validation failed", error: err.issues }); + const statusCode = err.status || (err instanceof AppError ? err.statusCode : 500); + const message = statusCode === 500 ? "Internal server error" : err.message; + return res.status(statusCode).json({ success: false, message: message }); } }; diff --git a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx index 4d424f4cf..b231a95ab 100644 --- a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx +++ b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx @@ -29,7 +29,12 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, if (config.storageProvider === "supabase") { setConfig(prev => ({ ...prev, s3AccessKeyId: "", s3SecretAccessKey: "", s3Region: "", s3Endpoint: "", s3Bucket: "", publicUrlHost: "" })); } else if (["s3", "cloudflare_r2", "gcs"].includes(config.storageProvider)) { - setConfig(prev => ({ ...prev, storageUrl: "", storageKey: "" })); + setConfig(prev => ({ + ...prev, + storageUrl: "", + storageKey: "", + ...(config.storageProvider === "gcs" ? { s3Endpoint: "", publicUrlHost: "" } : {}) + })); } }); }, [config.storageProvider]); diff --git a/apps/web-dashboard/src/pages/Templates.jsx b/apps/web-dashboard/src/pages/Templates.jsx index 4b340feb9..069e893b3 100644 --- a/apps/web-dashboard/src/pages/Templates.jsx +++ b/apps/web-dashboard/src/pages/Templates.jsx @@ -190,14 +190,18 @@ function Templates() { templateId: cloneTemplate.id, }); toast.success('Project cloned successfully!'); - navigate(`/project/${res.data._id}`); + + const newProjectId = res.data.data?._id || res.data._id; + + setCloneTemplate(null); + setProjectName(''); + setProjectDescription(''); + + navigate(`/project/${newProjectId}`); } catch (error) { toast.error(error.response?.data?.error || 'Failed to clone project'); } finally { setIsCloning(false); - setCloneTemplate(null); - setProjectName(''); - setProjectDescription(''); } }; @@ -445,9 +449,12 @@ function Templates() {
-

+

Clone {cloneTemplate.name}

+

+ Cloning automatically provisions the template's authentication setup and database collections. A Project Name is required. +

@@ -474,7 +481,7 @@ function Templates() { />
-