diff --git a/.env.template b/.env.template index 7190077..20143a8 100644 --- a/.env.template +++ b/.env.template @@ -2,38 +2,10 @@ DATABASE_URL="" # Authentication -NEXTAUTH_URL="" -NEXTAUTH_SECRET="" -AUTH_TRUST_HOST="true" - -# GitHub App (required for GitHub login and repository import) -GITHUB_APP_ID="" -GITHUB_APP_PRIVATE_KEY="" -GITHUB_APP_WEBHOOK_SECRET="" -GITHUB_APP_CLIENT_ID="" -GITHUB_APP_CLIENT_SECRET="" -NEXT_PUBLIC_GITHUB_APP_NAME="" - -# Sealos OAuth (optional) -SEALOS_JWT_SECRET="" - -# Feature Flags -ENABLE_PASSWORD_AUTH="" -ENABLE_GITHUB_AUTH="true" -ENABLE_SEALOS_AUTH="" - -# Reconciliation -DATABASE_LOCK_DURATION_SECONDS="" -MAX_DATABASES_PER_RECONCILE="" -SANDBOX_LOCK_DURATION_SECONDS="" -MAX_SANDBOXES_PER_RECONCILE="" - -# Kubernetes -RUNTIME_IMAGE="" - -# AI Proxy -AIPROXY_ENDPOINT="" -ANTHROPIC_BASE_URL="" +BETTER_AUTH_URL="http://localhost:3000" +BETTER_AUTH_SECRET="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" # Logging LOG_LEVEL="info" diff --git a/.github/workflows/build-runtime.yml b/.github/workflows/build-runtime.yml deleted file mode 100644 index 997c39f..0000000 --- a/.github/workflows/build-runtime.yml +++ /dev/null @@ -1,216 +0,0 @@ -name: Build Runtime Image - -on: - workflow_dispatch: - -permissions: - pull-requests: write - packages: write - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} - -jobs: - build-runtime-images: - name: Build Runtime Docker Images - permissions: - packages: write - runs-on: ubuntu-24.04 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Convert repository owner to lowercase - id: repo-owner - run: | - echo "lowercase=${GITHUB_REPOSITORY_OWNER@L}" >> $GITHUB_OUTPUT - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' && env.DOCKERHUB_USERNAME != '' }} - uses: docker/login-action@v3 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to GitHub Container Registry - if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ steps.repo-owner.outputs.lowercase }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime - ${{ env.DOCKERHUB_USERNAME && format('docker.io/{0}/fullstack-web-runtime', env.DOCKERHUB_USERNAME) || '' }} - tags: | - type=ref,event=branch - type=ref,event=tag - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha,prefix=sha- - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') || github.ref == format('refs/heads/{0}', 'master') }} - labels: | - org.opencontainers.image.title=FullStack Web Runtime - org.opencontainers.image.description=Full-stack web development runtime with Next.js, shadcn/ui, Claude Code CLI, and container tools - org.opencontainers.image.vendor=${{ steps.repo-owner.outputs.lowercase }} - - - name: Build and Push Docker Image - id: docker-build - uses: docker/build-push-action@v6 - with: - context: ./runtime - file: ./runtime/Dockerfile - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64 - tags: ${{ steps.meta.outputs.tags }} - # PR builds: load locally for validation, Push builds: push to registry - push: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} - load: ${{ github.event_name == 'pull_request' }} - cache-from: type=gha,scope=runtime-amd64 - cache-to: type=gha,mode=max,scope=runtime-amd64 - - - name: Comment on PR - if: github.event_name == 'pull_request' && always() - uses: actions/github-script@v7 - continue-on-error: true - with: - script: | - const buildSuccess = '${{ steps.docker-build.outcome }}' === 'success'; - const emoji = buildSuccess ? '✅' : '❌'; - const status = buildSuccess ? 'Success' : 'Failed'; - - let body = `## ${emoji} FullStack Web Runtime Build ${status}\n\n`; - body += `### Build Details\n\n`; - body += `| Item | Value |\n`; - body += `|------|-------|\n`; - body += `| Build Status | ${buildSuccess ? '✅ Passed' : '❌ Failed'} |\n`; - body += `| Platforms | linux/amd64 (PR validation) |\n`; - body += `| Push to Registry | ⚠️ No (PR build only) |\n`; - body += `| Base Image | ubuntu:24.04 |\n`; - body += `| Node.js | 22.x LTS |\n`; - body += `| Components | Claude Code CLI, ttyd, Next.js, Prisma, PostgreSQL client, Buildah |\n\n`; - - if (buildSuccess) { - body += `### 📦 Runtime image will be published after merge\n\n`; - body += `**Note**: PR builds only verify the Docker build process. `; - body += `Images are pushed to registries only when merged to main.\n\n`; - body += `**Registries**:\n`; - body += `- GitHub Container Registry: \`ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime\`\n`; - if ('${{ env.DOCKERHUB_USERNAME }}') { - body += `- Docker Hub: \`docker.io/${{ env.DOCKERHUB_USERNAME }}/fullstack-web-runtime\`\n`; - } - body += `\n**Included Tools**:\n`; - body += `- Node.js 22.x + npm, pnpm, yarn\n`; - body += `- Claude Code CLI (@anthropic-ai/claude-code)\n`; - body += `- Next.js with shadcn/ui components\n`; - body += `- PostgreSQL 16 client\n`; - body += `- Container tools (Buildah, Podman, Skopeo)\n`; - body += `- Development tools (Git, GitHub CLI, ripgrep, jq, etc.)\n`; - body += `- ttyd web terminal\n`; - } else { - body += `### ❌ Build Failed\n\n`; - body += `Please check the workflow logs for detailed error information.\n`; - } - - body += `\n---\n`; - body += `**Commit**: \`${{ github.sha }}\`\n`; - body += `**Triggered by**: @${{ github.actor }}\n`; - - try { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('FullStack Web Runtime Build') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } - } catch (error) { - console.log('Failed to post comment:', error.message); - console.log('This might be expected for PRs from forks'); - } - - - name: Generate build summary - if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' && always() }} - run: | - echo "## 🚀 Runtime Image Build & Push Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Build Status" >> $GITHUB_STEP_SUMMARY - if [ "${{ steps.docker-build.outcome }}" = "success" ]; then - echo "- ✅ Runtime build successful" >> $GITHUB_STEP_SUMMARY - echo "- ✅ Platform: \`linux/amd64\`" >> $GITHUB_STEP_SUMMARY - echo "- ✅ Pushed to GitHub Container Registry: \`ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime\`" >> $GITHUB_STEP_SUMMARY - if [ -n "${{ env.DOCKERHUB_USERNAME }}" ]; then - echo "- ✅ Pushed to Docker Hub: \`docker.io/${{ env.DOCKERHUB_USERNAME }}/fullstack-web-runtime\`" >> $GITHUB_STEP_SUMMARY - fi - else - echo "- ❌ Build or push failed" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Runtime Image Details" >> $GITHUB_STEP_SUMMARY - echo "- **Base**: Ubuntu 24.04" >> $GITHUB_STEP_SUMMARY - echo "- **Node.js**: 22.x LTS" >> $GITHUB_STEP_SUMMARY - echo "- **PostgreSQL Client**: 16" >> $GITHUB_STEP_SUMMARY - echo "- **Claude Code CLI**: @anthropic-ai/claude-code" >> $GITHUB_STEP_SUMMARY - echo "- **Next.js**: Latest with shadcn/ui components" >> $GITHUB_STEP_SUMMARY - echo "- **Container Tools**: Buildah, Podman, Skopeo" >> $GITHUB_STEP_SUMMARY - echo "- **Terminal**: ttyd web-based terminal" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Build Information" >> $GITHUB_STEP_SUMMARY - echo "- **Commit SHA**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Branch**: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Triggered by**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY - echo "- **Event**: \`${{ github.event_name }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Build time**: $(date '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Image Tags" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Usage Example" >> $GITHUB_STEP_SUMMARY - echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "# Pull the latest image" >> $GITHUB_STEP_SUMMARY - echo "docker pull ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime:latest" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "# Run with environment variables" >> $GITHUB_STEP_SUMMARY - echo "docker run -d -p 7681:7681 -p 3000:3000 \\" >> $GITHUB_STEP_SUMMARY - echo " -e ANTHROPIC_AUTH_TOKEN=your_token \\" >> $GITHUB_STEP_SUMMARY - echo " -e PROJECT_NAME=my-project \\" >> $GITHUB_STEP_SUMMARY - echo " ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime:latest" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c98dcd..51909d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,32 @@ env: NODE_VERSION: "22.12.0" PNPM_VERSION: "10.20.0" NEXT_TELEMETRY_DISABLED: 1 - SKIP_ENV_VALIDATION: 1 + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fulling_test + BETTER_AUTH_URL: http://127.0.0.1:3000 + BETTER_AUTH_SECRET: ci-better-auth-secret-at-least-32-characters + GITHUB_CLIENT_ID: test-github-client-id + GITHUB_CLIENT_SECRET: test-github-client-secret jobs: lint-test-build: name: Lint, Test, Build runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: fulling_test + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d fulling_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -41,11 +60,20 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Deploy database baseline + run: pnpm prisma:migrate + - name: Run linter run: pnpm lint - name: Run tests run: pnpm test + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run browser tests + run: pnpm test:e2e + - name: Build project run: pnpm build diff --git a/.gitignore b/.gitignore index 51b965d..5517136 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ # testing /coverage +/playwright-report/ +/test-results/ +/e2e/.auth/ # next.js /.next/ @@ -42,8 +45,12 @@ yarn-error.log* *.pem *.crt *.pfx -kubeconfig -*kubeconfig* +/kubeconfig +/kubeconfig.yaml +/kubeconfig.yml +*.kubeconfig +*.kubeconfig.yaml +*.kubeconfig.yml credentials.json secrets.yaml secrets.yml diff --git a/AGENTS.md b/AGENTS.md index fa38a85..1ecbd00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ Read [docs/architecture.md](./docs/architecture.md) before product or architectu - Next.js 16 (App Router) + React 19 + TypeScript - Tailwind CSS v4 + Shadcn/UI -- Node.js 22 + Prisma + NextAuth v5 -- Kubernetes + PostgreSQL (KubeBlocks) +- Node.js 22 + Prisma + Better Auth +- Kubernetes + PostgreSQL ## Code Conventions @@ -25,11 +25,13 @@ Read [docs/architecture.md](./docs/architecture.md) before product or architectu ## Current Implementation Context -- Existing code still contains v2 project/resource abstractions. Do not treat them as v3 product architecture. -- **Asynchronous reconciliation** exists in current code: API → Database → Reconciliation Job → Event → K8s Operation -- **Always use user-specific K8s service**: `const k8sService = await getK8sServiceForUser(userId)` -- **Optimistic locking** in Repository layer -- **Non-blocking APIs**: endpoints only update DB, return immediately +- GitHub is the only authentication provider. +- Better Auth stores users, provider accounts, and sessions in PostgreSQL. +- Each user may store one plaintext kubeconfig; browser APIs never return it. +- **Always use the user-specific K8s service**: + `const k8sService = await getK8sServiceForUser(userId)`. +- The current user-level kubeconfig is a foundation boundary, not the final v3 + Workspace Runtime ownership model. ## UI Direction @@ -43,9 +45,9 @@ Avoid generic AI copywriting cliches such as "Elevate", "Seamless", and ## Key Files - [docs/architecture.md](./docs/architecture.md) — v3 system architecture and product model -- `lib/k8s/k8s-service-helper.ts` — User-specific K8s service -- `lib/events/` + `lib/jobs/` — Reconciliation core -- `instrumentation.ts` — Application startup +- `lib/auth/` — Better Auth and application session boundary +- `lib/kubeconfig/` — User credential persistence +- `lib/k8s/` — Validation and user-specific Kubernetes service ## Image Tagging Policy @@ -90,9 +92,10 @@ Do not use ambiguous moving tags such as `main`, `stable`, `prod`, `release`, ## Development Commands ```bash -pnpm dev # Start dev server -pnpm build # Build for production -pnpm lint # Run ESLint -npx prisma generate # Generate Prisma client -npx prisma db push # Push schema to database +corepack pnpm dev # Start dev server +corepack pnpm build # Build for production +corepack pnpm lint # Run ESLint +corepack pnpm test # Run Vitest +corepack pnpm test:e2e # Run Playwright +corepack pnpm prisma:migrate # Deploy the baseline migration ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8adffc5..5feba05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,42 +1,31 @@ # Contributing to Fulling -Thank you for your interest in Fulling. This document covers the essentials for contributing effectively. - -## Project Direction - -Fulling v3.0 is building **dedicated AI workspaces** — persistent, personalizable environments with skills, files, memory, scripts, and runtime. Before contributing, read [README.md](./README.md) for the product vision and [design/DESIGN.md](./design/DESIGN.md) for the UI design system. +Fulling v3 is building dedicated AI workspaces. Read +[docs/architecture.md](./docs/architecture.md) before product or architecture +work. ## Development Setup -Prerequisites: Node.js 22.12.0+, pnpm 10.20.0+, PostgreSQL, Kubernetes with KubeBlocks. +Requirements: Node.js 22.12+, pnpm 10.20.0, PostgreSQL, and GitHub OAuth +credentials. ```bash -git clone https://github.com/FullAgent/fulling.git -cd fulling -pnpm install +corepack pnpm install cp .env.template .env.local -# Edit .env.local with your settings -npx prisma generate -npx prisma db push -pnpm dev +corepack pnpm prisma:migrate +corepack pnpm dev ``` -Generated repository documentation lives in [.qoder/repowiki](./.qoder/repowiki). -Regenerate it when code structure changes materially. +The baseline migration targets a new or explicitly reset database. There is no +v2 data migration. ## Before Submitting -- [ ] `pnpm lint` passes -- [ ] `pnpm build` succeeds -- [ ] UI changes follow [design/DESIGN.md](./design/DESIGN.md) -- [ ] Commits use conventional format: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` - -## Reporting Issues - -- Search existing issues first -- One issue per bug or feature request -- Include: reproduction steps, expected vs actual behavior, environment info - -## Questions? +- `corepack pnpm lint` +- `corepack pnpm test` +- `corepack pnpm test:e2e` +- `corepack pnpm build` +- verify that no browser response or log includes kubeconfig content -Open a [GitHub Discussion](https://github.com/FullAgent/fulling/discussions) or ask in an existing issue. +Commits use conventional prefixes such as `feat:`, `fix:`, `docs:`, `refactor:`, +and `chore:`. diff --git a/Dockerfile b/Dockerfile index 9fa064f..544e755 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Install dependencies only when needed -FROM node:current-alpine AS deps +FROM node:22-alpine AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat openssl && npm install -g pnpm +RUN apk add --no-cache libc6-compat openssl && corepack enable && corepack prepare pnpm@10.20.0 --activate WORKDIR /app # Copy package files and prisma schema @@ -18,7 +18,7 @@ RUN \ fi # Rebuild the source code only when needed -FROM node:current-alpine AS builder +FROM node:22-alpine AS builder RUN apk add --no-cache openssl WORKDIR /app COPY --from=deps /app/node_modules ./node_modules @@ -29,16 +29,15 @@ COPY . . # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. ENV NEXT_TELEMETRY_DISABLED=1 -ENV NEXT_PUBLIC_MOCK_USER='' ENV SKIP_ENV_VALIDATION=1 -# Install pnpm and generate Prisma client before build -RUN npm install -g pnpm && \ +# Generate Prisma client before build +RUN corepack enable && corepack prepare pnpm@10.20.0 --activate && \ pnpm prisma generate && \ pnpm run build # Production image, copy all the files and run next -FROM node:current-alpine AS runner +FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production @@ -49,7 +48,6 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Install runtime dependencies including OpenSSL for Prisma -RUN sed -i 's/https/http/' /etc/apk/repositories RUN apk add --no-cache \ curl \ ca-certificates \ diff --git a/README.md b/README.md index 675f3e4..747ae99 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,84 @@ # Fulling -
- Fulling +Fulling is building dedicated AI workspaces: persistent environments that combine +skills, files, memory, scripts, and runtime. The current v3 foundation provides +the identity and Kubernetes credential boundary required for that product model. -

