diff --git a/components/backend/handlers/github_auth.go b/components/backend/handlers/github_auth.go index 4ecd4553b..f4b79bbb1 100644 --- a/components/backend/handlers/github_auth.go +++ b/components/backend/handlers/github_auth.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strconv" "strings" @@ -209,6 +210,20 @@ func HandleGitHubUserOAuthCallback(c *gin.Context) { if retURL == "" { retURL = "/integrations" } + + // Check if we need to preserve onboarding parameter + onboarding := c.Query("onboarding") + if onboarding != "" { + // Parse the URL to add query parameter properly + parsedURL, err := url.Parse(retURL) + if err == nil { + q := parsedURL.Query() + q.Set("onboarding", onboarding) + parsedURL.RawQuery = q.Encode() + retURL = parsedURL.String() + } + } + c.Redirect(http.StatusFound, retURL) } diff --git a/components/frontend/.env.example b/components/frontend/.env.example index 01a918caf..80d919f96 100644 --- a/components/frontend/.env.example +++ b/components/frontend/.env.example @@ -6,7 +6,7 @@ # GitHub App identifier used to initiate installation # This may be your GitHub App slug or Client ID, depending on your setup -GITHUB_APP_SLUG=ambient-code-vteam +NEXT_PUBLIC_GITHUB_APP_SLUG=ambient-code-local # Direct backend base URL (used by server-side code where applicable) # Default local backend URL diff --git a/components/frontend/.env.production b/components/frontend/.env.production new file mode 100644 index 000000000..38dbff7f8 --- /dev/null +++ b/components/frontend/.env.production @@ -0,0 +1,2 @@ +NEXT_PUBLIC_GITHUB_APP_SLUG=ambient-code-local +NEXT_PUBLIC_VTEAM_VERSION=v0.0.3 diff --git a/components/frontend/Dockerfile b/components/frontend/Dockerfile index c656ac5ac..0e8933298 100644 --- a/components/frontend/Dockerfile +++ b/components/frontend/Dockerfile @@ -59,7 +59,6 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" - # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output CMD ["node", "server.js"] diff --git a/components/frontend/README.md b/components/frontend/README.md index e74887cc0..97f8cb00b 100644 --- a/components/frontend/README.md +++ b/components/frontend/README.md @@ -50,6 +50,19 @@ npm run dev # Open http://localhost:3000 ``` +### Mock Mode (No Backend Required) +npm ci +npm run dev +# Open http://localhost:3000 +``` + +**Mock Mode Features:** +- Pre-populated projects, sessions, and RFE workflows +- Auto-logged in as `developer` user with admin permissions +- GitHub integration pre-connected with mock repositories +- Full onboarding wizard support +- Configurable network delay simulation + ### Header forwarding model (dev and prod) Next.js API routes forward incoming headers to the backend. They do not auto-inject user identity. In development, you can optionally provide values via environment or `oc`: diff --git a/components/frontend/package.json b/components/frontend/package.json index df5f50ae9..6150dc8ab 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "NEXT_PUBLIC_USE_MOCKS=true NEXT_PUBLIC_MOCK_DELAY=300 GITHUB_APP_SLUG=ambient-code NEXT_PUBLIC_LOG_MOCKS=true next dev", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/components/frontend/src/app/api/auth/github/install/route.ts b/components/frontend/src/app/api/auth/github/install/route.ts index 4b9dd2316..1cd02d7d5 100644 --- a/components/frontend/src/app/api/auth/github/install/route.ts +++ b/components/frontend/src/app/api/auth/github/install/route.ts @@ -1,7 +1,14 @@ import { BACKEND_URL } from '@/lib/config' import { buildForwardHeadersAsync } from '@/lib/auth' +import { USE_MOCKS } from '@/lib/mock-config' +import { handleInstallGitHub } from '@/lib/mocks/handlers' export async function POST(request: Request) { + // Return mock data if enabled + if (USE_MOCKS) { + return handleInstallGitHub(request); + } + const headers = await buildForwardHeadersAsync(request) const body = await request.text() diff --git a/components/frontend/src/app/api/auth/github/status/route.ts b/components/frontend/src/app/api/auth/github/status/route.ts index 0117293fa..7bdf7a80f 100644 --- a/components/frontend/src/app/api/auth/github/status/route.ts +++ b/components/frontend/src/app/api/auth/github/status/route.ts @@ -1,7 +1,14 @@ import { BACKEND_URL } from '@/lib/config' import { buildForwardHeadersAsync } from '@/lib/auth' +import { USE_MOCKS } from '@/lib/mock-config' +import { handleGetGitHubStatus } from '@/lib/mocks/handlers' export async function GET(request: Request) { + // Return mock data if enabled + if (USE_MOCKS) { + return handleGetGitHubStatus(); + } + const headers = await buildForwardHeadersAsync(request) const resp = await fetch(`${BACKEND_URL}/auth/github/status`, { diff --git a/components/frontend/src/app/api/cluster-info/route.ts b/components/frontend/src/app/api/cluster-info/route.ts index 6fc84654a..40b57e217 100644 --- a/components/frontend/src/app/api/cluster-info/route.ts +++ b/components/frontend/src/app/api/cluster-info/route.ts @@ -1,4 +1,6 @@ import { BACKEND_URL } from '@/lib/config'; +import { USE_MOCKS } from '@/lib/mock-config'; +import { handleGetClusterInfo } from '@/lib/mocks/handlers'; /** * GET /api/cluster-info @@ -6,6 +8,11 @@ import { BACKEND_URL } from '@/lib/config'; * This endpoint does not require authentication as it's public cluster information */ export async function GET() { + // Return mock data if enabled + if (USE_MOCKS) { + return handleGetClusterInfo(); + } + try { const response = await fetch(`${BACKEND_URL}/cluster-info`, { method: 'GET', diff --git a/components/frontend/src/app/api/me/route.ts b/components/frontend/src/app/api/me/route.ts index f9f1f09f2..5ed4b1baf 100644 --- a/components/frontend/src/app/api/me/route.ts +++ b/components/frontend/src/app/api/me/route.ts @@ -1,6 +1,13 @@ import { buildForwardHeadersAsync } from '@/lib/auth'; +import { USE_MOCKS } from '@/lib/mock-config'; +import { handleGetMe } from '@/lib/mocks/handlers'; export async function GET(request: Request) { + // Return mock data if enabled + if (USE_MOCKS) { + return handleGetMe(); + } + try { // Use the shared helper so dev oc whoami and env fallbacks apply uniformly const headers = await buildForwardHeadersAsync(request); diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts index 64b24c6f2..096b0d911 100644 --- a/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts @@ -1,13 +1,21 @@ import { BACKEND_URL } from '@/lib/config'; import { buildForwardHeadersAsync } from '@/lib/auth'; +import { USE_MOCKS } from '@/lib/mock-config'; +import { handleListSessions } from '@/lib/mocks/handlers'; // GET /api/projects/[name]/agentic-sessions - List sessions in a project export async function GET( request: Request, { params }: { params: Promise<{ name: string }> } ) { + const { name } = await params; + + // Return mock data if enabled + if (USE_MOCKS) { + return handleListSessions(name); + } + try { - const { name } = await params; const headers = await buildForwardHeadersAsync(request); const response = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions`, { headers }); const text = await response.text(); diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts index 0ad40fd65..7412cd313 100644 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts +++ b/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts @@ -1,11 +1,19 @@ import { BACKEND_URL } from '@/lib/config' import { buildForwardHeadersAsync } from '@/lib/auth' +import { USE_MOCKS } from '@/lib/mock-config' +import { handleListRFEWorkflows } from '@/lib/mocks/handlers' export async function GET( request: Request, { params }: { params: Promise<{ name: string }> }, ) { const { name } = await params + + // Return mock data if enabled + if (USE_MOCKS) { + return handleListRFEWorkflows(name); + } + const headers = await buildForwardHeadersAsync(request) const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows`, { headers }) const data = await resp.text() diff --git a/components/frontend/src/app/api/projects/[name]/route.ts b/components/frontend/src/app/api/projects/[name]/route.ts index c8d07c800..0675b025f 100644 --- a/components/frontend/src/app/api/projects/[name]/route.ts +++ b/components/frontend/src/app/api/projects/[name]/route.ts @@ -1,13 +1,21 @@ import { BACKEND_URL } from '@/lib/config'; import { buildForwardHeadersAsync } from '@/lib/auth'; +import { USE_MOCKS } from '@/lib/mock-config'; +import { handleGetProject, handleDeleteProject } from '@/lib/mocks/handlers'; // GET /api/projects/[name] - Get project by name export async function GET( request: Request, { params }: { params: Promise<{ name: string }> } ) { + const { name } = await params; + + // Return mock data if enabled + if (USE_MOCKS) { + return handleGetProject(name); + } + try { - const { name } = await params; const headers = await buildForwardHeadersAsync(request); const response = await fetch(`${BACKEND_URL}/projects/${name}`, { headers }); @@ -56,8 +64,14 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ name: string }> } ) { + const { name } = await params; + + // Return mock data if enabled + if (USE_MOCKS) { + return handleDeleteProject(name); + } + try { - const { name } = await params; const headers = await buildForwardHeadersAsync(request); const response = await fetch(`${BACKEND_URL}/projects/${name}`, { diff --git a/components/frontend/src/app/api/projects/route.ts b/components/frontend/src/app/api/projects/route.ts index 6fcc7d24c..febda1592 100644 --- a/components/frontend/src/app/api/projects/route.ts +++ b/components/frontend/src/app/api/projects/route.ts @@ -1,8 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { BACKEND_URL } from "@/lib/config"; import { buildForwardHeadersAsync } from "@/lib/auth"; +import { USE_MOCKS } from "@/lib/mock-config"; +import { handleListProjects, handleCreateProject } from "@/lib/mocks/handlers"; export async function GET(request: NextRequest) { + // Return mock data if enabled + if (USE_MOCKS) { + return handleListProjects(); + } + try { const headers = await buildForwardHeadersAsync(request); @@ -30,6 +37,11 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + // Return mock data if enabled + if (USE_MOCKS) { + return handleCreateProject(request); + } + try { const body = await request.text(); diff --git a/components/frontend/src/app/api/version/route.ts b/components/frontend/src/app/api/version/route.ts index 51b250eb7..deb0c0334 100644 --- a/components/frontend/src/app/api/version/route.ts +++ b/components/frontend/src/app/api/version/route.ts @@ -1,6 +1,13 @@ import { env } from '@/lib/env'; +import { USE_MOCKS } from '@/lib/mock-config'; +import { handleGetVersion } from '@/lib/mocks/handlers'; export async function GET() { + // Return mock data if enabled + if (USE_MOCKS) { + return handleGetVersion(); + } + return Response.json({ version: env.VTEAM_VERSION, }); diff --git a/components/frontend/src/app/integrations/IntegrationsClient.tsx b/components/frontend/src/app/integrations/IntegrationsClient.tsx index 657447dcd..7726f50aa 100644 --- a/components/frontend/src/app/integrations/IntegrationsClient.tsx +++ b/components/frontend/src/app/integrations/IntegrationsClient.tsx @@ -23,6 +23,7 @@ export default function IntegrationsClient({ appSlug }: Props) { const handleDisconnect = async () => { disconnectMutation.mutate(undefined, { onSuccess: () => { + // TODO actually invalidate the github application remotely successToast('GitHub disconnected successfully') refetch() }, diff --git a/components/frontend/src/app/integrations/github/setup/page.tsx b/components/frontend/src/app/integrations/github/setup/page.tsx index 1ae8a76e6..072c37809 100644 --- a/components/frontend/src/app/integrations/github/setup/page.tsx +++ b/components/frontend/src/app/integrations/github/setup/page.tsx @@ -4,35 +4,61 @@ import React, { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Alert, AlertDescription } from '@/components/ui/alert' import { useConnectGitHub } from '@/services/queries' +import { publicEnv } from '@/lib/env' export default function GitHubSetupPage() { - const [message, setMessage] = useState('Finalizing GitHub connection...') + const [message, setMessage] = useState('Preparing GitHub connection...') const [error, setError] = useState(null) const connectMutation = useConnectGitHub() useEffect(() => { const url = new URL(window.location.href) const installationId = url.searchParams.get('installation_id') + const fromOnboarding = url.searchParams.get('from') === 'onboarding' - if (!installationId) { - setMessage('No installation was detected.') - return - } + // If returning from GitHub with installation_id, complete the connection + if (installationId) { + setMessage('Finalizing GitHub connection...') + connectMutation.mutate( + { installationId: Number(installationId) }, + { + onSuccess: () => { + setMessage('GitHub connected successfully! Redirecting...') + setTimeout(() => { + // Redirect back to onboarding or integrations page + window.location.replace(fromOnboarding ? '/onboarding' : '/integrations') + }, 800) + }, + onError: (err) => { + setError(err instanceof Error ? err.message : 'Failed to complete setup') + }, + } + ) + } else { + // No installation_id, redirect to GitHub OAuth + const appSlug = publicEnv.GITHUB_APP_SLUG + + if (!appSlug) { + setError('GitHub App is not configured. Please contact your administrator.') + return + } - connectMutation.mutate( - { installationId: Number(installationId) }, - { - onSuccess: () => { - setMessage('GitHub connected. Redirecting...') - setTimeout(() => { - window.location.replace('/integrations') - }, 800) - }, - onError: (err) => { - setError(err instanceof Error ? err.message : 'Failed to complete setup') - }, + setMessage('Redirecting to GitHub...') + + // Build the redirect URI with from parameter to return to the right place + const setupUrl = new URL('/integrations/github/setup', window.location.origin) + if (fromOnboarding) { + setupUrl.searchParams.set('from', 'onboarding') } - ) + const redirectUri = encodeURIComponent(setupUrl.toString()) + + // Redirect to GitHub OAuth + const githubUrl = `https://github.com/apps/${appSlug}/installations/new?redirect_uri=${redirectUri}` + + setTimeout(() => { + window.location.href = githubUrl + }, 500) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/components/frontend/src/app/onboarding/OnboardingClient.tsx b/components/frontend/src/app/onboarding/OnboardingClient.tsx new file mode 100644 index 000000000..f59fe2cc3 --- /dev/null +++ b/components/frontend/src/app/onboarding/OnboardingClient.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useProjects } from '@/services/queries'; +import { OnboardingWizard } from './components/OnboardingWizard'; +import { Skeleton } from '@/components/ui/skeleton'; + +export default function OnboardingClient() { + const { data: projects, isLoading } = useProjects(); + + // If user already has projects, allow them to skip onboarding + const canSkip = !isLoading && (projects?.length ?? 0) > 0; + + // Show loading state while checking projects + if (isLoading) { + return ( +
+
+
+
+ + +
+
+ +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ +
+
+
+ ); + } + + return ; +} + diff --git a/components/frontend/src/app/onboarding/components/OnboardingWizard.tsx b/components/frontend/src/app/onboarding/components/OnboardingWizard.tsx new file mode 100644 index 000000000..a0d9d204b --- /dev/null +++ b/components/frontend/src/app/onboarding/components/OnboardingWizard.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { ArrowLeft, ArrowRight, X } from 'lucide-react'; +import { WelcomeStep } from './steps/WelcomeStep'; +import { CreateProjectStep } from './steps/CreateProjectStep'; +import { GitHubStep } from './steps/GitHubStep'; +import { ReviewStep } from './steps/ReviewStep'; +import { useConnectGitHub } from '@/services/queries'; +import type { WizardData, WizardStep } from '../types'; + +const WIZARD_STEPS: WizardStep[] = [ + { + id: 'welcome', + title: 'Welcome', + description: 'Get started with Ambient Code', + }, + { + id: 'github', + title: 'Connect GitHub', + description: 'Enable repository access', + }, + { + id: 'project', + title: 'Create Project', + description: 'Set up your workspace', + }, + { + id: 'review', + title: 'Review', + description: 'Confirm and complete', + }, +]; + +type OnboardingWizardProps = { + canSkip?: boolean; +}; + +export function OnboardingWizard({ canSkip = false }: OnboardingWizardProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const connectGitHubMutation = useConnectGitHub(); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [wizardData, setWizardData] = useState({ + projectName: '', + projectDisplayName: '', + projectDescription: '', + githubConnected: false, + }); + const [processingInstallation, setProcessingInstallation] = useState(false); + + const currentStep = WIZARD_STEPS[currentStepIndex]; + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === WIZARD_STEPS.length - 1; + const progress = ((currentStepIndex + 1) / WIZARD_STEPS.length) * 100; + + const handleNext = useCallback(() => { + if (!isLastStep) { + setCurrentStepIndex((prev) => prev + 1); + } + }, [isLastStep]); + + const handleBack = useCallback(() => { + if (!isFirstStep) { + setCurrentStepIndex((prev) => prev - 1); + } + }, [isFirstStep]); + + const handleProjectCreated = useCallback( + (projectName: string) => { + setWizardData((prev) => ({ + ...prev, + createdProjectName: projectName, + })); + handleNext(); + }, + [handleNext] + ); + + const handleGitHubVerified = useCallback(() => { + setWizardData((prev) => ({ + ...prev, + githubConnected: true, + })); + }, []); + + const handleComplete = useCallback(() => { + const projectName = wizardData.createdProjectName || wizardData.projectName; + if (projectName) { + router.push(`/projects/${projectName}`); + } else { + router.push('/projects'); + } + }, [wizardData, router]); + + const handleSkip = useCallback(() => { + router.push('/projects'); + }, [router]); + + const canProceedToNext = useCallback(() => { + switch (currentStep.id) { + case 'welcome': + return true; + case 'github': + return wizardData.githubConnected; + case 'project': + return !!wizardData.createdProjectName; + case 'review': + return true; + default: + return false; + } + }, [currentStep.id, wizardData]); + + // Handle GitHub installation callback + useEffect(() => { + const installationId = searchParams.get('installation_id'); + const isOnGitHubStep = currentStep.id === 'github'; + + if (installationId && isOnGitHubStep && !processingInstallation && !wizardData.githubConnected) { + setProcessingInstallation(true); + + connectGitHubMutation.mutate( + { installationId: Number(installationId) }, + { + onSuccess: () => { + handleGitHubVerified(); + // Remove the installation_id from URL without navigation + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('installation_id'); + window.history.replaceState({}, '', newUrl); + }, + onError: (error) => { + console.error('Failed to connect GitHub:', error); + setProcessingInstallation(false); + }, + } + ); + } + }, [searchParams, currentStep.id, processingInstallation, wizardData.githubConnected, connectGitHubMutation, handleGitHubVerified]); + + return ( +
+
+ {/* Header */} +
+
+
+

Get Started

+

+ Set up your Ambient Code for your organization +

+
+ {canSkip && ( + + )} +
+ + {/* Progress */} +
+
+ + Step {currentStepIndex + 1} of {WIZARD_STEPS.length} + + {currentStep.title} +
+ +
+ + {/* Step Indicators */} +
+ {WIZARD_STEPS.map((step, index) => ( +
+
+
+ {index + 1} +
+
+

+ {step.title} +

+
+
+
+ ))} +
+
+ + {/* Step Content */} +
+ {currentStep.id === 'welcome' && } + {currentStep.id === 'project' && ( + + )} + {currentStep.id === 'github' && ( + + )} + {currentStep.id === 'review' && } +
+ + {/* Navigation */} +
+ + + {isLastStep ? ( + + ) : ( + + )} +
+
+
+ ); +} + diff --git a/components/frontend/src/app/onboarding/components/steps/CreateProjectStep.tsx b/components/frontend/src/app/onboarding/components/steps/CreateProjectStep.tsx new file mode 100644 index 000000000..9eb74e837 --- /dev/null +++ b/components/frontend/src/app/onboarding/components/steps/CreateProjectStep.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useCreateProject } from '@/services/queries'; +import { useEffect } from 'react'; + +const projectSchema = z.object({ + name: z + .string() + .min(3, 'Project name must be at least 3 characters') + .max(63, 'Project name must be at most 63 characters') + .regex(/^[a-z0-9-]+$/, 'Project name must be lowercase alphanumeric with dashes') + .regex(/^[a-z]/, 'Project name must start with a letter') + .regex(/[a-z0-9]$/, 'Project name must end with a letter or number'), + displayName: z + .string() + .min(1, 'Display name is required') + .max(100, 'Display name must be at most 100 characters'), + description: z.string().max(500, 'Description must be at most 500 characters').optional(), +}); + +type ProjectFormData = z.infer; + +type CreateProjectStepProps = { + onProjectCreated: (projectName: string) => void; + initialData?: { + name: string; + displayName: string; + description: string; + }; +}; + +export function CreateProjectStep({ onProjectCreated, initialData }: CreateProjectStepProps) { + const createProjectMutation = useCreateProject(); + + const form = useForm({ + resolver: zodResolver(projectSchema), + defaultValues: { + name: initialData?.name || '', + displayName: initialData?.displayName || '', + description: initialData?.description || '', + }, + }); + + const onSubmit = (data: ProjectFormData) => { + createProjectMutation.mutate( + { + name: data.name, + displayName: data.displayName, + description: data.description || '', + }, + { + onSuccess: () => { + onProjectCreated(data.name); + }, + } + ); + }; + + // Auto-generate project name from display name + const watchDisplayName = form.watch('displayName'); + useEffect(() => { + if (watchDisplayName && !form.formState.dirtyFields.name) { + const kebabCase = watchDisplayName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + form.setValue('name', kebabCase, { shouldValidate: true }); + } + }, [watchDisplayName, form]); + + return ( + + + Create Your First Project + + Projects provide isolated namespaces for your agentic sessions and configurations + + + +
+ + ( + + Display Name + + + + A human-readable name for your project + + + )} + /> + + ( + + Project Name + + + + + Unique identifier (lowercase, alphanumeric, dashes only) + + + + )} + /> + + ( + + Description (Optional) + +