23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Root compose defaults (safe for local self-host / dev)
|
||||||
|
# Copy to .env to override.
|
||||||
|
|
||||||
|
# --- app ports (host) ---
|
||||||
|
FRONTEND_PORT=3000
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
|
||||||
|
# --- database ---
|
||||||
|
POSTGRES_DB=openclaw_agency
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# --- redis ---
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# --- backend settings (see backend/.env.example for full list) ---
|
||||||
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
DB_AUTO_MIGRATE=true
|
||||||
|
|
||||||
|
# --- frontend settings ---
|
||||||
|
# Public URL used by the browser to reach the API
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
# Allow maintainers to manually kick CI when GitHub doesn't create a run for a new head SHA.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: python -m pip install --upgrade pip uv
|
||||||
|
|
||||||
|
- name: Cache uv
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/uv
|
||||||
|
backend/.venv
|
||||||
|
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: make setup
|
||||||
|
|
||||||
|
- name: Run checks
|
||||||
|
env:
|
||||||
|
# Keep CI builds deterministic and secretless.
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
run: make check
|
||||||
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System deps (keep minimal)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv (https://github.com/astral-sh/uv)
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
ENV PATH="/root/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
# --- deps layer ---
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
# Copy only dependency metadata first for better build caching
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
# Create venv and sync deps (including runtime)
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
# --- runtime ---
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
|
# Copy virtual environment from deps stage
|
||||||
|
COPY --from=deps /app/.venv /app/.venv
|
||||||
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
|
# Copy app source
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
# Default API port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the API
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
67
compose.yml
Normal file
67
compose.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: openclaw-mission-control
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-openclaw_agency}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env.example
|
||||||
|
environment:
|
||||||
|
# Override localhost defaults for container networking
|
||||||
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-openclaw_agency}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||||
|
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||||
|
env_file:
|
||||||
|
- ./frontend/.env.example
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
38
frontend/Dockerfile
Normal file
38
frontend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Allows configuring the API URL at build time.
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
|
||||||
|
# (but note some values may be baked at build time).
|
||||||
|
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut } from "@/auth/clerk";
|
||||||
|
|
||||||
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import { useCreateBoardApiV1BoardsPost } from "@/api/generated/boards/boards";
|
import { useCreateBoardApiV1BoardsPost } from "@/api/generated/boards/boards";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import "./globals.css";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ClerkProvider } from "@clerk/nextjs";
|
|
||||||
import { IBM_Plex_Sans, Sora } from "next/font/google";
|
import { IBM_Plex_Sans, Sora } from "next/font/google";
|
||||||
|
|
||||||
|
import { AuthProvider } from "@/components/providers/AuthProvider";
|
||||||
import { QueryProvider } from "@/components/providers/QueryProvider";
|
import { QueryProvider } from "@/components/providers/QueryProvider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -29,14 +29,14 @@ const headingFont = Sora({
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ClerkProvider>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${bodyFont.variable} ${headingFont.variable} min-h-screen bg-app text-strong antialiased`}
|
className={`${bodyFont.variable} ${headingFont.variable} min-h-screen bg-app text-strong antialiased`}
|
||||||
>
|
>
|
||||||
|
<AuthProvider>
|
||||||
<QueryProvider>{children}</QueryProvider>
|
<QueryProvider>{children}</QueryProvider>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ClerkProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@/auth/clerk";
|
||||||
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
|
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
|
|||||||
70
frontend/src/auth/clerk.tsx
Normal file
70
frontend/src/auth/clerk.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// NOTE: We intentionally keep this file very small and dependency-free.
|
||||||
|
// It provides CI/secretless-build safe fallbacks for Clerk hooks/components.
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClerkProvider,
|
||||||
|
SignedIn as ClerkSignedIn,
|
||||||
|
SignedOut as ClerkSignedOut,
|
||||||
|
SignInButton as ClerkSignInButton,
|
||||||
|
SignOutButton as ClerkSignOutButton,
|
||||||
|
useAuth as clerkUseAuth,
|
||||||
|
useUser as clerkUseUser,
|
||||||
|
} from "@clerk/nextjs";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function isClerkEnabled(): boolean {
|
||||||
|
// Invariant: Clerk is disabled ONLY when the publishable key is absent.
|
||||||
|
// If a key is present, we assume Clerk is intended to be enabled and we let
|
||||||
|
// Clerk fail fast if the key is invalid/misconfigured.
|
||||||
|
return Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignedIn(props: { children: ReactNode }) {
|
||||||
|
if (!isClerkEnabled()) return null;
|
||||||
|
return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignedOut(props: { children: ReactNode }) {
|
||||||
|
if (!isClerkEnabled()) return <>{props.children}</>;
|
||||||
|
return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the same prop surface as Clerk components so call sites don't need edits.
|
||||||
|
export function SignInButton(props: ComponentProps<typeof ClerkSignInButton>) {
|
||||||
|
if (!isClerkEnabled()) return null;
|
||||||
|
return <ClerkSignInButton {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOutButton(props: ComponentProps<typeof ClerkSignOutButton>) {
|
||||||
|
if (!isClerkEnabled()) return null;
|
||||||
|
return <ClerkSignOutButton {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
if (!isClerkEnabled()) {
|
||||||
|
return { isLoaded: true, isSignedIn: false, user: null } as const;
|
||||||
|
}
|
||||||
|
return clerkUseUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
if (!isClerkEnabled()) {
|
||||||
|
return {
|
||||||
|
isLoaded: true,
|
||||||
|
isSignedIn: false,
|
||||||
|
userId: null,
|
||||||
|
sessionId: null,
|
||||||
|
getToken: async () => null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return clerkUseAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export ClerkProvider for places that want to mount it, but strongly prefer
|
||||||
|
// gating via isClerkEnabled() at call sites.
|
||||||
|
export { ClerkProvider };
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@clerk/nextjs";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut } from "@/auth/clerk";
|
||||||
|
|
||||||
import { HeroCopy } from "@/components/molecules/HeroCopy";
|
import { HeroCopy } from "@/components/molecules/HeroCopy";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { SignOutButton, useUser } from "@clerk/nextjs";
|
import { SignOutButton, useUser } from "@/auth/clerk";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|||||||
30
frontend/src/components/providers/AuthProvider.tsx
Normal file
30
frontend/src/components/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ClerkProvider } from "@clerk/nextjs";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
function isLikelyValidClerkPublishableKey(key: string | undefined): key is string {
|
||||||
|
if (!key) return false;
|
||||||
|
// Clerk publishable keys look like: pk_test_... or pk_live_...
|
||||||
|
// In CI we want builds to stay secretless; if the key isn't present/valid,
|
||||||
|
// we skip Clerk entirely so `next build` can prerender.
|
||||||
|
//
|
||||||
|
// Note: Clerk appears to validate key *contents*, not just shape. We therefore
|
||||||
|
// use a conservative heuristic to avoid treating obvious placeholders as valid.
|
||||||
|
const m = /^pk_(test|live)_([A-Za-z0-9]+)$/.exec(key);
|
||||||
|
if (!m) return false;
|
||||||
|
const body = m[2];
|
||||||
|
if (body.length < 16) return false;
|
||||||
|
if (/^0+$/.test(body)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
if (!isLikelyValidClerkPublishableKey(publishableKey)) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ClerkProvider publishableKey={publishableKey}>{children}</ClerkProvider>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { SignedIn, useUser } from "@clerk/nextjs";
|
import { SignedIn, useUser } from "@/auth/clerk";
|
||||||
|
|
||||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
import { UserMenu } from "@/components/organisms/UserMenu";
|
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { SignedIn } from "@clerk/nextjs";
|
import { SignedIn } from "@/auth/clerk";
|
||||||
|
|
||||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
import { UserMenu } from "@/components/organisms/UserMenu";
|
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
import { clerkMiddleware } from "@clerk/nextjs/server";
|
import { clerkMiddleware } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
export default clerkMiddleware();
|
const isClerkEnabled = () => Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
|
||||||
|
|
||||||
|
export default isClerkEnabled() ? clerkMiddleware() : () => NextResponse.next();
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
|
|||||||
Reference in New Issue
Block a user