Package AI workspaces once. Share them with people who just need the AI to work.

+## Current Foundation -

- Fulling v3.0 workspace delivery - Fulling v2.0.0 released - Next.js 16 - TypeScript 5 -

+- GitHub-only sign-in through Better Auth +- PostgreSQL-backed users, provider accounts, and sessions +- a protected application workspace entry +- one plaintext kubeconfig per user +- authenticated kubeconfig validation through Kubernetes `SelfSubjectReview` +- a user-scoped Kubernetes client boundary -

- Overview • - Workspace model • - How it works • - Project status • - Local development -

-
+The repository intentionally contains no compatibility layer for the previous +product model or authentication system. -## Overview +Read [docs/architecture.md](./docs/architecture.md) for the target Workspace model. +The user-level kubeconfig in this foundation is not the final Workspace Runtime +ownership model. -Fulling is a product for creating ready-to-use AI workspaces. +## Requirements -It is built around a practical problem: many professionals can benefit from AI, -but they do not want to configure skills, prompts, memory, scripts, integrations, -or runtime settings. Fulling gives creators a place to assemble those pieces, -then share the finished workspace with the person who needs it. - -> [!NOTE] -> Fulling v3.0 centers on workspace delivery: the setup work happens once, and -> the recipient opens a prepared AI workspace instead of learning the AI stack. - -## Why Fulling? - -Most AI tools start with an empty chat box. That works for people who already -know how to prompt, configure tools, and judge the outputs. It breaks down when -the user is a domain expert who simply needs help with real work. - -Fulling turns that setup into a deliverable: - -- a building designer receives a workspace with drawing review workflows, - project references, and report templates -- a consultant receives a workspace with client materials, analysis scripts, - and reusable delivery formats -- a team receives a workspace with shared context, approved tools, and repeatable - operating routines - -The recipient uses the workspace. The creator owns the configuration. - -## Workspace Model - -An AI workspace combines the context, capabilities, and runtime needed for a -specific person or job. - -| Part | Purpose | -| --- | --- | -| Mission | The job this AI workspace exists to help with | -| Knowledge | Files, notes, examples, references, and domain material | -| Memory | Durable context that can evolve over time | -| Skills | Named capabilities the AI can use | -| Scripts | Repeatable actions for work that needs reliability | -| Runtime | A place where tools, code, and automation can run | -| Sharing | A way to hand the configured workspace to another user | - -## How It Works - -```text -Create - -> choose who the workspace is for - -> describe the work it supports - -> add knowledge and files - -> configure skills, scripts, memory, and runtime - -> test the workspace - -> share it with the recipient - -Use - -> open the prepared workspace - -> ask for work through task-focused entry points - -> review outputs, files, and approvals - -> keep using the same workspace as context grows -``` - -## What Fulling Is Not - -Fulling is not a prompt marketplace, generic chatbot builder, Kubernetes UI, or -DevOps control panel. Those pieces can exist behind the scenes, but the product -is organized around prepared AI workspaces. - -## Project Status - -Fulling v3.0 is the active product direction. The current repository is being -realigned around the workspace delivery model described above. - -The previous v2.0.0 release is available at -[Fulling v2.0.0](https://github.com/FullAgent/fulling/releases/tag/v2.0.0). - -Active branches: - -- `v3.0` for the current product direction -- `release/2.0` for v2 maintenance -- `main` is not the target branch for v3 work - -## Tech Stack - -| Area | Stack | -| --- | --- | -| App | Next.js 16 App Router, React 19, TypeScript | -| UI | Tailwind CSS v4, Shadcn/UI, Radix UI | -| Data | Prisma, PostgreSQL | -| Auth | NextAuth v5 | -| Runtime | Kubernetes, KubeBlocks PostgreSQL, ttyd, FileBrowser | -| Integrations | GitHub App, sandbox-side commands, AI proxy | -| Testing | Vitest, ESLint | +- Node.js 22.12 or later +- pnpm 10.20.0 +- PostgreSQL +- a GitHub OAuth App -## Repository Map +Configure the GitHub OAuth callback as: ```text -app/ Next.js routes, dashboard, auth, API endpoints -components/ Shared UI and layout components -lib/ Application libraries and service code -prisma/ Database schema and migrations -runtime/ Runtime image and sandbox support files -public/ Static icons and assets +${BETTER_AUTH_URL}/api/auth/callback/github ``` ## Local Development -### Prerequisites - -- Node.js 22.12.0 or later -- pnpm 10.20.0 -- PostgreSQL database -- Kubernetes cluster with KubeBlocks installed -- GitHub App and OAuth credentials for GitHub integration work - -### Setup - ```bash -git clone https://github.com/FullAgent/fulling.git -cd fulling -pnpm install +corepack pnpm install cp .env.template .env.local -npx prisma generate -npx prisma db push -pnpm dev +# Fill in the database, Better Auth, and GitHub values. +corepack pnpm prisma:migrate +corepack pnpm dev ``` Open [http://localhost:3000](http://localhost:3000). -## Useful Commands +This release uses a new baseline schema. Run it against a new database or an +explicitly reset database; it does not migrate v2 data. + +## Commands ```bash -pnpm dev # Start the development server -pnpm build # Generate Prisma client and build for production -pnpm lint # Run ESLint -pnpm test # Run Vitest -pnpm test:watch # Run Vitest in watch mode -npx prisma generate # Generate Prisma client -npx prisma db push # Push schema changes to the database +corepack pnpm dev # Start the development server +corepack pnpm build # Generate Prisma client and build +corepack pnpm lint # Run ESLint +corepack pnpm test # Run Vitest +corepack pnpm test:e2e # Run Playwright +corepack pnpm prisma:format # Format the Prisma schema +corepack pnpm prisma:validate # Validate the Prisma schema +corepack pnpm prisma:migrate # Deploy the baseline migration ``` -## Documentation +## Credential Boundary + +Kubeconfigs are stored as plaintext in PostgreSQL. Database read access grants +access to users' Kubernetes credentials. Browser-facing APIs never return saved +content, and logs must not contain tokens, keys, certificates, or kubeconfig +content. + +Validation rejects executable credential plugins, auth-provider plugins, local +file credential fields, proxy configuration, non-HTTPS API servers, redirects, +and anonymous identities. Authenticated users may still configure an HTTPS API +server on any network address. This authenticated outbound-request/SSRF boundary +is an explicit deployment decision. + +## Deployment Reset + +Before replacing a v2 deployment, follow +[docs/v2-resource-inventory.md](./docs/v2-resource-inventory.md). Resetting the +Fulling database does not delete Kubernetes resources created by v2. -- [AGENTS.md](./AGENTS.md) - agent guidance for this repository -- [docs/architecture.md](./docs/architecture.md) - v3 system architecture +Use [docs/github-oauth-verification.md](./docs/github-oauth-verification.md) to +verify a real OAuth application before release. diff --git a/app/(auth)/auth-error/page.tsx b/app/(auth)/auth-error/page.tsx deleted file mode 100644 index 08b90b9..0000000 --- a/app/(auth)/auth-error/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -'use client'; - -import { Suspense } from 'react'; -import { MdArrowBack, MdErrorOutline, MdHome, MdRefresh } from 'react-icons/md'; -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; - -import { Button } from '@/components/ui/button'; - -function ErrorContent() { - const searchParams = useSearchParams(); - const error = searchParams.get('error'); - - const getErrorDetails = () => { - switch (error) { - case 'Configuration': - return { - title: 'Configuration Error', - message: 'There was a problem with the authentication configuration.', - code: 'AUTH_CONFIG_ERROR', - }; - case 'AccessDenied': - return { - title: 'Access Denied', - message: 'You do not have permission to sign in.', - code: 'AUTH_ACCESS_DENIED', - }; - case 'Verification': - return { - title: 'Verification Failed', - message: 'The verification token has expired or has already been used.', - code: 'AUTH_VERIFICATION_FAILED', - }; - default: - return { - title: 'Authentication Error', - message: 'An unexpected error occurred during authentication.', - code: 'AUTH_UNKNOWN_ERROR', - }; - } - }; - - const errorDetails = getErrorDetails(); - - return ( -
- {/* Error Header - VSCode style */} -
-
- -
-
-

