diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fe12f1..45ee29e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - development workflow_dispatch: # Allows manual triggering jobs: @@ -24,9 +25,26 @@ jobs: - run: pnpm install - run: pnpm lint + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: pnpm install + # Copy the example custom-seeds file to satisfy TypeScript during CI + - name: Create custom-seeds.ts from example for typecheck + run: cp apps/web/src/lib/db/seeds/custom-seeds.example.ts apps/web/src/lib/db/seeds/custom-seeds.ts + - run: pnpm check-types + build: name: Build - needs: lint + needs: [lint, typecheck] if: github.ref == 'refs/heads/main' || github.event.pull_request.base.ref == 'main' runs-on: ubuntu-latest steps: @@ -38,4 +56,7 @@ jobs: with: node-version: 20 - run: pnpm install - - run: pnpm build + # Copy the example custom-seeds file to satisfy TypeScript during CI build + - name: Create custom-seeds.ts from example for build + run: cp apps/web/src/lib/db/seeds/custom-seeds.example.ts apps/web/src/lib/db/seeds/custom-seeds.ts + - run: pnpm build diff --git a/.gitignore b/.gitignore index 7b8da95..96fab4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,38 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem +# Dependencies +node_modules +.pnp +.pnp.js -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage -# env files (can opt-in for committing if needed) -.env* -!.env.example +# Turbo +.turbo -# vercel +# Vercel .vercel -# typescript -*.tsbuildinfo -next-env.d.ts +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e69de29 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a5bb03b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e3cc49..fc83c68 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,12 @@ { + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ], "files.associations": { - "*.css": "tailwindcss", + "globals.css": "tailwindcss", + "markdown.css": "tailwindcss", "*.scss": "tailwindcss" } } diff --git a/README.md b/README.md index 63a879e..fa68f12 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ NextWiki includes a powerful search system with several capabilities: 1. Exact vector matching (highest relevance) 2. Title matching (high relevance) 3. Content matching (medium relevance) - 4. Similarity matching for typos (lower relevance) *(in progress)* + 4. Similarity matching for typos (lower relevance) _(in progress)_ When a user clicks a search result, they'll be taken directly to the page with all instances of the search term highlighted, and the view will automatically scroll to the first match. diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..4cc714a --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1 @@ +DATABASE_URL= \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..d30c946 --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,98 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ pnpm install +``` + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Deployment + +When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. + +If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: + +```bash +$ pnpm install -g @nestjs/mau +$ mau deploy +``` + +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/apps/backend/eslint.config.mjs b/apps/backend/eslint.config.mjs new file mode 100644 index 0000000..dc943dc --- /dev/null +++ b/apps/backend/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from '@repo/eslint-config/base'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..eb5e8b7 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,91 @@ +{ + "name": "backend", + "version": "0.0.1", + "type": "module", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "dev": "nest start --watch", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", + "test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch", + "test:cov": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-fastify": "^11.0.20", + "@repo/db": "workspace:*", + "joi": "^17.13.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@swc/cli": "^0.6.0", + "@swc/core": "^1.10.7", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "cross-env": "^7.0.3", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^29.7.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "useESM": true + } + ] + }, + "preset": "ts-jest/presets/default-esm", + "moduleNameMapper": { + "^(.{1,2}/.*).js$": "$1" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/apps/backend/src/app.controller.spec.ts b/apps/backend/src/app.controller.spec.ts new file mode 100644 index 0000000..f8f8bfd --- /dev/null +++ b/apps/backend/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AppController } from "./app.controller.js"; +import { AppService } from "./app.service.js"; + +describe("AppController", () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe("root", () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe("Hello World!"); + }); + }); +}); diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts new file mode 100644 index 0000000..6d6b10c --- /dev/null +++ b/apps/backend/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from "@nestjs/common"; +import { AppService } from "./app.service.js"; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts new file mode 100644 index 0000000..c8cddc2 --- /dev/null +++ b/apps/backend/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { AppController } from "./app.controller.js"; +import { AppService } from "./app.service.js"; +import { ConfigModule } from "@nestjs/config"; +import { AppConfigModule } from "./config/app-config.module.js"; +import { HealthModule } from "./health/health.module.js"; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + AppConfigModule, + HealthModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/apps/backend/src/app.service.ts b/apps/backend/src/app.service.ts new file mode 100644 index 0000000..b00a667 --- /dev/null +++ b/apps/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class AppService { + getHello(): string { + return "Hello World!"; + } +} diff --git a/apps/backend/src/config/README.md b/apps/backend/src/config/README.md new file mode 100644 index 0000000..4e8f577 --- /dev/null +++ b/apps/backend/src/config/README.md @@ -0,0 +1,23 @@ +# Config + +This module is used to load the configuration for the backend. +It uses the `@nestjs/config` package to load the configuration from the `.env` file. +It also uses the `joi` package to validate the configuration. + +## Usage + +```typescript +import { ConfigService } from "@nestjs/config"; + +const configService = new ConfigService(); + +const port = configService.get("PORT"); +``` + +## Validation + +- The configuration is validated using the `joi` package. +- The validation schema is defined in the `validation.schema.ts` file. +- The validation is done in the `app-config.module.ts` file. + +**If any required environment variables are not provided, the application will not start!** diff --git a/apps/backend/src/config/app-config.module.ts b/apps/backend/src/config/app-config.module.ts new file mode 100644 index 0000000..ee446ad --- /dev/null +++ b/apps/backend/src/config/app-config.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { validationSchema } from "./validation.schema.js"; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, // Make ConfigService available globally + envFilePath: [".env.development.local", ".env.development", ".env"], // Load env files in order of precedence + validationSchema: validationSchema, // Apply validation schema + validationOptions: { + allowUnknown: true, // Allow env vars not in the schema + abortEarly: false, // Report all validation errors + }, + }), + ], + exports: [ConfigModule], // Export ConfigModule if you need ConfigService directly elsewhere, otherwise create an AppConfigService wrapper +}) +export class AppConfigModule {} diff --git a/apps/backend/src/config/validation.schema.ts b/apps/backend/src/config/validation.schema.ts new file mode 100644 index 0000000..2981ca0 --- /dev/null +++ b/apps/backend/src/config/validation.schema.ts @@ -0,0 +1,9 @@ +import Joi from "joi"; + +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid("development", "production", "test") + .default("development"), + PORT: Joi.number().default(3001), + DATABASE_URL: Joi.string().uri().required(), +}); diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts new file mode 100644 index 0000000..d158060 --- /dev/null +++ b/apps/backend/src/health/health.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get } from "@nestjs/common"; + +/** + * Controller for health checks + * Used by deployment platforms to ensure the service is running properly + */ +@Controller("health") +export class HealthController { + /** + * Basic health check endpoint + * @returns A simple message indicating the service is running + */ + @Get() + check() { + return { + status: "ok", + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts new file mode 100644 index 0000000..5b1f8c6 --- /dev/null +++ b/apps/backend/src/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller.js"; + +/** + * Health check module + * Provides endpoints for service health monitoring + */ +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts new file mode 100644 index 0000000..99c028c --- /dev/null +++ b/apps/backend/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from "@nestjs/core"; +import { + FastifyAdapter, + NestFastifyApplication, +} from "@nestjs/platform-fastify"; +import { AppModule } from "./app.module.js"; + +async function bootstrap() { + const app = await NestFactory.create( + AppModule, + new FastifyAdapter() + ); + await app.listen(process.env.PORT ?? 3000); +} +bootstrap(); diff --git a/apps/backend/test/app.e2e-spec.ts b/apps/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..5b373ab --- /dev/null +++ b/apps/backend/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { AppModule } from "./../src/app.module.js"; + +describe("AppController (e2e)", () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it("/ (GET)", () => { + return request(app.getHttpServer()) + .get("/") + .expect(200) + .expect("Hello World!"); + }); +}); diff --git a/apps/backend/test/jest-e2e.json b/apps/backend/test/jest-e2e.json new file mode 100644 index 0000000..1c6b6bc --- /dev/null +++ b/apps/backend/test/jest-e2e.json @@ -0,0 +1,18 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "useESM": true + } + ] + }, + "preset": "ts-jest/presets/default-esm", + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + } +} diff --git a/apps/backend/tsconfig.build.json b/apps/backend/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/apps/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..92558f6 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/.env.example b/apps/web/.env.example similarity index 71% rename from .env.example rename to apps/web/.env.example index 845d8e5..6e30ef4 100644 --- a/.env.example +++ b/apps/web/.env.example @@ -10,4 +10,9 @@ GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret \ No newline at end of file +GOOGLE_CLIENT_SECRET=your-google-client-secret + +NEXT_PUBLIC_DEV_MODE=true + +# DEBUG, INFO, WARN, ERROR +OVERRIDE_MAX_LOG_LELEL= \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..6ebdea1 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +/temp + +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# drizzle +/drizzle \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..a98bfa8 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..e8759ff --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,4 @@ +import { nextJsConfig } from "@repo/eslint-config/next-js"; + +/** @type {import("eslint").Linter.Config} */ +export default nextJsConfig; diff --git a/next.config.ts b/apps/web/next.config.ts similarity index 100% rename from next.config.ts rename to apps/web/next.config.ts diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..b61f77c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,113 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.8.1", + "type": "module", + "scripts": { + "dev": "PROCESS_ORIGIN=NEXT next dev --turbopack", + "build": "next build", + "start": "PROCESS_ORIGIN=PROD next start", + "lint": "next lint", + "check-types": "tsc --noEmit", + "rebuild-rendered-html": "NODE_ENV=development env-cmd -f .env tsx scripts/rebuildRenderedHtml.ts", + "watch-css": "tailwindcss -i ./src/styles/globals.css -o ./public/output.css --watch" + }, + "dependencies": { + "@auth/drizzle-adapter": "^1.8.0", + "@codemirror/collab": "^6.1.1", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/language": "^6.11.0", + "@codemirror/language-data": "^6.5.1", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.5", + "@heroicons/react": "^2.2.0", + "@lezer/highlight": "^1.2.1", + "@radix-ui/react-icons": "^1.3.2", + "@repo/db": "workspace:*", + "@repo/logger": "workspace:*", + "@repo/ui": "workspace:*", + "@t3-oss/env-nextjs": "^0.12.0", + "@tailwindcss/cli": "^4.1.4", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.74.4", + "@tanstack/react-query-devtools": "^5.74.4", + "@trpc/client": "^11.1.0", + "@trpc/next": "^11.1.0", + "@trpc/react-query": "^11.1.0", + "@trpc/server": "^11.1.0", + "@trpc/tanstack-react-query": "^11.1.0", + "@types/react-syntax-highlighter": "^15.5.13", + "@uiw/codemirror-theme-tokyo-night-storm": "^4.23.10", + "@uiw/codemirror-theme-xcode": "^4.23.10", + "@uiw/codemirror-themes": "^4.23.10", + "@uiw/react-codemirror": "^4.23.10", + "bcryptjs": "^3.0.2", + "child_process": "^1.0.2", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "cookie": "^1.0.2", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.42.0", + "highlight.js": "^11.11.1", + "lucide-react": "^0.436.0", + "next": "15.3.1", + "next-auth": "^4.24.11", + "pg": "^8.14.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^15.6.1", + "rehype-highlight": "^7.0.2", + "rehype-stringify": "^10.0.1", + "remark": "^15.0.1", + "remark-breaks": "^4.0.0", + "remark-directive": "^4.0.0", + "remark-directive-rehype": "^0.4.2", + "remark-emoji": "^5.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "rxjs": "^7.8.2", + "server-only": "^0.0.1", + "sharp": "^0.34.1", + "sonner": "^2.0.3", + "tailwind-merge": "^2.6.0", + "unified": "^11.0.5", + "unist": "^0.0.1", + "unist-util-visit": "^5.0.0", + "ws": "^8.18.1", + "y-codemirror.next": "^0.3.5", + "y-websocket": "^3.0.0", + "yjs": "^13.6.26", + "zod": "^3.24.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.1", + "@next/eslint-plugin-next": "^15.3.1", + "@repo/eslint-config": "workspace:*", + "@repo/tailwind-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@shadcn/ui": "^0.0.4", + "@tailwindcss/postcss": "^4.1.4", + "@types/hast": "^3.0.4", + "@types/node": "^22.14.1", + "@types/pg": "^8.11.13", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/unist": "^3.0.3", + "@types/ws": "^8.18.1", + "autoprefixer": "^10.4.21", + "env-cmd": "^10.1.0", + "eslint": "^9.24.0", + "eslint-config-next": "15.3.1", + "eslint-plugin-react-hooks": "^5.2.0", + "npm-run-all": "^4.1.5", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4", + "trpc-ui": "^1.0.15", + "tsup": "^8.4.0", + "tsx": "^4.19.3", + "typescript": "^5.8.3" + } +} diff --git a/postcss.config.mjs b/apps/web/postcss.config.mjs similarity index 100% rename from postcss.config.mjs rename to apps/web/postcss.config.mjs diff --git a/public/assets/images/nextwiki-browser.png b/apps/web/public/assets/images/nextwiki-browser.png similarity index 100% rename from public/assets/images/nextwiki-browser.png rename to apps/web/public/assets/images/nextwiki-browser.png diff --git a/public/assets/images/nextwiki-edit.png b/apps/web/public/assets/images/nextwiki-edit.png similarity index 100% rename from public/assets/images/nextwiki-edit.png rename to apps/web/public/assets/images/nextwiki-edit.png diff --git a/public/assets/images/nextwiki-home-dark.png b/apps/web/public/assets/images/nextwiki-home-dark.png similarity index 100% rename from public/assets/images/nextwiki-home-dark.png rename to apps/web/public/assets/images/nextwiki-home-dark.png diff --git a/public/assets/images/nextwiki-home.png b/apps/web/public/assets/images/nextwiki-home.png similarity index 100% rename from public/assets/images/nextwiki-home.png rename to apps/web/public/assets/images/nextwiki-home.png diff --git a/public/assets/images/nextwiki-move.png b/apps/web/public/assets/images/nextwiki-move.png similarity index 100% rename from public/assets/images/nextwiki-move.png rename to apps/web/public/assets/images/nextwiki-move.png diff --git a/public/assets/images/nextwiki-page.png b/apps/web/public/assets/images/nextwiki-page.png similarity index 100% rename from public/assets/images/nextwiki-page.png rename to apps/web/public/assets/images/nextwiki-page.png diff --git a/public/assets/images/nextwiki-search.png b/apps/web/public/assets/images/nextwiki-search.png similarity index 100% rename from public/assets/images/nextwiki-search.png rename to apps/web/public/assets/images/nextwiki-search.png diff --git a/public/file.svg b/apps/web/public/file.svg similarity index 100% rename from public/file.svg rename to apps/web/public/file.svg diff --git a/public/globe.svg b/apps/web/public/globe.svg similarity index 100% rename from public/globe.svg rename to apps/web/public/globe.svg diff --git a/public/next.svg b/apps/web/public/next.svg similarity index 100% rename from public/next.svg rename to apps/web/public/next.svg diff --git a/public/vercel.svg b/apps/web/public/vercel.svg similarity index 100% rename from public/vercel.svg rename to apps/web/public/vercel.svg diff --git a/public/window.svg b/apps/web/public/window.svg similarity index 100% rename from public/window.svg rename to apps/web/public/window.svg diff --git a/apps/web/scripts/rebuildRenderedHtml.ts b/apps/web/scripts/rebuildRenderedHtml.ts new file mode 100644 index 0000000..71d20df --- /dev/null +++ b/apps/web/scripts/rebuildRenderedHtml.ts @@ -0,0 +1,14 @@ +import { markdownService } from "~/lib/services/markdown"; +import { logger } from "~/lib/utils/logger"; + +async function main() { + await markdownService.rebuildAllRenderedHtml(); +} + +// Run the main function +main().catch((error) => { + void error; + // Error details should have been printed already by internal functions + logger.error("❌ Setup script failed."); + process.exit(1); +}); diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..c6adee8 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function LoginLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..afe9c2b --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { LoginForm } from "~/components/auth/LoginForm"; +import { Suspense, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { usePermissions } from "~/components/auth/permission/client"; +import { Button } from "@repo/ui"; +import { ArrowLeftIcon } from "lucide-react"; + +export default function LoginPage() { + const router = useRouter(); + const { isAuthenticated, hasPermission } = usePermissions(); + + useEffect(() => { + if (isAuthenticated) { + router.push("/"); + } + }, [isAuthenticated, router]); + + const hasWikiReadPermission = hasPermission("wiki:page:read"); + + return ( +
+
+
+

