diff --git a/.eslintrc.js b/.eslintrc.js index fb0b5d235..e1eb1430d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -157,11 +157,6 @@ const config = { message: "Import from '@blocksuite/global/utils'", importNames: ['assertExists', 'assertEquals'], }, - { - group: ['react-router-dom'], - message: 'Use `useNavigateHelper` instead', - importNames: ['useNavigate'], - }, ], }, ], diff --git a/.github/deployment/front/Dockerfile b/.github/deployment/front/Dockerfile index 81030c7aa..0e2f90f1e 100644 --- a/.github/deployment/front/Dockerfile +++ b/.github/deployment/front/Dockerfile @@ -1,6 +1,7 @@ FROM openresty/openresty:1.25.3.1-0-buster WORKDIR /app COPY ./packages/frontend/web/dist ./dist +COPY ./packages/frontend/admin/dist ./admin COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf diff --git a/.github/deployment/front/affine.nginx.conf b/.github/deployment/front/affine.nginx.conf index c424ee365..67e2caaf5 100644 --- a/.github/deployment/front/affine.nginx.conf +++ b/.github/deployment/front/affine.nginx.conf @@ -1,13 +1,17 @@ server { - listen 8080; - root /app/dist; + listen 8080; + root /app; - location / { - try_files $uri $uri/ /index.html; - } + location /admin { + try_files $uri $uri/ /admin/index.html; + } - error_page 404 /404.html; - location = /404.html { - internal; - } + location / { + try_files $uri $uri/ /dist/index.html; + } + + error_page 404 /404.html; + location = /404.html { + internal; + } } diff --git a/.github/deployment/node/Dockerfile b/.github/deployment/node/Dockerfile index 716431210..619073423 100644 --- a/.github/deployment/node/Dockerfile +++ b/.github/deployment/node/Dockerfile @@ -2,6 +2,7 @@ FROM node:20-bookworm-slim COPY ./packages/backend/server /app COPY ./packages/frontend/web/dist /app/static +COPY ./packages/frontend/admin/dist /app/static/admin WORKDIR /app RUN apt-get update && \ diff --git a/.github/workflows/build-server-image.yml b/.github/workflows/build-server-image.yml index 98fb20718..d6e82a059 100644 --- a/.github/workflows/build-server-image.yml +++ b/.github/workflows/build-server-image.yml @@ -6,6 +6,11 @@ on: flavor: type: string required: true + workflow_dispatch: + inputs: + flavor: + type: string + required: false env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} @@ -66,6 +71,32 @@ jobs: path: ./packages/frontend/web/dist if-no-files-found: error + build-admin-selfhost: + name: Build @affine/admin selfhost + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.flavor }} + steps: + - uses: actions/checkout@v4 + - name: Setup Version + id: version + uses: ./.github/actions/setup-version + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Build Core + run: yarn nx build @affine/admin --skip-nx-cache + env: + BUILD_TYPE: ${{ github.event.inputs.flavor }} + SHOULD_REPORT_TRACE: false + PUBLIC_PATH: '/admin/' + SELF_HOSTED: true + MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + - name: Upload admin artifact + uses: actions/upload-artifact@v4 + with: + name: selfhost-admin + path: ./packages/frontend/admin/dist + if-no-files-found: error + build-server-native: name: Build Server native - ${{ matrix.targets.name }} runs-on: ubuntu-latest @@ -108,6 +139,7 @@ jobs: needs: - build-server - build-web-selfhost + - build-admin-selfhost - build-server-native steps: - uses: actions/checkout@v4 @@ -171,6 +203,12 @@ jobs: name: selfhost-web path: ./packages/frontend/web/dist + - name: Download selfhost admin artifact + uses: actions/download-artifact@v4 + with: + name: selfhost-admin + path: ./packages/frontend/admin/dist + - name: Install Node.js dependencies run: | yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 32f1ca77c..0d713347a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -61,11 +61,46 @@ jobs: path: ./packages/frontend/web/dist if-no-files-found: error + build-admin: + name: Build @affine/admin + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.flavor }} + steps: + - uses: actions/checkout@v4 + - name: Setup Version + id: version + uses: ./.github/actions/setup-version + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Build Core + run: yarn nx build @affine/admin --skip-nx-cache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + BUILD_TYPE: ${{ github.event.inputs.flavor }} + SHOULD_REPORT_TRACE: true + TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }} + CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine-admin' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} + MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + - name: Upload admin artifact + uses: actions/upload-artifact@v4 + with: + name: admin + path: ./packages/frontend/admin/dist + if-no-files-found: error + build-frontend-image: name: Build Frontend Image runs-on: ubuntu-latest needs: - build-web + - build-admin steps: - uses: actions/checkout@v4 - name: Download web artifact @@ -73,6 +108,11 @@ jobs: with: name: web path: ./packages/frontend/web/dist + - name: Download admin artifact + uses: actions/download-artifact@v4 + with: + name: admin + path: ./packages/frontend/admin/dist - name: Setup env run: | echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index e94e4bb16..5880a2ae9 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -15,7 +15,8 @@ "test:coverage": "c8 ava --concurrency 1 --serial", "postinstall": "prisma generate", "data-migration": "node --loader ts-node/esm/transpile-only.mjs ./src/data/index.ts", - "predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run" + "predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run", + "predeploy:ts": "yarn prisma migrate deploy && node --loader ts-node/esm/transpile-only.mjs ./src/data/index.ts run" }, "dependencies": { "@apollo/server": "^4.10.2", diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index f0ff5b68c..4a3915e98 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -177,6 +177,14 @@ function buildAppModule() { config => config.isSelfhosted, ServeStaticModule.forRoot({ rootPath: join('/app', 'static'), + exclude: ['/admin*'], + }) + ) + .useIf( + config => config.isSelfhosted, + ServeStaticModule.forRoot({ + rootPath: join('/app', 'static', 'admin'), + serveRoot: '/admin', }) ); diff --git a/packages/backend/server/src/index.ts b/packages/backend/server/src/index.ts index d0d65a7db..02e7d87d9 100644 --- a/packages/backend/server/src/index.ts +++ b/packages/backend/server/src/index.ts @@ -16,7 +16,7 @@ const logger = new Logger('App'); logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`); if (AFFiNE.node.dev) { - logger.log('Startup Configration:'); + logger.log('Startup Configuration:'); logger.log(omit(globalThis.AFFiNE, 'ENV_MAP')); } logger.log(`Listening on http://${listeningHost}:${AFFiNE.server.port}`); diff --git a/packages/frontend/admin/components.json b/packages/frontend/admin/components.json new file mode 100644 index 000000000..b6b9529a7 --- /dev/null +++ b/packages/frontend/admin/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/global.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@affine/admin/components", + "utils": "@affine/admin/utils" + } +} diff --git a/packages/frontend/admin/package.json b/packages/frontend/admin/package.json new file mode 100644 index 000000000..54e243882 --- /dev/null +++ b/packages/frontend/admin/package.json @@ -0,0 +1,70 @@ +{ + "name": "@affine/admin", + "version": "0.14.0", + "private": true, + "dependencies": { + "@affine/core": "workspace:*", + "@affine/graphql": "workspace:*", + "@hookform/resolvers": "^3.6.0", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "@sentry/react": "^8.9.0", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.1.4", + "input-otp": "^1.2.4", + "lucide-react": "^0.394.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.51.5", + "react-resizable-panels": "^2.0.19", + "react-router-dom": "^6.23.1", + "sonner": "^1.5.0", + "vaul": "^0.9.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cross-env": "^7.0.3", + "shadcn-ui": "^0.8.0", + "tailwind-merge": "^2.3.0", + "tailwindcss": "^3.4.4", + "tailwindcss-animate": "^1.0.7" + }, + "scripts": { + "build": "cross-env DISTRIBUTION=admin yarn workspace @affine/cli build", + "update-shadcn": "shadcn-ui add -p src/components/ui" + }, + "exports": { + "./utils": "./src/utils.ts", + "./components/ui/*": "./src/components/ui/*.tsx" + } +} diff --git a/packages/frontend/admin/src/app.tsx b/packages/frontend/admin/src/app.tsx new file mode 100644 index 000000000..75c34c19a --- /dev/null +++ b/packages/frontend/admin/src/app.tsx @@ -0,0 +1,72 @@ +import { Toaster } from '@affine/admin/components/ui/sonner'; +import { Telemetry } from '@affine/core/telemetry'; +import { wrapCreateBrowserRouter } from '@sentry/react'; +import { useEffect } from 'react'; +import { + createBrowserRouter as reactRouterCreateBrowserRouter, + RouterProvider, + useLocation, + useNavigate, +} from 'react-router-dom'; + +import { TooltipProvider } from './components/ui/tooltip'; + +const createBrowserRouter = wrapCreateBrowserRouter( + reactRouterCreateBrowserRouter +); + +const _createBrowserRouter = window.SENTRY_RELEASE + ? createBrowserRouter + : reactRouterCreateBrowserRouter; + +const Redirect = function Redirect() { + const location = useLocation(); + const navigate = useNavigate(); + useEffect(() => { + if (!location.pathname.startsWith('/admin')) { + navigate('/admin', { replace: true }); + } + }, [location, navigate]); + return null; +}; + +export const router = _createBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: '/admin', + children: [ + { + path: '', + lazy: () => import('./modules/home'), + }, + { + path: '/admin/auth', + lazy: () => import('./modules/auth'), + }, + { + path: '/admin/users', + lazy: () => import('./modules/users'), + }, + ], + }, + ], + { + future: { + v7_normalizeFormMethod: true, + }, + } +); + +export const App = () => { + return ( + + + + + + ); +}; diff --git a/packages/frontend/admin/src/components/ui/accordion.tsx b/packages/frontend/admin/src/components/ui/accordion.tsx new file mode 100644 index 000000000..c2b0b9e5e --- /dev/null +++ b/packages/frontend/admin/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import { cn } from '@affine/admin/utils'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; +import * as React from 'react'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/packages/frontend/admin/src/components/ui/alert-dialog.tsx b/packages/frontend/admin/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..3fd0b60dc --- /dev/null +++ b/packages/frontend/admin/src/components/ui/alert-dialog.tsx @@ -0,0 +1,138 @@ +import { buttonVariants } from '@affine/admin/components/ui/button'; +import { cn } from '@affine/admin/utils'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/packages/frontend/admin/src/components/ui/alert.tsx b/packages/frontend/admin/src/components/ui/alert.tsx new file mode 100644 index 000000000..97caed1cc --- /dev/null +++ b/packages/frontend/admin/src/components/ui/alert.tsx @@ -0,0 +1,58 @@ +import { cn } from '@affine/admin/utils'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertDescription, AlertTitle }; diff --git a/packages/frontend/admin/src/components/ui/aspect-ratio.tsx b/packages/frontend/admin/src/components/ui/aspect-ratio.tsx new file mode 100644 index 000000000..5dfdf1e67 --- /dev/null +++ b/packages/frontend/admin/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/packages/frontend/admin/src/components/ui/avatar.tsx b/packages/frontend/admin/src/components/ui/avatar.tsx new file mode 100644 index 000000000..a0f525651 --- /dev/null +++ b/packages/frontend/admin/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +import { cn } from '@affine/admin/utils'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import * as React from 'react'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/packages/frontend/admin/src/components/ui/badge.tsx b/packages/frontend/admin/src/components/ui/badge.tsx new file mode 100644 index 000000000..9e8cf4011 --- /dev/null +++ b/packages/frontend/admin/src/components/ui/badge.tsx @@ -0,0 +1,35 @@ +import { cn } from '@affine/admin/utils'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type * as React from 'react'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/frontend/admin/src/components/ui/breadcrumb.tsx b/packages/frontend/admin/src/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..0d973e60a --- /dev/null +++ b/packages/frontend/admin/src/components/ui/breadcrumb.tsx @@ -0,0 +1,114 @@ +import { cn } from '@affine/admin/utils'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; +import * as React from 'react'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>