{errorDetails.title}

-

{errorDetails.message}

-
-
- - {/* Error Code Box - VSCode terminal style */} -
-
- - Error Code -
-
{errorDetails.code}
-
- - {/* Actions - VSCode button style */} -
- - - - - - - - - -
- - {/* Help Text */} -
-

- If this problem persists, please contact support or check your authentication - configuration. -

-
-
- ); -} - -export default function ErrorPage() { - return ( -
- -
-
- -
-
-
-
-
-
-
- } - > - - - - ); -} diff --git a/app/(auth)/login/_components/github-login-button.tsx b/app/(auth)/login/_components/github-login-button.tsx new file mode 100644 index 0000000..473f522 --- /dev/null +++ b/app/(auth)/login/_components/github-login-button.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useState } from 'react' +import { Github, LoaderCircle } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { signIn } from '@/lib/auth-client' + +export function GitHubLoginButton() { + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + async function handleSignIn() { + setIsPending(true) + setError(null) + const result = await signIn.social({ + provider: 'github', + callbackURL: '/workspace', + errorCallbackURL: '/login?error=oauth', + }) + if (result?.error) { + setError('GitHub sign-in could not be started. Please try again.') + setIsPending(false) + } + } + + return ( +
+ + {error ?

{error}

: null} +
+ ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index f4776c2..c865040 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,132 +1,44 @@ -'use client'; +import Link from 'next/link' +import { redirect } from 'next/navigation' -import { useState } from 'react'; -import { FaGithub } from 'react-icons/fa'; -import { MdPerson } from 'react-icons/md'; -import { useRouter } from 'next/navigation'; -import { signIn } from 'next-auth/react'; +import { getSession } from '@/lib/auth/session' -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { GitHubLoginButton } from './_components/github-login-button' -export default function LoginPage() { - const router = useRouter(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [isLoading, setIsLoading] = useState(false); - - const handleCredentialsLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(''); +type LoginPageProps = { + searchParams: Promise<{ error?: string }> +} - try { - const result = await signIn('credentials', { - username, - password, - redirect: false, - callbackUrl: '/projects', - }); +export default async function LoginPage({ searchParams }: LoginPageProps) { + if (await getSession()) redirect('/workspace') - if (result?.error) { - // Show generic error message for security - setError('Invalid username or password'); - } else if (result?.ok) { - // Login successful - redirect to projects - router.push('/projects'); - router.refresh(); - } - } catch (err) { - console.error('Login error:', err); - setError('An error occurred. Please try again.'); - } finally { - setIsLoading(false); - } - }; + const { error } = await searchParams return ( -
- - - Welcome to Fulling - - You're one click away from creating your own full-stack app. - - - -
-
- - setUsername(e.target.value)} - required - className="bg-secondary border-input text-foreground" - /> -
- -
- - setPassword(e.target.value)} - required - minLength={6} - className="bg-secondary border-input text-foreground" - /> -
- - {error &&

{error}

} - - - -

- Don't have an account? Just enter your credentials and we'll create - one for you. -

-
- -
-
- -
-
- Or -
-
- - -
-
-
- ); + Fulling + +

+ Sign in to your workspace +

+

Use your GitHub account to continue.

+ {error === 'oauth' ? ( +

+ GitHub sign-in could not be completed. Please try again. +

+ ) : null} + + + +
+
+
+ + ) } diff --git a/app/(dashboard)/_components/dashboard-header.tsx b/app/(dashboard)/_components/dashboard-header.tsx new file mode 100644 index 0000000..cfc9b48 --- /dev/null +++ b/app/(dashboard)/_components/dashboard-header.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useState } from 'react' +import { LogOut, Settings2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { signOut } from '@/lib/auth-client' + +type DashboardHeaderProps = { userName: string; userEmail: string; userImage: string | null } + +export function DashboardHeader({ userName, userEmail, userImage }: DashboardHeaderProps) { + const router = useRouter() + const [isSigningOut, setIsSigningOut] = useState(false) + + async function handleSignOut() { + setIsSigningOut(true) + const result = await signOut() + + if (result.error) { + setIsSigningOut(false) + return + } + + router.push('/login') + router.refresh() + } + + return ( +
+
+ Fulling + +
+ {userName.slice(0, 1).toUpperCase()} +

{userName}

{userEmail}

+ +
+
+ +
+ ) +} diff --git a/app/(dashboard)/_components/kubeconfig-status.test.ts b/app/(dashboard)/_components/kubeconfig-status.test.ts new file mode 100644 index 0000000..8991ff1 --- /dev/null +++ b/app/(dashboard)/_components/kubeconfig-status.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { formatUpdatedAt, parseKubeconfigStatus } from './kubeconfig-status' + +describe('parseKubeconfigStatus', () => { + it('keeps the public status fields', () => { + expect( + parseKubeconfigStatus({ configured: true, updatedAt: '2026-07-13T10:30:00.000Z' }), + ).toEqual({ configured: true, updatedAt: '2026-07-13T10:30:00.000Z' }) + }) + + it('rejects malformed responses', () => { + expect(() => parseKubeconfigStatus(null)).toThrow('Invalid kubeconfig status response.') + expect(() => parseKubeconfigStatus({ configured: 'yes', updatedAt: 123 })).toThrow( + 'Invalid kubeconfig status response.' + ) + }) +}) + +describe('formatUpdatedAt', () => { + it('does not render invalid dates', () => { + expect(formatUpdatedAt(null)).toBe('Not available') + expect(formatUpdatedAt('not-a-date')).toBe('Not available') + }) +}) diff --git a/app/(dashboard)/_components/kubeconfig-status.ts b/app/(dashboard)/_components/kubeconfig-status.ts new file mode 100644 index 0000000..1f3ff34 --- /dev/null +++ b/app/(dashboard)/_components/kubeconfig-status.ts @@ -0,0 +1,34 @@ +export type KubeconfigStatus = { + configured: boolean + updatedAt: string | null +} + +export function parseKubeconfigStatus(value: unknown): KubeconfigStatus { + if (!value || typeof value !== 'object') { + throw new Error('Invalid kubeconfig status response.') + } + + const { configured, updatedAt } = value as Record + if ( + typeof configured !== 'boolean' || + (updatedAt !== null && typeof updatedAt !== 'string') + ) { + throw new Error('Invalid kubeconfig status response.') + } + + return { configured, updatedAt } +} + +export function formatUpdatedAt(updatedAt: string | null): string { + if (!updatedAt) { + return 'Not available' + } + + const date = new Date(updatedAt) + return Number.isNaN(date.valueOf()) + ? 'Not available' + : new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..5ab3c22 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,14 @@ +import { requireSession } from '@/lib/auth/session' + +import { DashboardHeader } from './_components/dashboard-header' + +export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) { + const { user } = await requireSession() + + return ( +
+ + {children} +
+ ) +} diff --git a/app/(dashboard)/projects/(list)/_components/create-project-card.tsx b/app/(dashboard)/projects/(list)/_components/create-project-card.tsx deleted file mode 100644 index 6c6a3f5..0000000 --- a/app/(dashboard)/projects/(list)/_components/create-project-card.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import { useState } from 'react' -import { MdAdd } from 'react-icons/md' - -import { cn } from '@/lib/utils' - -import CreateProjectDialog from '../../_components/create-project-dialog' - -export function CreateProjectCard() { - const [isDialogOpen, setIsDialogOpen] = useState(false) - - return ( - <> - - - - - ) -} diff --git a/app/(dashboard)/projects/(list)/_components/home-page-content.tsx b/app/(dashboard)/projects/(list)/_components/home-page-content.tsx deleted file mode 100644 index 40501f6..0000000 --- a/app/(dashboard)/projects/(list)/_components/home-page-content.tsx +++ /dev/null @@ -1,111 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { toast } from 'sonner' - -import { getInstallations } from '@/lib/actions/github' -import type { ProjectWithRelations } from '@/lib/data/project' -import { env } from '@/lib/env' -import type { ProjectDisplayStatus } from '@/lib/util/project-display-status' - -import { PageHeaderWithFilter } from './page-header-with-filter' -import { ProjectList } from './project-list' - -const REFRESH_INTERVAL_MS = 3000 - -interface HomePageContentProps { - projects: ProjectWithRelations<{ sandboxes: true; tasks: true }>[] -} - -export function HomePageContent({ projects }: HomePageContentProps) { - const router = useRouter() - const searchParams = useSearchParams() - const [activeFilter, setActiveFilter] = useState<'ALL' | ProjectDisplayStatus>('ALL') - const [hasTriggeredInstall, setHasTriggeredInstall] = useState(false) - - useEffect(() => { - const interval = setInterval(() => { - router.refresh() - }, REFRESH_INTERVAL_MS) - - return () => clearInterval(interval) - }, [router]) - - useEffect(() => { - if (hasTriggeredInstall) return - - const shouldTriggerInstall = searchParams.get('github_install') === 'true' - if (!shouldTriggerInstall) return - - const triggerGitHubAppInstall = async () => { - setHasTriggeredInstall(true) - - try { - const result = await getInstallations() - if (result.success && result.data.length > 0) { - router.replace('/projects') - return - } - - const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME - if (!appName) { - toast.error('GitHub App is not configured') - router.replace('/projects') - return - } - - const installUrl = `https://github.com/apps/${appName}/installations/new` - - const width = 800 - const height = 800 - const left = window.screen.width / 2 - width / 2 - const top = window.screen.height / 2 - height / 2 - - const popup = window.open( - installUrl, - 'github-app-install', - `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` - ) - - if (!popup) { - toast.error('Failed to open popup window. Please allow popups for this site.') - router.replace('/projects') - return - } - - const handleMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return - if (event.data.type !== 'github-app-installed') return - - window.removeEventListener('message', handleMessage) - toast.success('GitHub App installed successfully!') - router.replace('/projects') - router.refresh() - } - - window.addEventListener('message', handleMessage) - - const checkClosed = setInterval(() => { - if (popup.closed) { - clearInterval(checkClosed) - window.removeEventListener('message', handleMessage) - router.replace('/projects') - } - }, 500) - } catch (error) { - console.error('Failed to trigger GitHub App install:', error) - router.replace('/projects') - } - } - - triggerGitHubAppInstall() - }, [searchParams, hasTriggeredInstall, router]) - - return ( - <> - - - - ) -} diff --git a/app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx b/app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx deleted file mode 100644 index 37c781f..0000000 --- a/app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client' - -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import type { ProjectDisplayStatus } from '@/lib/util/project-display-status' -import { cn } from '@/lib/utils' - -type FilterStatus = 'ALL' | ProjectDisplayStatus - -interface PageHeaderWithFilterProps { - activeFilter: FilterStatus - onFilterChange: (filter: FilterStatus) => void -} - -const filters: { label: string; value: FilterStatus }[] = [ - { label: 'All', value: 'ALL' }, - { label: 'Running', value: 'RUNNING' }, - { label: 'Importing', value: 'IMPORTING' }, - { label: 'Stopped', value: 'STOPPED' }, - { label: 'Needs Attention', value: 'NEEDS_ATTENTION' }, -] - -export function PageHeaderWithFilter({ activeFilter, onFilterChange }: PageHeaderWithFilterProps) { - return ( -
-
-

- My Projects -

-

- Manage and monitor your full stack project workspaces. -

-
- value && onFilterChange(value as FilterStatus)} - className="flex bg-sidebar p-1 rounded-lg border border-border" - spacing={1} - > - {filters.map((filter) => ( - - {filter.label} - - ))} - -
- ) -} diff --git a/app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx b/app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx deleted file mode 100644 index 23adbec..0000000 --- a/app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx +++ /dev/null @@ -1,206 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { MdDeleteOutline, MdMoreHoriz, MdPause, MdPlayArrow, MdRefresh, MdSettings } from 'react-icons/md'; -import { ProjectStatus } from '@prisma/client'; -import { useRouter } from 'next/navigation'; - -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - FullScreenDialog, - FullScreenDialogAction, - FullScreenDialogClose, - FullScreenDialogContent, - FullScreenDialogDescription, - FullScreenDialogFooter, - FullScreenDialogHeader, - FullScreenDialogTitle, -} from '@/components/ui/fullscreen-dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useProjectOperations } from '@/hooks/use-project-operations'; - -interface ProjectActionsMenuProps { - projectId: string; - projectName: string; - status: ProjectStatus; -} - -export function ProjectActionsMenu({ projectId, projectName, status }: ProjectActionsMenuProps) { - const router = useRouter(); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [confirmInput, setConfirmInput] = useState(''); - const { executeOperation, loading } = useProjectOperations(projectId); - - // Determine available actions based on status - const showStart = status === 'STOPPED'; - const showStop = status !== 'STOPPED'; - - // Check if the confirmation input matches the project name - const isConfirmValid = confirmInput === projectName; - - const handleDeleteClick = () => { - setShowDeleteDialog(true); - }; - - const handleDeleteConfirm = () => { - if (!isConfirmValid) return; - setShowDeleteDialog(false); - setConfirmInput(''); - executeOperation('DELETE'); - }; - - const handleDialogOpenChange = (open: boolean) => { - setShowDeleteDialog(open); - if (!open) { - setConfirmInput(''); - } - }; - - const handleSettingsClick = () => { - router.push(`/projects/${projectId}/environment`); - }; - - return ( - <> - - - - - - {/* Start/Stop based on status */} - {showStart && ( - { - e.stopPropagation(); - executeOperation('START'); - }} - disabled={loading !== null} - className="gap-3 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5" - > - {loading === 'START' ? ( - <> - - Starting... - - ) : ( - <> - - Start - - )} - - )} - {showStop && ( - { - e.stopPropagation(); - executeOperation('STOP'); - }} - disabled={loading !== null} - className="gap-3 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5" - > - {loading === 'STOP' ? ( - <> - - Stopping... - - ) : ( - <> - - Stop - - )} - - )} - - {/* Settings */} - { - e.stopPropagation(); - handleSettingsClick(); - }} - className="gap-3 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5" - > - - Settings - - - - - {/* Delete */} - { - e.stopPropagation(); - handleDeleteClick(); - }} - disabled={loading !== null} - className="gap-3 px-3 py-2 text-xs font-medium text-red-500 hover:text-red-400 hover:bg-red-500/10" - > - - Delete - - - - - {/* Delete Confirmation Dialog */} - - - - - Are you sure you want to delete
- "{projectName}"? -
- - This will terminate all resources (databases, sandboxes) and cannot be undone. - -
- - {/* Confirmation Input */} -
- - setConfirmInput(e.target.value)} - placeholder={projectName} - className="bg-background border-border rounded-xl px-4 py-3 text-white placeholder:text-muted-foreground/30 focus-visible:border-red-500 focus-visible:ring-red-500/50 font-mono text-sm shadow-inner" - /> -
- - - - Cancel - - - Permanently Delete - - -
-
- - ); -} diff --git a/app/(dashboard)/projects/(list)/_components/project-card.tsx b/app/(dashboard)/projects/(list)/_components/project-card.tsx deleted file mode 100644 index 4e347cc..0000000 --- a/app/(dashboard)/projects/(list)/_components/project-card.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { MdOpenInNew } from 'react-icons/md' -import { ProjectStatus } from '@prisma/client' -import Link from 'next/link' - -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader } from '@/components/ui/card' -import type { ProjectDisplayStatus } from '@/lib/util/project-display-status' -import { cn } from '@/lib/utils' - -import { ProjectActionsMenu } from './project-actions-menu' -import { statusConfig } from './status-config' - -interface ProjectCardProps { - id: string - name: string - description: string - status: ProjectStatus - displayStatus: ProjectDisplayStatus - updatedAt: string - publicUrl?: string | null -} - - -export function ProjectCard({ - id, - name, - description, - status, - displayStatus, - updatedAt, - publicUrl, -}: ProjectCardProps) { - const config = statusConfig[displayStatus] - const initial = name.charAt(0).toUpperCase() - - // Handle open project button - open sandbox publicUrl in new tab - const handleOpenProject = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - if (publicUrl) { - window.open(publicUrl, '_blank', 'noopener,noreferrer') - } - } - - return ( - - - {/* Card Header */} - - {/* More dropdown */} - - - {/* Initial Avatar */} -
- {initial} -
-
- - {/* Card Content */} - -
-

- {name} -

-
- -

- {description} -

- - {/* Card Footer */} -
-
- {/* Status indicator */} -
- {displayStatus === 'RUNNING' && ( - - )} - -
- - {config.label} - - • {updatedAt} -
- - {/* Open button */} - -
-
-
- - ) -} diff --git a/app/(dashboard)/projects/(list)/_components/project-list.tsx b/app/(dashboard)/projects/(list)/_components/project-list.tsx deleted file mode 100644 index 82d95dd..0000000 --- a/app/(dashboard)/projects/(list)/_components/project-list.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { ProjectWithRelations } from '@/lib/data/project' -import { formatRelativeTime } from '@/lib/util/format-time' -import { - getProjectDisplayStatus, - type ProjectDisplayStatus, -} from '@/lib/util/project-display-status' - -import { CreateProjectCard } from './create-project-card' -import { ProjectCard } from './project-card' - -interface ProjectListProps { - projects: ProjectWithRelations<{ sandboxes: true; tasks: true }>[] - activeFilter: 'ALL' | ProjectDisplayStatus -} - -export function ProjectList({ projects, activeFilter }: ProjectListProps) { - // Map to frontend format with sandbox publicUrl - const mappedProjects = projects.map((p) => ({ - id: p.id, - name: p.name, - description: p.description || 'No description', - status: p.status, - displayStatus: getProjectDisplayStatus(p), - updatedAt: formatRelativeTime(p.updatedAt), - publicUrl: p.sandboxes?.[0]?.publicUrl, - })) - - const filteredProjects = - activeFilter === 'ALL' - ? mappedProjects - : mappedProjects.filter((p) => p.displayStatus === activeFilter) - - return ( -
- {filteredProjects.map((project) => ( - - ))} - -
- ) -} diff --git a/app/(dashboard)/projects/(list)/_components/status-config.ts b/app/(dashboard)/projects/(list)/_components/status-config.ts deleted file mode 100644 index 0cfe4c4..0000000 --- a/app/(dashboard)/projects/(list)/_components/status-config.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ProjectDisplayStatus } from '@/lib/util/project-display-status' - -export interface StatusConfigItem { - color: string - bg: string - label: string - animate?: string -} - -export const statusConfig: Record = { - // Stable states - RUNNING: { - color: 'text-emerald-500', - bg: 'bg-emerald-500', - label: 'Running', - animate: 'animate-pulse', - }, - STOPPED: { - color: 'text-gray-500', - bg: 'bg-gray-500', - label: 'Stopped', - }, - // Transition states - CREATING: { - color: 'text-yellow-500', - bg: 'bg-yellow-500', - label: 'Creating', - animate: 'animate-pulse', - }, - IMPORTING: { - color: 'text-sky-400', - bg: 'bg-sky-400', - label: 'Importing', - animate: 'animate-pulse', - }, - UPDATING: { - color: 'text-blue-500', - bg: 'bg-blue-500', - label: 'Updating', - animate: 'animate-pulse', - }, - STARTING: { - color: 'text-cyan-500', - bg: 'bg-cyan-500', - label: 'Starting', - animate: 'animate-pulse', - }, - STOPPING: { - color: 'text-orange-500', - bg: 'bg-orange-500', - label: 'Stopping', - animate: 'animate-pulse', - }, - TERMINATING: { - color: 'text-red-400', - bg: 'bg-red-400', - label: 'Terminating', - animate: 'animate-pulse', - }, - // Special states - ERROR: { - color: 'text-red-500', - bg: 'bg-red-500', - label: 'Error', - }, - NEEDS_ATTENTION: { - color: 'text-amber-400', - bg: 'bg-amber-400', - label: 'Needs Attention', - }, -} diff --git a/app/(dashboard)/projects/(list)/layout.tsx b/app/(dashboard)/projects/(list)/layout.tsx deleted file mode 100644 index 826509b..0000000 --- a/app/(dashboard)/projects/(list)/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Sidebar } from '@/components/sidebar' - -import { SearchBar } from '../_components/search-bar' - -export default function HomeLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( -
- -
- - {children} -
-
- ) -} diff --git a/app/(dashboard)/projects/(list)/page.tsx b/app/(dashboard)/projects/(list)/page.tsx deleted file mode 100644 index 7764b48..0000000 --- a/app/(dashboard)/projects/(list)/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect } from 'next/navigation' - -import { auth } from '@/lib/auth' -import { getProjects } from '@/lib/data/project' - -import { HomePageContent } from './_components/home-page-content' - -export const metadata = { - title: 'My Projects | Fulling', - description: 'Manage and monitor your full stack project workspaces.', -} - -export default async function HomePage() { - const session = await auth() - - if (!session) { - redirect('/login') - } - - const projects = await getProjects(session.user.id, { sandboxes: true, tasks: true }) - - return ( -
-
- -
-
- ) -} diff --git a/app/(dashboard)/projects/[id]/_components/settings-layout.tsx b/app/(dashboard)/projects/[id]/_components/settings-layout.tsx deleted file mode 100644 index aca701c..0000000 --- a/app/(dashboard)/projects/[id]/_components/settings-layout.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Settings page layout component - * Used for project settings pages (database, environments, etc.) - * VSCode Dark Modern style with clean design - */ - -'use client'; - -import type { ReactNode } from 'react'; - -import { Skeleton } from '@/components/ui/skeleton'; - -interface SettingsLayoutProps { - title: string; - description: string; - children: ReactNode; - loading?: boolean; -} - -/** - * Layout wrapper for project settings pages - * Uses skeleton to maintain layout stability during loading - */ -export function SettingsLayout({ title, description, children, loading }: SettingsLayoutProps) { - return ( -
-
-
-

{title}

-

{description}

-
- -
- {loading ? ( - <> - - - - - ) : ( - children - )} -
-
-
- ); -} diff --git a/app/(dashboard)/projects/[id]/auth/page.tsx b/app/(dashboard)/projects/[id]/auth/page.tsx deleted file mode 100644 index 69e2053..0000000 --- a/app/(dashboard)/projects/[id]/auth/page.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Authentication Configuration Page - * Configure OAuth providers and NextAuth settings - */ - -'use client'; - -import { FaGithub } from 'react-icons/fa'; -import { MdOpenInNew, MdVpnKey } from 'react-icons/md'; -import { useParams } from 'next/navigation'; - -import { EnvVarSection } from '@/components/config/env-var-section'; -import { - useBatchUpdateEnvironmentVariables, - useEnvironmentVariables, -} from '@/hooks/use-environment-variables'; -import { useProject } from '@/hooks/use-project'; - -import { SettingsLayout } from '../_components/settings-layout'; - -/** - * Generate a secure random secret - */ -function generateSecret(): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let secret = ''; - for (let i = 0; i < 32; i++) { - secret += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return secret; -} - -function AuthPageContent() { - const params = useParams(); - const projectId = params.id as string; - - const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId); - const { data: project, isLoading: projectLoading } = useProject(projectId); - const batchUpdate = useBatchUpdateEnvironmentVariables(projectId); - - const authVars = envData?.auth || []; - - const handleSave = async ( - variables: Array<{ key: string; value: string; isSecret?: boolean }> - ) => { - await batchUpdate.mutateAsync({ - category: 'auth', - variables, - }); - }; - - // GitHub OAuth templates - const githubTemplates = [ - { - key: 'GITHUB_CLIENT_ID', - label: 'Client ID', - placeholder: 'Enter your GitHub OAuth App Client ID', - isSecret: false, - description: 'Get this from GitHub Developer Settings → OAuth Apps', - }, - { - key: 'GITHUB_CLIENT_SECRET', - label: 'Client Secret', - placeholder: 'Enter your GitHub OAuth App Client Secret', - isSecret: true, - description: 'Keep this secret! Get it from your GitHub OAuth App settings', - }, - ]; - - // NextAuth templates - const nextAuthTemplates = [ - { - key: 'NEXTAUTH_URL', - label: 'Application URL', - placeholder: 'https://your-app.example.com', - isSecret: false, - description: 'The public URL of your application', - }, - { - key: 'NEXTAUTH_SECRET', - label: 'NextAuth Secret', - placeholder: 'Click Generate to create a secure secret', - isSecret: true, - description: 'A random string used to hash tokens and sign cookies (min 32 characters)', - generateValue: generateSecret, - }, - ]; - - return ( - -
- {/* GitHub OAuth Section */} -
-
-
- -

