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..9bdc321bb --- /dev/null +++ b/apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js @@ -0,0 +1,310 @@ +'use strict'; + +const mongoose = require('mongoose'); + +/** + * PROJECT_TEMPLATES — inline copy of just the templates createProject uses. + * We keep this in the test so we never pull in the real @urbackend/common + * (which initialises Redis, BullMQ, GC timers — all blow up in CI). + */ +const 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' }, + ], + }, + ], + }, +}; + +/* ------------------------------------------------------------------ */ +/* Real Zod — needed for schema validation inside createProject */ +/* ------------------------------------------------------------------ */ +const { z } = require('zod'); + +/* Reconstruct the real createProjectSchema so parse() actually works */ +const 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 + .string() + .url('Invalid Site URL format') + .refine((url) => { + try { + const parsed = new URL(url); + return ( + parsed.protocol === 'https:' || + (parsed.protocol === 'http:' && + ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) + ); + } catch { + return false; + } + }, 'Site URL must use HTTPS (or http://localhost for local development)') + .optional(), + ), +}); + +/* ------------------------------------------------------------------ */ +/* Mock @urbackend/common — NO jest.requireActual to avoid side- */ +/* effects (Redis, BullMQ queues, GC timers, email service, etc.) */ +/* ------------------------------------------------------------------ */ +const mockSave = jest.fn(); +const mockCountDocuments = jest.fn(); + +// Minimal Project constructor mock that behaves like a Mongoose model +function MockProject(data) { + Object.assign(this, data); + this._id = data._id || new mongoose.Types.ObjectId(); + this.collections = this.collections || []; + this.isAuthEnabled = this.isAuthEnabled || false; +} +MockProject.prototype.save = mockSave; +MockProject.prototype.toObject = function () { + const obj = { ...this }; + // Simulate toObject stripping prototype methods + delete obj.save; + delete obj.toObject; + return obj; +}; +MockProject.countDocuments = mockCountDocuments; +// For spying / instanceof checks +MockProject.schema = { obj: {} }; + +jest.mock('@urbackend/common', () => ({ + Project: MockProject, + PROJECT_TEMPLATES, + createProjectSchema, + generateApiKey: jest.fn(() => 'test_api_key'), + hashApiKey: jest.fn(() => 'hashed_key'), + encrypt: jest.fn(() => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), + markDeveloperOnboardingStep: jest.fn().mockResolvedValue(), + AppError: class AppError extends Error { + constructor(statusCode, message, error = null) { + super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.error = error || (statusCode >= 500 ? 'Internal Server Error' : 'Error'); + this.isOperational = true; + } + }, + // Stubs for other imports used at module-level by project.controller.js + Developer: { findById: jest.fn() }, + Log: { aggregate: jest.fn() }, + getStorage: jest.fn(), + getConnection: jest.fn(), + getCompiledModel: jest.fn(), + QueryEngine: jest.fn(), + storageRegistry: {}, + webhookQueue: { add: jest.fn() }, + enqueueCollectionCleanup: jest.fn(), + syncCollectionCleanup: jest.fn(), + resolveEffectivePlan: jest.fn(() => ({ name: 'free' })), + deleteProjectByApiKeyCache: jest.fn(), + setProjectById: jest.fn(), + getProjectById: jest.fn(), + deleteProjectById: jest.fn(), + isProjectStorageExternal: jest.fn(() => false), + getBucket: jest.fn(() => 'default'), + getPresignedUploadUrl: jest.fn(), + verifyUploadedFile: jest.fn(), + getPublicIp: jest.fn(), + clearCompiledModel: jest.fn(), + createUniqueIndexes: jest.fn(), + ApiAnalytics: { aggregate: jest.fn() }, + MailLog: { aggregate: jest.fn() }, + getProjectAccessQuery: jest.fn((userId) => ({ + $or: [{ owner: userId }, { 'members.user': userId }], + })), + getProjectRole: jest.fn(), + Invitation: { find: jest.fn() }, + sanitizeObjectId: jest.fn((v) => v), + sanitizeNonEmptyString: jest.fn((v) => v), + createCollectionSchema: { parse: jest.fn((v) => v) }, + editCollectionSchema: { parse: jest.fn((v) => v) }, + updateExternalConfigSchema: { parse: jest.fn((v) => v) }, + updateAuthProvidersSchema: { parse: jest.fn((v) => v) }, + decrypt: jest.fn(() => 'decrypted'), + getS3CompatibleStorage: jest.fn(), +})); + +jest.mock('../utils/emitEvent', () => ({ + emitEvent: jest.fn(), +})); + +jest.setTimeout(15000); + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ +describe('Project Controller - Clone Template', () => { + let req, res; + + // Import AFTER mocks are set up + const { createProject } = require('../controllers/project.controller'); + + beforeEach(() => { + req = { + body: {}, + user: { _id: new mongoose.Types.ObjectId(), isVerified: true }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + jest.clearAllMocks(); + + // Simulate standalone MongoDB (no replica set) — triggers fallback path + jest.spyOn(mongoose, 'startSession').mockRejectedValue( + Object.assign(new Error('Transaction numbers are only allowed on a replica set member or mongos'), { code: 20 }) + ); + + // Mock save to behave like Mongoose save + mockSave.mockImplementation(function () { + this._id = this._id || new mongoose.Types.ObjectId(); + return Promise.resolve(this); + }); + + mockCountDocuments.mockResolvedValue(0); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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); + expect(res.json).toHaveBeenCalled(); + + const responseObj = res.json.mock.calls[0][0]; + expect(responseObj.success).toBe(true); + expect(responseObj.data.name).toBe('Test Clone Project'); + expect(responseObj.data.isAuthEnabled).toBe(true); + + const collectionNames = responseObj.data.collections.map((c) => c.name); + expect(collectionNames).toContain('boards'); + expect(collectionNames).toContain('tasks'); + expect(collectionNames).toContain('users'); + + const boardsCol = responseObj.data.collections.find((c) => c.name === 'boards'); + const mode = typeof boardsCol.rls === 'string' ? boardsCol.rls : boardsCol.rls?.mode; + expect(mode).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 responseObj = res.json.mock.calls[0][0]; + expect(responseObj.success).toBe(true); + expect(responseObj.data.name).toBe('Normal Project'); + expect(responseObj.data.collections).toHaveLength(0); + expect(responseObj.data.isAuthEnabled).toBeFalsy(); + }); + + it('should create a project without templateId', async () => { + req.body = { + name: 'Blank Project', + }; + + await createProject(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + const responseObj = res.json.mock.calls[0][0]; + expect(responseObj.success).toBe(true); + expect(responseObj.data.name).toBe('Blank Project'); + expect(responseObj.data.collections).toHaveLength(0); + }); + + it('should return 400 for missing project name', async () => { + req.body = {}; + + await createProject(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('should enforce project limit when set', async () => { + req.body = { name: 'Over Limit' }; + req.projectLimit = 2; + mockCountDocuments.mockResolvedValue(2); + + await createProject(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('should add users collection with auth-enabled template', async () => { + req.body = { + name: 'Auth Template Project', + templateId: 'sdk-kanban', + }; + + await createProject(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + const responseObj = res.json.mock.calls[0][0]; + const usersCol = responseObj.data.collections.find((c) => c.name === 'users'); + expect(usersCol).toBeDefined(); + + const usersMode = typeof usersCol.rls === 'string' ? usersCol.rls : usersCol.rls?.mode; + expect(usersMode).toBe('private'); + + const emailField = usersCol.model.find((f) => f.key === 'email'); + const passwordField = usersCol.model.find((f) => f.key === 'password'); + expect(emailField).toBeDefined(); + expect(passwordField).toBeDefined(); + }); + + it('should create project successfully using transactional path', async () => { + const mockSession = { + startTransaction: jest.fn(), + commitTransaction: jest.fn().mockResolvedValue(), + abortTransaction: jest.fn().mockResolvedValue(), + endSession: jest.fn() + }; + mongoose.startSession.mockResolvedValue(mockSession); + + req.body = { name: 'Transactional Project' }; + + await createProject(req, res); + + expect(mockSession.startTransaction).toHaveBeenCalled(); + expect(mockSession.commitTransaction).toHaveBeenCalled(); + expect(mockSession.endSession).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Project created successfully' }) + ); + }); +}); diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 173239b4f..1bad7e982 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; @@ -267,8 +267,24 @@ const bestEffortDeleteUploadedObject = async (project, filePath) => { }; module.exports.createProject = async (req, res) => { + const finalizeProjectCreation = (projectObj, newProject) => { + markDeveloperOnboardingStep(req.user._id, 'projectCreated', { projectId: newProject._id }) + .then(() => { + // Also reset subsequent steps since this is a new project + return Promise.all([ + markDeveloperOnboardingStep(req.user._id, 'collectionCreated', { _reset: true }), + markDeveloperOnboardingStep(req.user._id, 'firstApiCall', { _reset: true }) + ]); + }) + .catch((err) => { + 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({ success: true, data: projectObj, message: "Project created successfully" }); + }; + 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) { @@ -279,9 +295,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(403, `Project limit reached (${req.projectLimit}). Please upgrade your plan to create more projects.`); } } @@ -295,7 +309,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 +318,44 @@ 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; + } + if (template.collections && template.collections.length > 0) { + newProject.collections = template.collections.map(col => { + const mode = typeof col.rls === 'string' ? col.rls : (col.rls?.mode || 'public-read'); + const defaultRls = getDefaultRlsForCollection(col.name, col.model); + return { + name: col.name, + model: col.model, + rls: { + enabled: mode !== 'public-read', + mode, + ownerField: defaultRls.ownerField, + requireAuthForWrite: true + } + }; + }); + } + if (newProject.isAuthEnabled && !newProject.collections.some(c => c.name === 'users')) { + newProject.collections.push({ + name: 'users', + rls: { + enabled: true, + mode: 'private', + ownerField: '_id', + requireAuthForWrite: true + }, + model: [ + { key: 'email', type: 'String', required: true, unique: true }, + { key: 'password', type: 'String', required: true } + ] + }); + } + } const saveOpts = session ? { session } : {}; await newProject.save(saveOpts); @@ -326,42 +378,41 @@ module.exports.createProject = async (req, res) => { await session.commitTransaction(); session.endSession(); - - markDeveloperOnboardingStep(req.user._id, 'projectCreated', { projectId: newProject._id }) - .then(() => { - // Also reset subsequent steps since this is a new project - return Promise.all([ - markDeveloperOnboardingStep(req.user._id, 'collectionCreated', { _reset: true }), - markDeveloperOnboardingStep(req.user._id, 'firstApiCall', { _reset: true }) - ]); - }) - .catch((err) => { - 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 finalizeProjectCreation(projectObj, newProject); } catch (err) { if (session) { - try { await session.abortTransaction(); } catch (e) {} + try { + await session.abortTransaction(); + } catch (e) { + console.error("Failed to abort transaction:", e.message); + } session.endSession(); } - if (err.message && (err.message.includes("Transaction numbers are only allowed") || err.message.includes("buffering timed out"))) { + const isTransactionError = err.message && + err.message.includes("Transaction numbers are only allowed") && + (err.code === 20 || err.codeName === 'IllegalOperation'); + + if (isTransactionError) { try { const { projectObj, newProject } = await executeOperation(null); - markDeveloperOnboardingStep(req.user._id, 'projectCreated', { projectId: newProject._id }).catch((err) => { - 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 finalizeProjectCreation(projectObj, newProject); } 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, data: retryErr.issues, message: "Validation failed" }); + } + const statusCode = retryErr instanceof AppError ? retryErr.statusCode : 500; + const message = statusCode === 500 ? "Internal server error" : retryErr.message; + return res.status(statusCode).json({ success: false, data: {}, 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, data: err.issues, message: "Validation failed" }); + } + const statusCode = err instanceof AppError ? err.statusCode : 500; + const message = statusCode === 500 ? "Internal server error" : err.message; + return res.status(statusCode).json({ success: false, data: {}, message }); } }; diff --git a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx index 35414ba4d..b231a95ab 100644 --- a/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx +++ b/apps/web-dashboard/src/components/Settings/StorageConfigForm.jsx @@ -28,8 +28,13 @@ 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)) { - setConfig(prev => ({ ...prev, storageUrl: "", storageKey: "" })); + } else if (["s3", "cloudflare_r2", "gcs"].includes(config.storageProvider)) { + setConfig(prev => ({ + ...prev, + storageUrl: "", + storageKey: "", + ...(config.storageProvider === "gcs" ? { s3Endpoint: "", publicUrlHost: "" } : {}) + })); } }); }, [config.storageProvider]); @@ -40,6 +45,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 +86,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 +117,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, +
@@ -128,7 +135,7 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate, )} - {(config.storageProvider === "s3" || config.storageProvider === "cloudflare_r2") && ( + {["s3", "cloudflare_r2", "gcs"].includes(config.storageProvider) && ( <>
@@ -140,6 +147,11 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate,
+ ) : config.storageProvider === "gcs" ? ( +
+ + +
) : (
@@ -149,11 +161,15 @@ export default function StorageConfigForm({ project, projectId, onProjectUpdate,
- - + +
- +
@@ -162,8 +178,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 6c632cffd..0836a7e64 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 = [ { @@ -286,6 +287,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 => { @@ -306,6 +312,33 @@ 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!'); + + 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); + } + }; + return (
{template.deployUrl && (
+ + {/* Clone Modal */} + {cloneTemplate && ( +
+
+

+ Clone {cloneTemplate.name} +

+

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

+
+
+ + 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 4c88f5381..972938448 100644 --- a/mintlify/docs/docs.json +++ b/mintlify/docs/docs.json @@ -14,11 +14,9 @@ }, "favicon": "https://media.brand.dev/98c8a636-fed2-4095-a2d6-1ed768aca5dd.png", "seo": { + "googleSiteVerification": "o5KNQ9R-ummCVpBGTiPWtBLvDPdVm-uxMuIQXmj3mlU", "metatags": { - "google-site-verification": [ - "o5KNQ9R-ummCVpBGTiPWtBLvDPdVm-uxMuIQXmj3mlU", - "7EMPCEhVMV-pX3iYSiRSjNI6d5aLL2csbeDQySbOMV4" - ] + "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..07eefda7e 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 || + (provider === "cloudflare_r2" && !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: [] + } +};