+ Sign in to NextWiki +

+ {!hasWikiReadPermission && ( +

+ This is a private wiki. You need to be logged in to access it. +

+ )} +
+ + Loading login form...
}> + + +
+ + {/* Show the back to home button if the user has the wiki:page:read permission, otherwise they will be redirected back here so no need to show it */} + {hasWikiReadPermission && ( + + )} + + ); +} diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..ecb7a1a --- /dev/null +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { ArrowLeftIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { RegisterForm } from "~/components/auth/RegisterForm"; +import { usePermissions } from "~/components/auth/permission/client"; +import { Button } from "@repo/ui"; + +export default function RegisterPage({ + isFirstUser = false, +}: { + isFirstUser?: boolean; +}) { + const router = useRouter(); + const { isAuthenticated } = usePermissions(); + + useEffect(() => { + if (isAuthenticated) { + router.push("/"); + } + }, [isAuthenticated, router]); + + return ( +
+
+
+

+ {isFirstUser ? "Create Admin Account" : "Create Account"} +

+ {isFirstUser && ( +

+ This will be the first user and will have admin privileges +

+ )} +
+ + + + +
+
+ ); +} diff --git a/src/app/[...path]/not-found.tsx b/apps/web/src/app/[...path]/not-found.tsx similarity index 58% rename from src/app/[...path]/not-found.tsx rename to apps/web/src/app/[...path]/not-found.tsx index a8fe562..c06f3a3 100644 --- a/src/app/[...path]/not-found.tsx +++ b/apps/web/src/app/[...path]/not-found.tsx @@ -1,23 +1,21 @@ import Link from "next/link"; import { MainLayout } from "~/components/layout/MainLayout"; +import { Button } from "@repo/ui"; import { CreatePageButton } from "~/components/wiki/CreatePageButton"; export default function WikiNotFound() { return ( -
-
404
+
+
404

Wiki Page Not Found

-

+

The wiki page you're looking for doesn't exist or has been moved.

- - Browse All Pages + +
diff --git a/src/app/[...path]/page.tsx b/apps/web/src/app/[...path]/page.tsx similarity index 69% rename from src/app/[...path]/page.tsx rename to apps/web/src/app/[...path]/page.tsx index 29663ef..53961e2 100644 --- a/src/app/[...path]/page.tsx +++ b/apps/web/src/app/[...path]/page.tsx @@ -1,22 +1,27 @@ -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { MainLayout } from "~/components/layout/MainLayout"; import { WikiPage } from "~/components/wiki/WikiPage"; import { WikiEditor } from "~/components/wiki/WikiEditor"; -import { HighlightedMarkdown } from "~/components/wiki/HighlightedMarkdown"; -import { db } from "~/lib/db"; -import { wikiPages } from "~/lib/db/schema"; +import { HighlightedContent } from "~/lib/markdown/client"; +import { db } from "@repo/db"; +import { wikiPages } from "@repo/db"; import { eq } from "drizzle-orm"; import { getServerSession } from "next-auth"; import { authOptions } from "~/lib/auth"; import { Suspense } from "react"; import { PageLocationEditor } from "~/components/wiki/PageLocationEditor"; +import { renderWikiMarkdownToHtml } from "~/lib/services/markdown"; +import { authorizationService } from "~/lib/services/authorization"; + +export const dynamic = "auto"; +export const revalidate = 300; // 5 minutes +export const fetchCache = "force-cache"; async function getWikiPageByPath(path: string[]) { // Decode each path segment individually const decodedPath = path.map((segment) => decodeURIComponent(segment)); const joinedPath = decodedPath.join("/").replace("%20", " "); - // FIXME: Use dbService to get the page const page = await db.query.wikiPages.findFirst({ where: eq(wikiPages.path, joinedPath), with: { @@ -31,7 +36,25 @@ async function getWikiPageByPath(path: string[]) { }, }); - // Return null if page is not found + if ( + page?.renderedHtml && + page?.renderedHtmlUpdatedAt && + page?.renderedHtmlUpdatedAt > (page?.updatedAt ?? new Date()) + ) { + return page; + } + + // If page is found and has content, pre-render the markdown to HTML with wiki link validation + if (page && page.content) { + const renderedHtml = await renderWikiMarkdownToHtml( + page.content, + page.id, + page.path + ); + page.renderedHtml = renderedHtml; + page.renderedHtmlUpdatedAt = new Date(); + } + return page; } @@ -52,12 +75,22 @@ export default async function WikiPageView({ const resolvedParams = await params; const resolvedSearchParams = await searchParams; - const page = await getWikiPageByPath(resolvedParams.path); const session = await getServerSession(authOptions); const currentUserId = session?.user?.id ? parseInt(session.user.id) : undefined; + const canAccessPage = await authorizationService.hasPermission( + currentUserId, + "wiki:page:read" + ); + + if (!canAccessPage) { + redirect("/"); + } + + const page = await getWikiPageByPath(resolvedParams.path); + if (!page) { notFound(); } @@ -68,6 +101,13 @@ export default async function WikiPageView({ // Edit mode if (isEditMode) { + const canEditPage = await authorizationService.hasPermission( + currentUserId, + "wiki:page:update" + ); + if (!canEditPage) { + redirect("/"); + } return ( Loading...
}> - + } createdAt={new Date(page.createdAt ?? new Date())} diff --git a/apps/web/src/app/admin/[404]/page.tsx b/apps/web/src/app/admin/[404]/page.tsx new file mode 100644 index 0000000..7ea5bc3 --- /dev/null +++ b/apps/web/src/app/admin/[404]/page.tsx @@ -0,0 +1,7 @@ +import { notFound } from "next/navigation"; + +// This is a page that is used to trigger a 404 error, because we have a catch-all route for all pages in +// app/[...path]/page.tsx, and we want to trigger a 404 error for all admin pages. +export default function NotFoundTrigger() { + notFound(); +} diff --git a/apps/web/src/app/admin/assets/page.tsx b/apps/web/src/app/admin/assets/page.tsx new file mode 100644 index 0000000..d2dd008 --- /dev/null +++ b/apps/web/src/app/admin/assets/page.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useState } from "react"; +import { useTRPC } from "~/server/client"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useNotification } from "~/lib/hooks/useNotification"; +import { Input } from "@repo/ui"; +import Image from "next/image"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui"; + +export default function AssetsAdminPage() { + const notification = useNotification(); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedFileType, setSelectedFileType] = useState("all"); + + const trpc = useTRPC(); + + // Get all assets + const assetsQuery = useQuery(trpc.assets.getAll.queryOptions({})); + + // Delete asset mutation + const deleteAssetMutation = useMutation( + trpc.assets.delete.mutationOptions({ + onSuccess: () => { + notification.success("Asset deleted successfully"); + assetsQuery.refetch(); + }, + onError: (error) => { + notification.error(`Failed to delete asset: ${error.message}`); + }, + }) + ); + + // Handle asset deletion + const handleDeleteAsset = (assetId: string) => { + if (confirm("Are you sure you want to delete this asset?")) { + deleteAssetMutation.mutate({ id: assetId }); + } + }; + + // Get all unique file types + const fileTypes = assetsQuery.data + ? Array.from( + new Set( + assetsQuery.data + .map((asset) => asset.fileType?.split("/")[0]) + .filter((type): type is string => !!type) + ) + ) + : []; + + // Filter assets + const filteredAssets = assetsQuery.data + ? assetsQuery.data.filter((asset) => { + const matchesSearch = searchTerm + ? asset.fileName.toLowerCase().includes(searchTerm.toLowerCase()) + : true; + const matchesType = + selectedFileType === "all" || + asset.fileType.startsWith(selectedFileType); + return matchesSearch && matchesType; + }) + : []; + + return ( +
+

Asset Management

+ + {/* Search and filter controls */} +
+
+ ) => + setSearchTerm(e.target.value) + } + /> +
+
+ +
+
+ + {/* Loading state */} + {assetsQuery.isLoading && ( +
+
+ Loading assets... +
+ )} + + {/* Empty state */} + {!assetsQuery.isLoading && filteredAssets.length === 0 && ( +
+ + + +

+ No assets found +

+

+ {searchTerm || selectedFileType !== "all" + ? "Try adjusting your search or filter criteria" + : "Upload some files through the wiki editor to get started"} +

+
+ )} + + {/* Asset table */} + {!assetsQuery.isLoading && filteredAssets.length > 0 && ( +
+ + + + + + + + + + + + + + + {filteredAssets.map((asset) => ( + + + + + + + + + + + ))} + +
PreviewFile NameTypeSizeUploaded ByPageDateActions
+ {asset.fileType.startsWith("image/") ? ( + {asset.fileName} + ) : ( +
+ + + +
+ )} +
{asset.fileName}{asset.fileType} + {formatFileSize(asset.fileSize)} + + {asset.uploadedBy?.name || "Unknown"} + + {asset.id ? ( + + View Page + + ) : ( + "Not attached" + )} + {formatDate(asset.createdAt)} +
+ + View + + +
+
+
+ )} +
+ ); +} + +// Helper function to format file size +function formatFileSize(bytes: number): string { + if (bytes < 1024) return bytes + " B"; + else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; + else if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + " MB"; + else return (bytes / 1073741824).toFixed(1) + " GB"; +} + +// Helper function to format date +function formatDate(date: Date | string | null | undefined): string { + if (!date) return "Unknown"; + const d = new Date(date); + return d.toLocaleDateString() + " " + d.toLocaleTimeString(); +} diff --git a/apps/web/src/app/admin/dashboard/page.tsx b/apps/web/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..87f186f --- /dev/null +++ b/apps/web/src/app/admin/dashboard/page.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@repo/ui"; + +interface StatsItem { + title: string; + value: number | string; + icon: React.ReactNode; +} + +// Define type for the static part +interface StaticStatData { + title: string; + icon: React.ReactNode; +} + +// Define static parts outside the component with explicit type +const staticStatsData: StaticStatData[] = [ + { + title: "Total Pages", + icon: ( + + + + ), + }, + { + title: "Total Users", + icon: ( + + + + ), + }, + { + title: "Total Assets", + icon: ( + + + + ), + }, + { + title: "User Groups", + icon: ( + + + + ), + }, +]; + +export default function AdminDashboardPage() { + const [isLoading, setIsLoading] = useState(true); + + // Initialize state with static titles/icons and placeholder values + const [stats, setStats] = useState(() => + staticStatsData.map((item) => ({ ...item, value: "..." })) + ); + + // Simulate loading data + useEffect(() => { + const timer = setTimeout(() => { + // Create the new state manually, using non-null assertions + const newStats: StatsItem[] = [ + { + title: staticStatsData[0]!.title, + icon: staticStatsData[0]!.icon, + value: 42, + }, + { + title: staticStatsData[1]!.title, + icon: staticStatsData[1]!.icon, + value: 15, + }, + { + title: staticStatsData[2]!.title, + icon: staticStatsData[2]!.icon, + value: 87, + }, + { + title: staticStatsData[3]!.title, + icon: staticStatsData[3]!.icon, + value: 5, + }, + ]; + setStats(newStats); + setIsLoading(false); + }, 1000); + + return () => clearTimeout(timer); + }, []); // Keep empty dependency array + + return ( +
+
+