GitHub OAuth

-
- - GitHub Developer Settings - - -
- - - - {/* Setup Instructions */} -
-

Setup Instructions

-
    -
  1. Go to GitHub Settings → Developer settings → OAuth Apps
  2. -
  3. Click "New OAuth App" or select an existing app
  4. -
  5. Set the Homepage URL and Authorization callback URL
  6. -
  7. Copy the Client ID and Client Secret to the fields above
  8. -
  9. Save your changes
  10. -
-
-
- - {/* Divider */} -
- - {/* NextAuth Configuration Section */} -
-
- -

NextAuth Configuration

-
- - - - {/* Important Notes */} -
-

Important Notes

-
    -
  • The NextAuth URL must match your application URL exactly
  • -
  • The secret should be at least 32 characters long
  • -
  • Never commit your NEXTAUTH_SECRET to version control
  • -
  • Use a strong, unique secret for production
  • -
-
-
-
- - ); -} - -export default AuthPageContent; diff --git a/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx b/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx deleted file mode 100644 index e80046f..0000000 --- a/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { createDatabase } from '@/lib/actions/database' - -interface AddDatabaseCardProps { - projectId: string - projectName: string -} - -export function AddDatabaseCard({ projectId }: AddDatabaseCardProps) { - const router = useRouter() - const [isPending, startTransition] = useTransition() - - const handleCreateDatabase = () => { - startTransition(async () => { - const result = await createDatabase(projectId) - - if (!result.success) { - toast.error(result.error || 'Failed to create database') - return - } - - toast.success('Database is being created...') - router.refresh() - }) - } - - return ( - - - No Database - - This project doesn't have a database yet. Add a PostgreSQL database to get started. - - - - -

- A PostgreSQL cluster will be created with 1Gi storage, 100m CPU, and 128Mi memory. -

-
-
- ) -} diff --git a/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx b/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx deleted file mode 100644 index e37ec94..0000000 --- a/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import { - MdCheck, - MdContentCopy, - MdInfo, - MdVisibility, - MdVisibilityOff, -} from 'react-icons/md'; - -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -interface ConnectionStringProps { - connectionString: string; -} - -export function ConnectionString({ connectionString }: ConnectionStringProps) { - const [isVisible, setIsVisible] = useState(false); - const [copied, setCopied] = useState(false); - - // Memoize masked string to avoid recalculation on every render - const displayValue = useMemo(() => { - if (isVisible) return connectionString; - return '•'.repeat(Math.min(connectionString.length, 50)); - }, [connectionString, isVisible]); - - const handleCopy = async () => { - await navigator.clipboard.writeText(connectionString); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* Header with title and badge */} -
-

- Full Connection String -

- - Read-only - -
- - {/* Connection string display */} -
-
- {displayValue} -
- - {/* Action buttons */} -
- {/* Toggle visibility */} - - - {/* Copy button */} - -
-
- - {/* Footer with info */} -

- - Use this connection string in your application to connect to the database. -

-
- ); -} diff --git a/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx b/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx deleted file mode 100644 index 73c6599..0000000 --- a/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { type IconType } from 'react-icons'; -import { MdAutoAwesome, MdHttps, MdStorage, MdTerminal } from 'react-icons/md'; - -// Feature card component -function FeatureCard({ - icon: Icon, - title, - description, -}: { - icon: IconType; - title: string; - description: string; -}) { - return ( -
-
- -
-

