diff --git a/README.md b/README.md index 16b08bf..13e9608 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ The backend handles the core evaluation logic and integrates with the GCP Pub/Su ### Local Setup & Execution 1. **Configure Environment:** ```bash + cd server export AES_SECRET_KEY=defaultaessecret export TOKEN_SIGNING_KEY=default_token_signing_key export JDBC_DATABASE_URL="jdbc:mysql://localhost:3306/stax_db" @@ -74,10 +75,11 @@ A React-based UI built with Mantine and Tailwind CSS for interacting with the St ### Setup & Execution 1. **Install Dependencies:** ```bash + cd frontend npm install ``` 2. **Environment Configuration:** - * Copy `.env.template` to `.env.local`. + * Copy `.env.template` to `.env.local` and set `NEXT_PUBLIC_API_BASE_URL` (e.g. `http://localhost:8080`) and `NEXT_PUBLIC_APP_BASE_URL` (e.g. `http://localhost:3000`). * Add `NEXT_PUBLIC_GOOGLE_CLIENT_ID` if using authentication. 3. **Run Development Server:** ```bash @@ -98,7 +100,8 @@ A React-based UI built with Mantine and Tailwind CSS for interacting with the St ├── terraform/ # Infrastructure as Code (GCP) │ ├── modules/ # Reusable GCP resource definitions │ └── quickstart/ # Main deployment entry point -├── backend/ # Spring Boot Java Server +├── server/ # Spring Boot Java Server │ └── src/ # Evaluation logic and API routes └── frontend/ # Next.js & React Web App - └── src/ # UI Components and Mantine hooks \ No newline at end of file + ├── app/ # App routes and page components + └── components/ # Reusable UI components \ No newline at end of file diff --git a/frontend/.env.template b/frontend/.env.template new file mode 100644 index 0000000..ac6909e --- /dev/null +++ b/frontend/.env.template @@ -0,0 +1,17 @@ +# URL to the Stax Spring Boot server (e.g. http://localhost:8080) +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 + +# URL of the Stax Frontend Next.js app (e.g. http://localhost:3000) +NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 + +# Google OAuth Client ID for authentication +NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id + +# Set to true to enable Google OAuth authentication (default is false for local development) +NEXT_PUBLIC_AUTH_ENABLED=false + +# Optional configurations +NEXT_PUBLIC_FEEDBACK_PRODUCT_ID= +NEXT_PUBLIC_HATS_API_KEY= +NEXT_PUBLIC_HATS_TRIGGER_ID= +NEXT_PUBLIC_GA_TAG_ID= diff --git a/frontend/app/(authRoutes)/projects/components/ProjectsTable.tsx b/frontend/app/(authRoutes)/projects/components/ProjectsTable.tsx index cc916af..3944362 100644 --- a/frontend/app/(authRoutes)/projects/components/ProjectsTable.tsx +++ b/frontend/app/(authRoutes)/projects/components/ProjectsTable.tsx @@ -88,6 +88,9 @@ export default function ProjectsTable() { setAllProjects(res?.["projects"] || []); setIsLoadingProjects(false); }, + onError: () => { + setIsLoadingProjects(false); + }, }); useEffect(() => { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 46379f4..8a452cd 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -16,6 +16,7 @@ "use client"; +import { MainConfig } from "@/config/config"; import { JWT_TOKEN_KEY } from "@/config/constants"; import { routes } from "@/config/routes"; import LocalStorage from "@/utils/LocalStorage"; @@ -28,7 +29,7 @@ export default function RootPage() { useEffect(() => { const hasJwtToken = LocalStorage.get(JWT_TOKEN_KEY); - if (hasJwtToken) { + if (!MainConfig.isAuthEnabled || hasJwtToken) { router.push(routes.projects); } else { router.push(routes.signin); diff --git a/frontend/config/config.tsx b/frontend/config/config.tsx index a7dc898..83c8bd6 100644 --- a/frontend/config/config.tsx +++ b/frontend/config/config.tsx @@ -16,7 +16,7 @@ export const MainConfig = { isPlaygroundStreamingEnabled: false, // if you want streaming enabled for pointwise set to true - isAuthEnabled: true, // Enable the authentication and bearer token passing to backend API + isAuthEnabled: process.env.NEXT_PUBLIC_AUTH_ENABLED === "true", // Enable the authentication and bearer token passing to backend API isPlaygroundAttachmentEnabled: false, // Not fully developed yet isEvaluatorUndoRedoEnabled: false, // Not fully developed yet }; diff --git a/frontend/middleware.ts b/frontend/middleware.ts index ae77c4c..117722e 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -22,67 +22,45 @@ import { routes } from "./config/routes"; // Define the strict CSP for production export function middleware(request: NextRequest) { - // Handle API routes specifically to prevent caching - if (request.nextUrl.pathname.startsWith("/api/")) { - const response = NextResponse.next(); + const response = NextResponse.next(); - // Set cache control headers for API routes - response.headers.set( - "Cache-Control", - "no-cache, no-store, must-revalidate, private", - ); - response.headers.set("Pragma", "no-cache"); - response.headers.set("Expires", "0"); - response.headers.set("X-Cache-Status", "disabled"); + // Set cache control headers for all pages and API routes + response.headers.set( + "Cache-Control", + "no-cache, no-store, must-revalidate, private", + ); + response.headers.set("Pragma", "no-cache"); + response.headers.set("Expires", "0"); + response.headers.set("X-Cache-Status", "disabled"); - return response; - } - - const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); - const cspHeader = ` - default-src 'self'; - script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: http:; - style-src 'self' 'nonce-${nonce}'; - img-src 'self' blob: data:; - font-src 'self'; - object-src 'none'; - base-uri 'self'; - form-action 'self'; - frame-ancestors 'none'; - upgrade-insecure-requests; - `; - - const isDev = process?.env?.NODE_ENV === "development"; - // Replace newline characters and spaces - const contentSecurityPolicyHeaderValue = cspHeader - .replace(/\s{2,}/g, " ") - .trim(); - - const requestHeaders = new Headers(request.headers); - requestHeaders.set("x-nonce", nonce); - if (!isDev) { - requestHeaders.set( - "Content-Security-Policy", - contentSecurityPolicyHeaderValue, - ); - } - - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - if (!isDev) { - response.headers.set( - "Content-Security-Policy", - contentSecurityPolicyHeaderValue, - ); + // Optional: only enforce strict CSP when explicitly enabled via env + if (process.env.ENABLE_STRICT_CSP === "true") { + const isHttps = + request.headers.get("x-forwarded-proto") === "https" || + request.nextUrl.protocol === "https:"; + const upgradeInsecure = isHttps ? "upgrade-insecure-requests;" : ""; + const cspHeader = ` + default-src 'self'; + script-src 'self' 'unsafe-eval' 'unsafe-inline' https: http:; + style-src 'self' 'unsafe-inline' https: http:; + img-src 'self' blob: data: https:; + font-src 'self' data: https:; + connect-src 'self' https: http: ws: wss:; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + ${upgradeInsecure} + `.replace(/\s{2,}/g, " ").trim(); + response.headers.set("Content-Security-Policy", cspHeader); } // --------- Authorization redirects --------- const pathname = request.nextUrl.pathname; if (pathname === routes.root) { + if (process.env.NEXT_PUBLIC_AUTH_ENABLED !== "true") { + return NextResponse.redirect(new URL(routes.projects, request.url)); + } // Add authorization code / error to the query params when user is redirected back to app let redirectRoute = routes.signin; if (request.nextUrl.searchParams.get("code")) { @@ -96,7 +74,10 @@ export function middleware(request: NextRequest) { return NextResponse.redirect(new URL(redirectRoute, request.url)); } - // Default case - apply CSP and continue + if (pathname === routes.signin && process.env.NEXT_PUBLIC_AUTH_ENABLED !== "true") { + return NextResponse.redirect(new URL(routes.projects, request.url)); + } + return response; } diff --git a/frontend/next.config.js b/frontend/next.config.js index ec06cd8..4a554c7 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -80,6 +80,19 @@ const nextConfig = { }, ]; }, + async rewrites() { + const backendUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080"; + return [ + { + source: "/api/:path*", + destination: `${backendUrl}/:path*`, + }, + { + source: "/streaming/:path*", + destination: `${backendUrl}/streaming/:path*`, + }, + ]; + }, }; module.exports = nextConfig; diff --git a/frontend/package.json b/frontend/package.json index 43c12a3..53dd627 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "test:watch": "jest --watch", "lint:check": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings=0", "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", - "githooks:init": "cp git-hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit" + "githooks:init": "cp git-hooks/pre-commit ../.git/hooks/pre-commit && chmod +x ../.git/hooks/pre-commit" }, "dependencies": { "@codemirror/lang-json": "^6.0.2", diff --git a/server/src/main/java/com/planck/planck/domain/project/ProjectServiceImpl.java b/server/src/main/java/com/planck/planck/domain/project/ProjectServiceImpl.java index 48ba5bb..0a69fa0 100644 --- a/server/src/main/java/com/planck/planck/domain/project/ProjectServiceImpl.java +++ b/server/src/main/java/com/planck/planck/domain/project/ProjectServiceImpl.java @@ -238,6 +238,9 @@ private ProjectDTO enrichProjectWithFields(Project project, Set includeF private List enrichProjectsWithFields( List projects, Set includeFields) { + if (projects == null || projects.isEmpty()) { + return List.of(); + } List projectIds = projects.stream().map(Project::getId).toList(); User user = projects.get(0).getUser(); diff --git a/terraform/README.md b/terraform/README.md index a568682..58fa417 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -65,11 +65,11 @@ You can use GCP Cloud Run CLI to build and push the container images for UI serv export PROJECT_ID= export REGION=us-central1 -# From the backend java directory using Dockerfile. -gcloud run deploy stax-ui --project=${PROJECT_ID} --source . --region=${REGION} - -# From the UI directory. Make sure you have set all the NEXT_PUBLIC_* environment variables. We use the `gcloud run deploy` to build and push the container image. +# From the backend java directory (server/) using Dockerfile. gcloud run deploy stax-backend --project=${PROJECT_ID} --source . --region=${REGION} + +# From the UI directory (frontend/). Make sure you have set all the NEXT_PUBLIC_* environment variables. We use the `gcloud run deploy` to build and push the container image. +gcloud run deploy stax-ui --project=${PROJECT_ID} --source . --region=${REGION} ``` After these steps, the images will be present in the following locations. diff --git a/terraform/quickstart/cloud_run_backend.tf b/terraform/quickstart/cloud_run_backend.tf index 301dd55..570d536 100644 --- a/terraform/quickstart/cloud_run_backend.tf +++ b/terraform/quickstart/cloud_run_backend.tf @@ -17,7 +17,7 @@ module "cloud_run_backend" { project_id = local.project_id region = local.region - cloud_run_image = "${local.region}-docker.pkg.dev/${local.project_id}/stax-backend" + cloud_run_image = "${local.region}-docker.pkg.dev/${local.project_id}/${var.artifact_registry_repo}/stax-backend" cloud_run_service_name = "stax-backend" cloud_run_cpu_limit = 4 cloud_run_memory_limit = "16Gi" @@ -64,7 +64,7 @@ module "cloud_run_backend" { }, { name = "GCP_BUCKET_ID", - value = "stax-prod-project-bucket" + value = var.gcs_bucket_name }, # This is needed for using Google OAuth authentication. As a prerequisite, configure a Google oauth client. # TODO: Uncomment the following lines and replace with the real oauth client id here diff --git a/terraform/quickstart/cloud_run_ui.tf b/terraform/quickstart/cloud_run_ui.tf index 749d7af..9e3a096 100644 --- a/terraform/quickstart/cloud_run_ui.tf +++ b/terraform/quickstart/cloud_run_ui.tf @@ -19,7 +19,7 @@ module "ui_service" { region = local.region cloud_run_service_name = "stax-ui" - cloud_run_image = "${local.region}-docker.pkg.dev/${local.project_id}/stax-ui" + cloud_run_image = "${local.region}-docker.pkg.dev/${local.project_id}/${var.artifact_registry_repo}/stax-ui" cloud_run_cpu_limit = 4 cloud_run_memory_limit = "2Gi" allow_unauthenticated = true diff --git a/terraform/quickstart/iam.tf b/terraform/quickstart/iam.tf index 859edd3..8a8d154 100644 --- a/terraform/quickstart/iam.tf +++ b/terraform/quickstart/iam.tf @@ -13,38 +13,54 @@ # limitations under the License. locals { - # TODO: change this to your email - # member = "user:you@gmail.com" - member = "user:xinyij@google.com" + admin_member = var.admin_email != "" ? (startswith(var.admin_email, "user:") || startswith(var.admin_email, "serviceAccount:") || startswith(var.admin_email, "group:") ? var.admin_email : "user:${var.admin_email}") : null default_service_account = "serviceAccount:${data.google_project.default_project.number}-compute@developer.gserviceaccount.com" } resource "google_project_iam_member" "admin" { + count = local.admin_member != null ? 1 : 0 project = data.google_project.default_project.project_id - role = "roles/admin" - - member = local.member + role = "roles/resourcemanager.projectIamAdmin" + member = local.admin_member } -resource "google_project_iam_binding" "storage_admin" { +resource "google_project_iam_member" "storage_admin_user" { + count = local.admin_member != null ? 1 : 0 project = data.google_project.default_project.project_id role = "roles/storage.objectAdmin" + member = local.admin_member +} - members = [local.member, "serviceAccount:${local.service_account}"] +resource "google_project_iam_member" "storage_admin_sa" { + project = data.google_project.default_project.project_id + role = "roles/storage.objectAdmin" + member = "serviceAccount:${local.service_account}" } -resource "google_project_iam_binding" "sql_client" { +resource "google_project_iam_member" "sql_client_user" { + count = local.admin_member != null ? 1 : 0 project = data.google_project.default_project.project_id role = "roles/cloudsql.client" + member = local.admin_member +} - members = [local.member, "serviceAccount:${local.service_account}"] +resource "google_project_iam_member" "sql_client_sa" { + project = data.google_project.default_project.project_id + role = "roles/cloudsql.client" + member = "serviceAccount:${local.service_account}" } -resource "google_project_iam_binding" "iam_id_token_creator" { +resource "google_project_iam_member" "iam_id_token_creator_user" { + count = local.admin_member != null ? 1 : 0 project = data.google_project.default_project.project_id role = "roles/iam.serviceAccountOpenIdTokenCreator" + member = local.admin_member +} - members = [local.member, "serviceAccount:${local.service_account}"] +resource "google_project_iam_member" "iam_id_token_creator_sa" { + project = data.google_project.default_project.project_id + role = "roles/iam.serviceAccountOpenIdTokenCreator" + member = "serviceAccount:${local.service_account}" } diff --git a/terraform/quickstart/main.tf b/terraform/quickstart/main.tf index 404c23b..394c5cd 100644 --- a/terraform/quickstart/main.tf +++ b/terraform/quickstart/main.tf @@ -13,8 +13,7 @@ # limitations under the License. provider "google" { - # TODO: Prerequisite - change this to your project ID. - project = "planck-opensource-test-769621" + project = var.project_id != "" ? var.project_id : null } data "google_project" "default_project" {} @@ -22,6 +21,6 @@ data "google_compute_default_service_account" "default" {} locals { project_id = data.google_project.default_project.project_id - region = "us-central1" + region = var.region service_account = data.google_compute_default_service_account.default.email } diff --git a/terraform/quickstart/variables.tf b/terraform/quickstart/variables.tf new file mode 100644 index 0000000..5405434 --- /dev/null +++ b/terraform/quickstart/variables.tf @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +variable "project_id" { + type = string + description = "The GCP Project ID where Stax resources will be deployed." + default = "" +} + +variable "region" { + type = string + description = "The GCP region for resources." + default = "us-central1" +} + +variable "admin_email" { + type = string + description = "The email address of the administrator (e.g. user:you@example.com)." + default = "" +} + +variable "artifact_registry_repo" { + type = string + description = "The Artifact Registry repository name hosting the container images." + default = "cloud-run-source-deploy" +} + +variable "gcs_bucket_name" { + type = string + description = "The GCS bucket name for Stax project data storage." + default = "stax-project-bucket" +}