Admin Dashboard

+

Overview of your NextWiki system

+
+ +
+ {stats.map((stat, index) => ( + +
+
+ {stat.icon} +
+
+

+ {stat.title} +

+

+ {isLoading ? ( + + ) : ( + stat.value + )} +

+
+
+
+ ))} +
+ +
+ +

Recent Pages

+ {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+

+ Recent page activity will be shown here +

+
+ )} +
+ + +

System Health

+ {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+
+ Database + Healthy +
+
+ Storage + Healthy +
+
+ Cache + Healthy +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/example/page.tsx b/apps/web/src/app/admin/example/page.tsx new file mode 100644 index 0000000..857b611 --- /dev/null +++ b/apps/web/src/app/admin/example/page.tsx @@ -0,0 +1,86 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PermissionGate } from "~/components/auth/permission/server"; +import { PermissionsExample } from "~/components/auth/PermissionsExample"; +import { env } from "~/env"; + +export const metadata: Metadata = { + title: "Permission Examples | NextWiki", + description: "Server-side permission checking examples", +}; + +export default function PermissionExamplesPage() { + if (env.NODE_ENV !== "development") { + notFound(); + } + + return ( +
+

Permission Examples

+ +
+

Single Permission Check

+ + + +
+ You have permission to read wiki pages. +
+
+ +
+ You do not have permission to read wiki pages. +
+
+
+
+ +
+

+ Multiple Permissions Check (Any) +

+ + + +
+ You have permission to create or update wiki pages. +
+
+ +
+ You do not have permission to create or update wiki pages. +
+
+
+
+ +
+

With Redirect Example

+

+ In this example, if you don't have admin permissions, you would + be redirected to the login page. Since we want to show this example, + we're not using the redirect here, but it would look like: +

+
+          {`
+  
+    Admin content here
+  
+  
+    Redirecting to login...
+  
+`}
+        
+
+ +
+

+ Client-side permission checking +

+ +
+
+ ); +} diff --git a/apps/web/src/app/admin/groups/[id]/edit/page.tsx b/apps/web/src/app/admin/groups/[id]/edit/page.tsx new file mode 100644 index 0000000..502e340 --- /dev/null +++ b/apps/web/src/app/admin/groups/[id]/edit/page.tsx @@ -0,0 +1,90 @@ +import { Metadata } from "next"; +import { getServerAuthSession } from "~/lib/auth"; +import { redirect } from "next/navigation"; +import { dbService } from "~/lib/services"; +import GroupForm from "../../group-form"; +import { notFound } from "next/navigation"; + +interface EditGroupPageProps { + params: { + id: string; + }; +} + +export async function generateMetadata({ + params, +}: EditGroupPageProps): Promise { + const group = await dbService.groups.getById(parseInt(params.id)); + return { + title: `Edit ${group?.name ?? "Group"} | NextWiki`, + description: "Edit user group settings and permissions", + }; +} + +export default async function EditGroupPage({ params }: EditGroupPageProps) { + const session = await getServerAuthSession(); + + // Redirect if not logged in + if (!session?.user) { + redirect("/login"); + } + + // Redirect if not admin + if (!session.user.isAdmin) { + redirect("/"); + } + + const group = await dbService.groups.getById(parseInt(params.id)); + if (!group) { + notFound(); + } + + // Get all permissions + const permissions = await dbService.permissions.getAll(); + + // Get group permissions + const groupPermissions = await dbService.groups.getGroupPermissions(group.id); + const groupPermissionIds = groupPermissions.map((p) => p.id); + + // Get module permissions + const modulePermissions = await dbService.groups.getModulePermissions( + group.id + ); + const modulePermissionModules = modulePermissions.map((p) => p.module); + + // Get action permissions + const actionPermissions = await dbService.groups.getActionPermissions( + group.id + ); + const actionPermissionActions = actionPermissions.map((p) => p.action); + + return ( +
+

Edit Group

+ +
+

+ Edit group settings and configure its permissions. Changes will affect + all users in this group. +

+ + +
+
+ ); +} diff --git a/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx b/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx new file mode 100644 index 0000000..99c5ca0 --- /dev/null +++ b/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus, Loader2, Search } from "lucide-react"; +import { Button, Modal, Input, Checkbox } from "@repo/ui"; +import { useTRPC } from "~/server/client"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +interface AddUsersModalProps { + groupId: number; + groupName: string; +} + +export default function AddUsersModal({ + groupId, + groupName, +}: AddUsersModalProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedUsers, setSelectedUsers] = useState([]); + const [isAdding, setIsAdding] = useState(false); + + const trpc = useTRPC(); + + // Get all users + const { data: allUsers, isLoading: loadingUsers } = useQuery( + trpc.users.getAll.queryOptions(undefined, { + enabled: isModalOpen, + }) + ); + + // Get existing group users to exclude them + const { data: groupUsers } = useQuery( + trpc.groups.getGroupUsers.queryOptions( + { groupId }, + { + enabled: isModalOpen, + } + ) + ); + + // Filter users that are not in the group already and match the search query + const filteredUsers = allUsers?.filter((user) => { + // Filter out users already in the group + const alreadyInGroup = groupUsers?.some( + (groupUser) => groupUser.id === user.id + ); + if (alreadyInGroup) return false; + + // Filter by search query + if (!searchQuery) return true; + + const query = searchQuery.toLowerCase(); + return ( + user.name?.toLowerCase().includes(query) || + false || + user.email.toLowerCase().includes(query) + ); + }); + + const addUsersMutation = useMutation( + trpc.groups.addUsers.mutationOptions({ + onSuccess: () => { + toast.success("Users added to group successfully"); + setIsModalOpen(false); + // Refresh the page to update the user list + window.location.reload(); + }, + onError: (error) => { + toast.error(error.message || "Failed to add users to group"); + setIsAdding(false); + }, + }) + ); + + const handleAddUsers = async () => { + if (selectedUsers.length === 0) { + toast.error("Please select at least one user"); + return; + } + + setIsAdding(true); + try { + await addUsersMutation.mutate({ + groupId, + userIds: selectedUsers, + }); + } catch { + setIsAdding(false); + } + }; + + const toggleUserSelection = (userId: number) => { + setSelectedUsers((prev) => + prev.includes(userId) + ? prev.filter((id) => id !== userId) + : [...prev, userId] + ); + }; + + const handleCloseModal = () => { + if (!isAdding) { + setIsModalOpen(false); + setSelectedUsers([]); + setSearchQuery(""); + } + }; + + return ( + <> + + + {isModalOpen && ( + +
+