{title}

-

{description}

-
- ); -} - -// Feature cards data -const DATABASE_FEATURES = [ - { - icon: MdAutoAwesome, - title: 'Auto Provisioned', - description: 'Database is automatically provisioned and ready to use with your sandbox environment.', - }, - { - icon: MdStorage, - title: 'High Availability', - description: 'Managed by KubeBlocks with high availability and automatic failover.', - }, - { - icon: MdHttps, - title: 'SSL Encrypted', - description: 'SSL encryption enabled by default for secure database connections.', - }, - { - icon: MdTerminal, - title: 'Environment Variable', - description: 'Connection string available via DATABASE_URL environment variable.', - }, -] as const; - -// Export complete component -export function FeatureCards() { - return ( -
- {DATABASE_FEATURES.map(({ icon, title, description }) => ( - - ))} -
- ); -} diff --git a/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx b/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx deleted file mode 100644 index f923c8a..0000000 --- a/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Read-only field component for displaying data - * Mimics input styling for display-only scenarios - */ - -import { useId } from 'react'; - -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -interface ReadOnlyFieldProps { - /** Field label */ - label: string; - /** Field value */ - value: string | number; - /** Full width on mobile (col-span-2) */ - fullWidth?: boolean; -} - -export function ReadOnlyField({ label, value, fullWidth }: ReadOnlyFieldProps) { - const fieldId = useId(); - - return ( -
- - -
- ); -} diff --git a/app/(dashboard)/projects/[id]/database/page.tsx b/app/(dashboard)/projects/[id]/database/page.tsx deleted file mode 100644 index dad82bd..0000000 --- a/app/(dashboard)/projects/[id]/database/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { notFound, redirect } from 'next/navigation'; - -import { auth } from '@/lib/auth'; -import { parseConnectionUrl } from '@/lib/data/database'; -import { getProject } from '@/lib/data/project'; - -import { SettingsLayout } from '../_components/settings-layout'; - -import { AddDatabaseCard } from './_components/add-database-card'; -import { ConnectionString } from './_components/connection-string'; -import { FeatureCards } from './_components/feature-cards'; -import { ReadOnlyField } from './_components/read-only-field'; - -export default async function DatabasePage({ params }: { params: Promise<{ id: string }> }) { - // Parallel: fetch session and params simultaneously - const [session, paramsResolved] = await Promise.all([ - auth(), - params - ]); - - if (!session) redirect('/login'); - - const { id } = paramsResolved; - - const project = await getProject(id, session.user.id, { - databases: true, - }); - - if (!project) notFound(); - - const database = project.databases[0]; - - // If no database exists, show "Add Database" card - if (!database) { - return ( - - - - ); - } - - const connectionString = database.connectionUrl || ''; - const connectionInfo = parseConnectionUrl(connectionString) || { - host: '', port: '', database: '', username: '', password: '' - }; - - return ( - - {connectionString ? ( - <> -
-

PostgreSQL Connection

- -
- - - - - -
- -
- -
-
- - - - ) : ( -
-

Database is being created...

-

- Connection details will appear once the database is ready -

-
- )} -
- ); -} \ No newline at end of file diff --git a/app/(dashboard)/projects/[id]/environment/page.tsx b/app/(dashboard)/projects/[id]/environment/page.tsx deleted file mode 100644 index 2dd5515..0000000 --- a/app/(dashboard)/projects/[id]/environment/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Environment Variables Configuration Page - * Configure custom environment variables - */ - -'use client'; - -import { useParams } from 'next/navigation'; - -import { EnvVarSection } from '@/components/config/env-var-section'; -import { - useBatchUpdateEnvironmentVariables, - useEnvironmentVariables, -} from '@/hooks/use-environment-variables'; -import { useProject } from '@/hooks/use-project'; - -import { SettingsLayout } from '../_components/settings-layout'; - -function EnvironmentPageContent() { - const params = useParams(); - const projectId = params.id as string; - - const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId); - const { data: project, isLoading: projectLoading } = useProject(projectId); - const batchUpdate = useBatchUpdateEnvironmentVariables(projectId); - - const generalVars = envData?.general || []; - - const handleSave = async ( - variables: Array<{ key: string; value: string; isSecret?: boolean }> - ) => { - await batchUpdate.mutateAsync({ - category: 'general', - variables, - }); - }; - - return ( - -
- {/* Environment Variables Section */} - - - {/* Usage Information */} -
-

Environment Variable Usage

-
    -
  • Environment variables are available in your application via process.env
  • -
  • Changes require an application restart to take effect
  • -
  • For authentication providers, use the Authentication page
  • -
  • For payment providers, use the Payment page
  • -
  • For sensitive data like API keys, use the Secrets page
  • -
-
-
-
- ); -} - -export default EnvironmentPageContent; diff --git a/app/(dashboard)/projects/[id]/exec-test/client.tsx b/app/(dashboard)/projects/[id]/exec-test/client.tsx deleted file mode 100644 index def9039..0000000 --- a/app/(dashboard)/projects/[id]/exec-test/client.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client' - -import { TtydExecTest } from '@/components/terminal/ttyd-exec-test' - -interface TtydExecTestClientProps { - ttydUrl: string - accessToken: string -} - -export function TtydExecTestClient({ ttydUrl, accessToken }: TtydExecTestClientProps) { - return -} \ No newline at end of file diff --git a/app/(dashboard)/projects/[id]/exec-test/page.tsx b/app/(dashboard)/projects/[id]/exec-test/page.tsx deleted file mode 100644 index a3c47f5..0000000 --- a/app/(dashboard)/projects/[id]/exec-test/page.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Terminal Exec Test Page - * - * Test page for verifying the ttyd-exec utility works in the browser. - * Accessible at: /projects/[id]/exec-test - */ - -import { notFound,redirect } from 'next/navigation' - -import { auth } from '@/lib/auth' -import { prisma } from '@/lib/db' - -import { TtydExecTestClient } from './client' - -export default async function ExecTestPage({ - params, -}: { - params: Promise<{ id: string }> -}) { - const session = await auth() - - if (!session) { - redirect('/login') - } - - const { id } = await params - - // Get project with sandbox - const project = await prisma.project.findFirst({ - where: { - id: id, - userId: session.user.id, - }, - include: { - sandboxes: true, - environments: true, - }, - }) - - if (!project) { - notFound() - } - - const sandbox = project.sandboxes[0] - - // Get TTYD access token from environments - const ttydAccessToken = project.environments.find( - (env) => env.key === 'TTYD_ACCESS_TOKEN' - )?.value - - if (!sandbox?.ttydUrl || !ttydAccessToken) { - return ( -
-
-

Configuration Missing

-
-

- ttydUrl: {sandbox?.ttydUrl || 'Not available'} -

-

- accessToken: {ttydAccessToken ? 'Configured' : 'Not configured'} -

-

- Sandbox Status: {sandbox?.status || 'No sandbox'} -

-
-

- Make sure the sandbox is RUNNING and TTYD_ACCESS_TOKEN is set in environments. -

-
-
- ) - } - - // Parse the ttydUrl to get base URL (without query params) - const ttydBaseUrl = new URL(sandbox.ttydUrl) - ttydBaseUrl.search = '' // Remove query params - const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '') - - return ( -
- -
- ) -} \ No newline at end of file diff --git a/app/(dashboard)/projects/[id]/github/page.tsx b/app/(dashboard)/projects/[id]/github/page.tsx deleted file mode 100644 index 6f9fe88..0000000 --- a/app/(dashboard)/projects/[id]/github/page.tsx +++ /dev/null @@ -1,192 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { FaGithub } from 'react-icons/fa'; -import { MdOpenInNew, MdRefresh } from 'react-icons/md'; -import { useParams, useRouter } from 'next/navigation'; -import { toast } from 'sonner'; - -import SettingsDialog from '@/components/dialog/settings-dialog'; -import { Button } from '@/components/ui/button'; -import { useProject } from '@/hooks/use-project'; -import { commitChanges, initializeRepo } from '@/lib/services/repoService'; - -import { SettingsLayout } from '../_components/settings-layout'; - -export default function GithubPage() { - const params = useParams(); - const projectId = params.id as string; - const router = useRouter(); - - const { data: project, isLoading: projectLoading } = useProject(projectId); - - const [isInitializing, setIsInitializing] = useState(false); - const [isCommitting, setIsCommitting] = useState(false); - const [showSettings, setShowSettings] = useState(false); - - const repoFullName = project?.githubRepoFullName || project?.githubRepo; - const hasRepo = Boolean(repoFullName); - const repoUrl = repoFullName - ? `https://github.com/${repoFullName}` - : null; - - // Create a new repository on GitHub - const handleInitialize = async () => { - if (hasRepo || isInitializing) return; - - setIsInitializing(true); - try { - const result = await initializeRepo(projectId); - if (result.success) { - toast.success(result.message); - router.refresh(); - } else { - if (result.code === 'GITHUB_NOT_BOUND') { - toast.error('Please connect your GitHub account first'); - setShowSettings(true); - } else { - toast.error(result.message); - } - } - } catch (_error) { - toast.error('An unexpected error occurred'); - } finally { - setIsInitializing(false); - } - }; - - // Commit changes to the repository and push to GitHub - const handleCommit = async () => { - if (isCommitting) return; - - setIsCommitting(true); - try { - const result = await commitChanges(projectId); - if (result.success) { - toast.success(result.message); - } else { - if (result.code === 'GITHUB_NOT_BOUND') { - toast.error('Please connect your GitHub account first'); - setShowSettings(true); - } else { - toast.error(result.message); - } - } - } catch (_error) { - toast.error('Failed to commit changes'); - } finally { - setIsCommitting(false); - } - }; - - return ( - -
- {/* Connection Status Section */} -
- {/* Visual Header */} -
-
- -
-
-

- {hasRepo ? 'Connected to GitHub' : 'GitHub Repository'} -

-

- {hasRepo - ? 'Your project is currently active and synced with a remote GitHub repository. You can push your latest changes below.' - : 'Initialize a new repository to start tracking changes. This will create a private repository in your GitHub account and push the initial code.'} -

-
-
- - {/* Actions */} -
- {hasRepo ? ( -
-
- Repository URL - - {repoFullName} - - -
- -
- - -
-
- ) : ( - - )} -
-
- - {/* Global Settings Link */} -
-
-

GitHub Account Settings

-

Manage your global GitHub connection and personal access tokens.

-
- -
- - -
-
- ); -} diff --git a/app/(dashboard)/projects/[id]/layout.tsx b/app/(dashboard)/projects/[id]/layout.tsx deleted file mode 100644 index 855bbd7..0000000 --- a/app/(dashboard)/projects/[id]/layout.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { redirect } from 'next/navigation'; -import { notFound } from 'next/navigation'; - -import { ProjectContentWrapper } from '@/components/layout/project-content-wrapper'; -import PrimarySidebar from '@/components/sidebars/primary-sidebar'; -import ProjectSidebar from '@/components/sidebars/project-sidebar'; -import { auth } from '@/lib/auth'; -import { prisma } from '@/lib/db'; - -export default async function ProjectLayout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ id: string }>; -}) { - const session = await auth(); - - if (!session) { - redirect('/login'); - } - - const { id } = await params; - - // Only need to check if project exists and belongs to user - // All components fetch their own data via useProject hook - const project = await prisma.project.findFirst({ - where: { - id: id, - userId: session.user.id, - }, - select: { - id: true, - }, - }); - - if (!project) { - notFound(); - } - - - return ( -
-
- {/* Primary Sidebar - VSCode style */} - - - {/* Secondary Sidebar - Project Settings */} - - - {/* Main Content Area */} -
- - {children} - -
-
-
- ); -} diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx deleted file mode 100644 index 26a535d..0000000 --- a/app/(dashboard)/projects/[id]/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - - // Redirect to terminal page (main view) - redirect(`/projects/${id}/terminal`); -} diff --git a/app/(dashboard)/projects/[id]/payment/page.tsx b/app/(dashboard)/projects/[id]/payment/page.tsx deleted file mode 100644 index 94e91eb..0000000 --- a/app/(dashboard)/projects/[id]/payment/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Payment Configuration Page - * Configure payment providers (Stripe, PayPal) - */ - -'use client'; - -import { MdOpenInNew } from 'react-icons/md'; -import { useParams } from 'next/navigation'; - -import { EnvVarSection } from '@/components/config/env-var-section'; -import { - useBatchUpdateEnvironmentVariables, - useEnvironmentVariables, -} from '@/hooks/use-environment-variables'; -import { useProject } from '@/hooks/use-project'; - -import { SettingsLayout } from '../_components/settings-layout'; - -function PaymentPageContent() { - const params = useParams(); - const projectId = params.id as string; - - const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId); - const { data: project, isLoading: projectLoading } = useProject(projectId); - const batchUpdate = useBatchUpdateEnvironmentVariables(projectId); - - const paymentVars = envData?.payment || []; - - const handleSave = async ( - variables: Array<{ key: string; value: string; isSecret?: boolean }> - ) => { - await batchUpdate.mutateAsync({ - category: 'payment', - variables, - }); - }; - - // Stripe templates - const stripeTemplates = [ - { - key: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', - label: 'Publishable Key', - placeholder: 'pk_test_...', - isSecret: false, - description: 'Public key used in client-side code (starts with pk_)', - }, - { - key: 'STRIPE_SECRET_KEY', - label: 'Secret Key', - placeholder: 'sk_test_...', - isSecret: true, - description: 'Secret key for server-side operations (starts with sk_)', - }, - { - key: 'STRIPE_WEBHOOK_SECRET', - label: 'Webhook Secret', - placeholder: 'whsec_...', - isSecret: true, - description: 'Secret for verifying webhook signatures (starts with whsec_)', - }, - ]; - - // PayPal templates - const paypalTemplates = [ - { - key: 'PAYPAL_CLIENT_ID', - label: 'Client ID', - placeholder: 'Enter PayPal Client ID', - isSecret: false, - description: 'PayPal client identifier for your application', - }, - { - key: 'PAYPAL_CLIENT_SECRET', - label: 'Client Secret', - placeholder: 'Enter PayPal Client Secret', - isSecret: true, - description: 'Secret key for PayPal API authentication', - }, - ]; - - return ( - -
- {/* Stripe Section */} -
-
-
- Stripe -