+ Add Users to {groupName} +

+ +
+ + ) => + setSearchQuery(e.target.value) + } + /> +
+ +
+ {loadingUsers ? ( +
+ +
+ ) : filteredUsers && filteredUsers.length > 0 ? ( +
+ + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + ))} + +
NameEmail
+ toggleUserSelection(user.id)} + /> + {user.name}{user.email}
+
+ ) : ( +

+ {searchQuery + ? "No matching users found" + : "No users available to add to this group"} +

+ )} +
+ +
+
+ {selectedUsers.length} users selected +
+
+ + +
+
+
+
+ )} + + ); +} diff --git a/apps/web/src/app/admin/groups/[id]/users/page.tsx b/apps/web/src/app/admin/groups/[id]/users/page.tsx new file mode 100644 index 0000000..9a64cda --- /dev/null +++ b/apps/web/src/app/admin/groups/[id]/users/page.tsx @@ -0,0 +1,98 @@ +import { Metadata } from "next"; +import { getServerAuthSession } from "~/lib/auth"; +import { redirect } from "next/navigation"; +import { dbService } from "~/lib/services"; +import Link from "next/link"; +import { Button } from "@repo/ui"; +import { ArrowLeft } from "lucide-react"; +import RemoveUserModal from "./remove-user-modal"; +import AddUsersModal from "./add-users-modal"; + +export const metadata: Metadata = { + title: "Admin - Group Users | NextWiki", + description: "Manage users in a group", +}; + +export default async function GroupUsersPage({ + params, +}: { + params: { id: string }; +}) { + const session = await getServerAuthSession(); + + // Redirect if not logged in + if (!session?.user) { + redirect("/login"); + } + + // Redirect if not admin + if (!session.user.isAdmin) { + redirect("/"); + } + + const groupId = parseInt(params.id); + const group = await dbService.groups.getById(groupId); + + if (!group) { + redirect("/admin/groups"); + } + + const users = await dbService.groups.getGroupUsers(groupId); + + return ( +
+
+ + + +

Group Members: {group.name}

+
+ +
+
+

Users in this group

+ +
+

+ Users in this group inherit all permissions assigned to the group. You + can add or remove users from this group. +

+ + {users.length > 0 ? ( +
+ + + + + + + + + + {users.map((user) => ( + + + + + + ))} + +
NameEmailActions
{user.name}{user.email} + +
+
+ ) : ( +

+ No users in this group yet. +

+ )} +
+
+ ); +} diff --git a/apps/web/src/app/admin/groups/[id]/users/remove-user-modal.tsx b/apps/web/src/app/admin/groups/[id]/users/remove-user-modal.tsx new file mode 100644 index 0000000..a7ad698 --- /dev/null +++ b/apps/web/src/app/admin/groups/[id]/users/remove-user-modal.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { Trash2, Loader2 } from "lucide-react"; +import { Button, Modal } from "@repo/ui"; +import { useTRPC } from "~/server/client"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +interface RemoveUserModalProps { + groupId: number; + userId: number; + userName: string; +} + +export default function RemoveUserModal({ + groupId, + userId, + userName, +}: RemoveUserModalProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + const trpc = useTRPC(); + + const removeUserMutation = useMutation( + trpc.groups.removeUsers.mutationOptions({ + onSuccess: () => { + toast.success("User removed from group successfully"); + setIsModalOpen(false); + // Refresh the page to update the user list + window.location.reload(); + }, + onError: (error) => { + toast.error(error.message || "Failed to remove user from group"); + setIsRemoving(false); + }, + }) + ); + + const handleRemove = async () => { + setIsRemoving(true); + try { + await removeUserMutation.mutate({ + groupId, + userIds: [userId], + }); + } catch { + setIsRemoving(false); + } + }; + + return ( + <> + + + {isModalOpen && ( + !isRemoving && setIsModalOpen(false)} + size="sm" + position="center" + animation="fade" + closeOnEscape={!isRemoving} + showCloseButton={!isRemoving} + > +
+

Remove User from Group

+

+ Are you sure you want to remove{" "} + {userName} from this group? + This action cannot be undone. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/apps/web/src/app/admin/groups/group-form.tsx b/apps/web/src/app/admin/groups/group-form.tsx new file mode 100644 index 0000000..8f89a59 --- /dev/null +++ b/apps/web/src/app/admin/groups/group-form.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Button, + Input, + Textarea, + Checkbox, + Label, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@repo/ui"; +import { toast } from "sonner"; +import { useTRPC } from "~/server/client"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { logger } from "~/lib/utils/logger"; + +interface GroupFormProps { + group?: { + id: number; + name: string; + description: string | null; + isSystem?: boolean; + isEditable?: boolean; + allowUserAssignment?: boolean; + }; + permissions: { + id: number; + name: string; + description: string | null; + module: string; + resource: string; + action: string; + }[]; + groupPermissions?: number[]; + groupModulePermissions?: string[]; + groupActionPermissions?: string[]; +} + +export default function GroupForm({ + group, + permissions, + groupPermissions = [], + groupModulePermissions = [], + groupActionPermissions = [], +}: GroupFormProps) { + const router = useRouter(); + const [name, setName] = useState(group?.name ?? ""); + const [description, setDescription] = useState(group?.description ?? ""); + const [selectedPermissions, setSelectedPermissions] = + useState(groupPermissions); + const [selectedModules, setSelectedModules] = useState( + groupModulePermissions + ); + const [selectedActions, setSelectedActions] = useState( + groupActionPermissions + ); + + const isSystem = group?.isSystem ?? false; + // const isEditable = group?.isEditable ?? true; + // const allowUserAssignment = group?.allowUserAssignment ?? true; + + const trpc = useTRPC(); + + // Fetch available modules and actions + const { data: availableModules = [] } = useQuery( + trpc.permissions.getModules.queryOptions() + ); + const { data: availableActions = [] } = useQuery( + trpc.permissions.getActions.queryOptions() + ); + + const createGroup = useMutation( + trpc.groups.create.mutationOptions({ + onSuccess: () => { + toast.success("Group created successfully"); + router.push("/admin/groups"); + }, + onError: (error: unknown) => { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("An unknown error occurred"); + } + }, + }) + ); + + const updateGroup = useMutation( + trpc.groups.update.mutationOptions({ + onSuccess: () => { + toast.success("Group updated successfully"); + router.push("/admin/groups"); + }, + onError: (error: unknown) => { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("An unknown error occurred"); + } + }, + }) + ); + + const addPermissions = useMutation( + trpc.groups.addPermissions.mutationOptions({ + onError: (error: unknown) => { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("An unknown error occurred"); + } + }, + }) + ); + + const addModulePermissions = useMutation( + trpc.groups.addModulePermissions.mutationOptions({ + onError: (error: unknown) => { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("An unknown error occurred"); + } + }, + }) + ); + + const addActionPermissions = useMutation( + trpc.groups.addActionPermissions.mutationOptions({ + onError: (error: unknown) => { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("An unknown error occurred"); + } + }, + }) + ); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (group) { + // Update existing group + const updatedGroup = await updateGroup.mutateAsync({ + id: group.id, + name, + description, + }); + + // Update permissions + await addPermissions.mutateAsync({ + groupId: updatedGroup.id, + permissionIds: selectedPermissions, + }); + + // Update module permissions + await addModulePermissions.mutateAsync({ + groupId: updatedGroup.id, + permissions: selectedModules.map((module) => ({ + module, + isAllowed: true, + })), + }); + + // Update action permissions + await addActionPermissions.mutateAsync({ + groupId: updatedGroup.id, + permissions: selectedActions.map((action) => ({ + action, + isAllowed: true, + })), + }); + } else { + // Create new group + const newGroup = await createGroup.mutateAsync({ + name, + description, + }); + + if (!newGroup) { + throw new Error("New group is undefined"); + } + + // Add permissions + await addPermissions.mutateAsync({ + groupId: newGroup.id, + permissionIds: selectedPermissions, + }); + + // Add module permissions + await addModulePermissions.mutateAsync({ + groupId: newGroup.id, + permissions: selectedModules.map((module) => ({ + module, + isAllowed: true, + })), + }); + + // Add action permissions + await addActionPermissions.mutateAsync({ + groupId: newGroup.id, + permissions: selectedActions.map((action) => ({ + action, + isAllowed: true, + })), + }); + } + } catch (error) { + logger.error("Error saving group:", error); + } + }; + + // Group permissions by module + const permissionsByModule = permissions.reduce( + (acc, permission) => { + if (!acc[permission.module]) { + acc[permission.module] = []; + } + acc[permission.module]?.push(permission); + return acc; + }, + {} as Record + ); + + // Check if a permission is allowed based on module and action permissions + const isPermissionAllowed = (permission: (typeof permissions)[0]) => { + // If no modules are selected, all modules are allowed + if (selectedModules.length === 0) { + // If no actions are selected, all actions are allowed + if (selectedActions.length === 0) return true; + // Otherwise, check if the action is allowed + return selectedActions.includes(permission.action); + } + // If modules are selected, check if the module is allowed + if (!selectedModules.includes(permission.module)) return false; + // If actions are selected, check if the action is allowed + if ( + selectedActions.length > 0 && + !selectedActions.includes(permission.action) + ) + return false; + return true; + }; + + return ( +
+
+
+ + ) => + setName(e.target.value) + } + required + disabled={isSystem} + /> +
+
+ +