Stripe

-
- - Stripe Dashboard - - -
- - - - {/* Setup Instructions */} -
-

Setup Instructions

-
    -
  1. Go to Stripe Dashboard → Developers → API keys
  2. -
  3. Copy the Publishable key and Secret key
  4. -
  5. For webhooks: Developers → Webhooks → Add endpoint
  6. -
  7. Configure webhook endpoint and copy the signing secret
  8. -
-
-
- - {/* Divider */} -
- - {/* PayPal Section */} -
-
-
- PayPal -

PayPal

-
- - PayPal Developer - - -
- - - - {/* Setup Instructions */} -
-

Setup Instructions

-
    -
  1. Go to PayPal Developer Dashboard
  2. -
  3. Navigate to My Apps & Credentials
  4. -
  5. Create a new app or select an existing one
  6. -
  7. Copy the Client ID and Secret from the app details
  8. -
-
-
-
- - ); -} - -export default PaymentPageContent; diff --git a/app/(dashboard)/projects/[id]/secrets/page.tsx b/app/(dashboard)/projects/[id]/secrets/page.tsx deleted file mode 100644 index e1ee3b4..0000000 --- a/app/(dashboard)/projects/[id]/secrets/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Secrets Configuration Page - * Manage sensitive environment variables and API keys - */ - -'use client'; - -import { useParams } from 'next/navigation'; - -import { EnvVarSection } from '@/components/config/env-var-section'; -import { - useBatchUpdateEnvironmentVariables, - useEnvironmentVariables, -} from '@/hooks/use-environment-variables'; -import { useProject } from '@/hooks/use-project'; - -import { SettingsLayout } from '../_components/settings-layout'; - -function SecretsPageContent() { - const params = useParams(); - const projectId = params.id as string; - - const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId); - const { data: project, isLoading: projectLoading } = useProject(projectId); - const batchUpdate = useBatchUpdateEnvironmentVariables(projectId); - - const secretVars = envData?.secret || []; - - const handleSave = async ( - variables: Array<{ key: string; value: string; isSecret?: boolean }> - ) => { - await batchUpdate.mutateAsync({ - category: 'secret', - variables: variables.map((v) => ({ ...v, isSecret: true })), - }); - }; - - return ( - -
- {/* Secrets Section */} - ({ ...v, isSecret: true }))} - sandboxes={project?.sandboxes || []} - onSave={handleSave} - saving={batchUpdate.isPending} - allowCustomVariables={true} - /> - - {/* Security Notice */} -
-

Security Best Practices

-
    -
  • All secret values are masked by default for security
  • -
  • Never commit secrets to Git
  • -
  • Rotate secrets regularly to maintain security
  • - {/*
  • Use different secrets for development and production environments
  • */} -
  • Limit access to secrets to only those who need them
  • -
-
-
-
- ); -} - -export default SecretsPageContent; diff --git a/app/(dashboard)/projects/[id]/terminal/page.tsx b/app/(dashboard)/projects/[id]/terminal/page.tsx deleted file mode 100644 index 749cb94..0000000 --- a/app/(dashboard)/projects/[id]/terminal/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Terminal Page - * - * This page is now a placeholder. The actual TerminalContainer is rendered - * in the ProjectLayout (app/projects/[id]/layout.tsx) to ensure persistence - * across page navigations. - */ - -export default function TerminalPage() { - return null; -} \ No newline at end of file diff --git a/app/(dashboard)/projects/_components/create-project-dialog.tsx b/app/(dashboard)/projects/_components/create-project-dialog.tsx deleted file mode 100644 index 0ccecb8..0000000 --- a/app/(dashboard)/projects/_components/create-project-dialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** - * CreateProjectDialog Component - * - * Dialog for creating new projects - * Handles project creation with kubeconfig validation - */ - -'use client'; - -import { useState, useTransition } from 'react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; - -import { - FullScreenDialog, - FullScreenDialogAction, - FullScreenDialogClose, - FullScreenDialogContent, - FullScreenDialogDescription, - FullScreenDialogFooter, - FullScreenDialogHeader, - FullScreenDialogTitle, -} from '@/components/ui/fullscreen-dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { createProject } from '@/lib/actions/project'; - -interface CreateProjectDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [projectName, setProjectName] = useState(''); - const [description, setDescription] = useState(''); - - const handleCreateProject = (e: React.FormEvent) => { - e.preventDefault(); - - if (!projectName.trim()) { - toast.error('Project name is required'); - return; - } - - startTransition(async () => { - const result = await createProject(projectName, description); - - if (!result.success) { - toast.error(result.error, { duration: 6000 }); - return; - } - - toast.success('Creating project...'); - setProjectName(''); - setDescription(''); - onOpenChange(false); - router.refresh(); - }); - }; - - return ( - - - - Create New Project - - Enter a name and optional description for your new project. - - - -
- {/* Project Name */} -
- - setProjectName(e.target.value)} - disabled={isPending} - autoFocus - /> -

- Use lowercase letters, numbers, and hyphens only. -

-
- - {/* Description */} -
- - setDescription(e.target.value)} - disabled={isPending} - /> -

- Optional. Add a brief description to help identify this project. -

-
- - {/* Actions */} - - - Cancel - - - {isPending ? 'Creating...' : 'Create Project'} - - -
-
-
- ); -} diff --git a/app/(dashboard)/projects/_components/import-github-dialog.tsx b/app/(dashboard)/projects/_components/import-github-dialog.tsx deleted file mode 100644 index 2104ee0..0000000 --- a/app/(dashboard)/projects/_components/import-github-dialog.tsx +++ /dev/null @@ -1,380 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { FaGithub } from 'react-icons/fa' -import { MdRefresh } from 'react-icons/md' -import type { ProjectStatus, ProjectTask, ResourceStatus } from '@prisma/client' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' -import { - getInstallationRepos, - getInstallations, - type GitHubRepo, -} from '@/lib/actions/github' -import { importProjectFromGitHub } from '@/lib/actions/project' -import { env } from '@/lib/env' -import { GET } from '@/lib/fetch-client' -import { getProjectImportStatus } from '@/lib/util/project-import-status' - -type Step = 'loading' | 'check-github-app' | 'select-repo' - -interface ImportGitHubDialogProps { - open: boolean - onOpenChange: (open: boolean) => void -} - -type ImportStatusPollResponse = { - status: ProjectStatus - sandboxes: Array<{ status: ResourceStatus }> - tasks: Array> -} - -export function ImportGitHubDialog({ open, onOpenChange }: ImportGitHubDialogProps) { - const router = useRouter() - const [step, setStep] = useState('loading') - const [isLoading, setIsLoading] = useState(true) - const [searchQuery, setSearchQuery] = useState('') - - // Step 1 state - const [hasInstallation, setHasInstallation] = useState(false) - const [installationId, setInstallationId] = useState(null) - - // Step 2 state - const [repos, setRepos] = useState([]) - const [selectedRepo, setSelectedRepo] = useState(null) - const [isCreating, setIsCreating] = useState(false) - const [importProjectId, setImportProjectId] = useState(null) - - const resetState = useCallback(() => { - setStep('loading') - setIsLoading(true) - setSearchQuery('') - setHasInstallation(false) - setInstallationId(null) - setRepos([]) - setSelectedRepo(null) - setIsCreating(false) - setImportProjectId(null) - }, []) - - const checkIdentity = useCallback(async () => { - setStep('loading') - setIsLoading(true) - try { - // Directly check for GitHub App installation - const installResult = await getInstallations() - if (installResult.success && installResult.data.length > 0) { - const firstInstallationId = installResult.data[0].installationId - setHasInstallation(true) - setInstallationId(firstInstallationId) - const repoResult = await getInstallationRepos(firstInstallationId.toString()) - if (repoResult.success) { - setRepos(repoResult.data) - setStep('select-repo') - } else { - setStep('check-github-app') - } - } else { - setHasInstallation(false) - setStep('check-github-app') - } - } catch (error) { - console.error('Failed to check GitHub installation:', error) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - if (open) { - resetState() - checkIdentity() - } - }, [open, resetState, checkIdentity]) - - useEffect(() => { - if (!open || !importProjectId) { - return - } - - const pollImportStatus = async () => { - try { - const project = await GET( - `/api/projects/${importProjectId}` - ) - - if (project.status === 'ERROR' || project.sandboxes.some((sandbox) => sandbox.status === 'ERROR')) { - toast.error('Project creation failed because the sandbox could not start.') - onOpenChange(false) - setImportProjectId(null) - router.refresh() - return - } - - const importStatus = getProjectImportStatus({ tasks: project.tasks }) - - if (importStatus === 'IMPORTED') { - toast.success('Repository imported successfully') - onOpenChange(false) - setImportProjectId(null) - router.refresh() - return - } - - if (importStatus === 'IMPORT_FAILED') { - toast.error('Repository import failed. An empty project was created instead.') - onOpenChange(false) - setImportProjectId(null) - router.refresh() - } - } catch (error) { - console.error('Failed to poll import status:', error) - } - } - - const timer = setInterval(pollImportStatus, 3000) - void pollImportStatus() - return () => clearInterval(timer) - }, [importProjectId, onOpenChange, open, router]) - - const handleInstallApp = () => { - const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME - if (!appName) { - toast.error('GitHub App is not configured') - return - } - - setIsLoading(true) - - const installUrl = `https://github.com/apps/${appName}/installations/new` - - const width = 800 - const height = 800 - const left = window.screen.width / 2 - width / 2 - const top = window.screen.height / 2 - height / 2 - - const popup = window.open( - installUrl, - 'github-app-install', - `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` - ) - - if (!popup) { - toast.error('Failed to open popup window. Please allow popups for this site.') - setIsLoading(false) - return - } - - const handleMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return - if (event.data.type !== 'github-app-installed') return - - window.removeEventListener('message', handleMessage) - clearInterval(checkClosed) - toast.success('GitHub App installed successfully!') - checkIdentity() - } - - window.addEventListener('message', handleMessage) - - const checkClosed = setInterval(() => { - if (popup.closed) { - clearInterval(checkClosed) - window.removeEventListener('message', handleMessage) - checkIdentity() - } - }, 500) - } - - const handleSelectRepo = (repo: GitHubRepo) => { - setSelectedRepo(repo) - } - - const handleImport = async () => { - if (!selectedRepo || !installationId) return - - setIsCreating(true) - try { - const result = await importProjectFromGitHub({ - installationId, - repoId: selectedRepo.id, - repoName: selectedRepo.name, - repoFullName: selectedRepo.full_name, - defaultBranch: selectedRepo.default_branch, - }) - - if (result.success) { - toast.success(`Project "${selectedRepo.name}" is being imported...`) - setImportProjectId(result.data.id) - } else { - toast.error(result.error || 'Failed to import project') - } - } catch (error) { - console.error('Failed to import project:', error) - toast.error('Failed to import project') - } finally { - setIsCreating(false) - } - } - - const filteredRepos = repos.filter((repo) => - repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) - ) - - const renderStepContent = () => { - switch (step) { - case 'loading': - return ( -
-
- - Loading your GitHub repositories... -
-
- ) - - case 'check-github-app': - if (hasInstallation) { - return null - } - - return ( -
-
-

- Install the GitHub App to grant access to your repositories. -

-
- - -
- ) - - case 'select-repo': - return ( -
- setSearchQuery(e.target.value)} - className="bg-input border-border" - /> - - -
- {isLoading ? ( -
-
- - Loading repositories... -
-
- ) : filteredRepos.length === 0 ? ( -
- No repositories found -
- ) : ( -
- {filteredRepos.map((repo) => ( - - ))} -
- )} -
-
- - {selectedRepo && ( -
- - -
- )} -
- ) - - default: - return null - } - } - - const getStepTitle = () => { - switch (step) { - case 'loading': - return 'Import from GitHub' - case 'check-github-app': - return 'Install GitHub App' - case 'select-repo': - return 'Select Repository' - default: - return 'Import from GitHub' - } - } - - return ( - - - - {getStepTitle()} - - -
{renderStepContent()}
- - {/* Step indicators */} -
-
-
-
- -
- ) -} diff --git a/app/(dashboard)/projects/_components/search-bar.tsx b/app/(dashboard)/projects/_components/search-bar.tsx deleted file mode 100644 index cb542ba..0000000 --- a/app/(dashboard)/projects/_components/search-bar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { useState } from 'react' -import { MdAdd, MdSearch } from 'react-icons/md' - -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Kbd } from '@/components/ui/kbd' - -import CreateProjectDialog from './create-project-dialog' -import { ImportGitHubDialog } from './import-github-dialog' - -export function SearchBar() { - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) - - return ( - <> -
- {/* Search input */} -
-
-
- -
- - - -
- - ⌘K - -
-
-
- - {/* Action buttons */} -
-
- - -
-
-
- - - - - - ) -} diff --git a/app/(dashboard)/settings/_components/github-status-card.tsx b/app/(dashboard)/settings/_components/github-status-card.tsx deleted file mode 100644 index b8207f8..0000000 --- a/app/(dashboard)/settings/_components/github-status-card.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { FaGithub } from 'react-icons/fa' -import { MdCheck } from 'react-icons/md' -import Image from 'next/image' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { getInstallations, type GitHubInstallation } from '@/lib/actions/github' -import { env } from '@/lib/env' - -export function GitHubStatusCard() { - const [isLoading, setIsLoading] = useState(true) - const [installation, setInstallation] = useState(null) - - useEffect(() => { - loadData() - }, []) - - const loadData = async () => { - setIsLoading(true) - try { - const installationsResult = await getInstallations() - - if (installationsResult.success && installationsResult.data.length > 0) { - setInstallation(installationsResult.data[0]) - } - } catch (error) { - console.error('Failed to load GitHub data:', error) - } finally { - setIsLoading(false) - } - } - - const handleInstallApp = () => { - const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME - if (!appName) { - toast.error('GitHub App is not configured') - return - } - - const installUrl = `https://github.com/apps/${appName}/installations/new` - - const width = 800 - const height = 800 - const left = window.screen.width / 2 - width / 2 - const top = window.screen.height / 2 - height / 2 - - const popup = window.open( - installUrl, - 'github-app-install', - `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` - ) - - if (!popup) { - toast.error('Failed to open popup window. Please allow popups for this site.') - return - } - - const checkClosed = setInterval(() => { - if (popup.closed) { - clearInterval(checkClosed) - window.removeEventListener('message', handleMessage) - } - }, 500) - - const handleMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return - if (event.data.type !== 'github-app-installed') return - - window.removeEventListener('message', handleMessage) - clearInterval(checkClosed) - toast.success('GitHub App installed successfully!') - loadData() - } - - window.addEventListener('message', handleMessage) - } - - if (isLoading) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) - } - - return ( -
-
-
-

GitHub Integration

-
- {installation ? ( -
- -
- -
-
- ) : ( - - )} -
-
- -
-

- What does the GitHub Integration do? -

-

- Connecting to GitHub allows you to import repositories and keep project source metadata available in Fulling. -

- - {installation ? ( -
- {installation.accountAvatarUrl ? ( - {installation.accountLogin} - ) : ( -
- -
- )} - - {installation.accountLogin} - - ● Connected -
- ) : ( - - )} -
-
-
- ) -} diff --git a/app/(dashboard)/settings/_components/settings-sidebar.tsx b/app/(dashboard)/settings/_components/settings-sidebar.tsx deleted file mode 100644 index 7015687..0000000 --- a/app/(dashboard)/settings/_components/settings-sidebar.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import Link from 'next/link' -import { usePathname } from 'next/navigation' - -import { Separator } from '@/components/ui/separator' -import { cn } from '@/lib/utils' - -const menuItems = [ - { label: 'Integrations', href: '/settings/integrations' }, -] - -export function SettingsSidebar() { - const pathname = usePathname() - - return ( - - ) -} diff --git a/app/(dashboard)/settings/integrations/page.tsx b/app/(dashboard)/settings/integrations/page.tsx deleted file mode 100644 index a664135..0000000 --- a/app/(dashboard)/settings/integrations/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { GitHubStatusCard } from '../_components/github-status-card' - -export const metadata = { - title: 'Integrations | Settings | Fulling', - description: 'Manage your integrations and connected services.', -} - -export default function IntegrationsPage() { - return ( - <> -
-

Integrations

-

- Manage your integrations and connected services. -

-
- -
- -
- - ) -} diff --git a/app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx b/app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx new file mode 100644 index 0000000..8b31d39 --- /dev/null +++ b/app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx @@ -0,0 +1,225 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { CheckCircle2, LoaderCircle, Save, Trash2 } from 'lucide-react' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' + +import { + formatUpdatedAt, + type KubeconfigStatus, + parseKubeconfigStatus, +} from '../../../_components/kubeconfig-status' + +type ApiError = { message?: unknown } + +async function readError(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as ApiError + return typeof body.message === 'string' ? body.message : fallback + } catch { + return fallback + } +} + +export function KubeconfigForm() { + const [content, setContent] = useState('') + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const loadStatus = useCallback(async () => { + setError(null) + setIsLoading(true) + try { + const response = await fetch('/api/kubeconfig', { cache: 'no-store' }) + if (!response.ok) { + throw new Error(await readError(response, 'Could not load kubeconfig status.')) + } + setStatus(parseKubeconfigStatus(await response.json())) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Could not load kubeconfig status.') + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + void loadStatus() + }, [loadStatus]) + + async function handleSave() { + setError(null) + setNotice(null) + setIsSaving(true) + + try { + const response = await fetch('/api/kubeconfig', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content }), + }) + if (!response.ok) { + throw new Error(await readError(response, 'Could not save kubeconfig.')) + } + + setStatus(parseKubeconfigStatus(await response.json())) + setContent('') + setNotice('Kubeconfig saved. The stored content remains hidden.') + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'Could not save kubeconfig.') + } finally { + setIsSaving(false) + } + } + + async function handleDelete() { + setError(null) + setNotice(null) + setIsDeleting(true) + + try { + const response = await fetch('/api/kubeconfig', { method: 'DELETE' }) + if (!response.ok) { + throw new Error(await readError(response, 'Could not delete kubeconfig.')) + } + + setStatus(parseKubeconfigStatus(await response.json())) + setContent('') + setNotice('Kubeconfig deleted.') + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : 'Could not delete kubeconfig.') + } finally { + setIsDeleting(false) + } + } + + return ( +
+
+

Settings

+

Kubeconfig

+

+ Fulling validates this credential against the selected Kubernetes cluster before storing + it as plaintext. Saved content is never displayed again. +

+
+ +
+
+
+

+ Saved credential +

+

+ {isLoading + ? 'Loading status...' + : status?.configured + ? `Configured. Last updated ${formatUpdatedAt(status.updatedAt)}.` + : 'No kubeconfig is configured.'} +

+
+ {status?.configured ? ( +
+
+ ) : null} +
+
+ +
+
+

+ {status?.configured ? 'Replace kubeconfig' : 'Configure kubeconfig'} +

+

+ Paste the complete replacement. Existing content is intentionally not prefilled. +

+ +