This commit is contained in:
sianida26 2024-05-04 04:03:40 +07:00
parent 48812f53fd
commit a34eba6777
268 changed files with 9825 additions and 6831 deletions

5
.env
View File

@ -1,5 +0,0 @@
DATABASE_URL=mysql://root:root@localhost:3306/dashboard_template
JWT_SECRET=
ERROR_LOG_PATH=./logs

View File

@ -1,2 +0,0 @@
WS_PORT=4001
WS_HOST=ws://localhost:$WS_PORT

10
.eslintrc.js Normal file
View File

@ -0,0 +1,10 @@
// This configuration only applies to the package manager root.
/** @type {import("eslint").Linter.Config} */
module.exports = {
ignorePatterns: ["apps/**", "packages/**"],
extends: ["@repo/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};

View File

@ -1,11 +0,0 @@
{
"extends": "next/core-web-vitals",
"plugins": ["@typescript-eslint"],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-unused-vars": "warn"
}
}

50
.gitignore vendored
View File

@ -1,36 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
# Dependencies
node_modules
.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# next.js
/.next/
/out/
# Testing
coverage
# production
/build
# Turbo
.turbo
# misc
.DS_Store
*.pem
# Vercel
.vercel
# debug
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Misc
.DS_Store
*.pem

0
.npmrc Normal file
View File

15
.vscode/launch.json vendored
View File

@ -1,15 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
}

View File

@ -1,36 +1,81 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
# Turborepo starter
## Getting Started
This is an official starter Turborepo.
First, run the development server:
## Using this example
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
Run the following command:
```sh
npx create-turbo@latest
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## What's inside?
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This Turborepo includes the following packages/apps:
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
### Apps and Packages
## Learn More
- `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app
- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
To learn more about Next.js, take a look at the following resources:
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
- [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.
### Utilities
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
This Turborepo has some additional tools already setup for you:
## Deploy on Vercel
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
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.
### Build
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
To build all apps and packages, run the following command:
```
cd my-turborepo
pnpm build
```
### Develop
To develop all apps and packages, run the following command:
```
cd my-turborepo
pnpm dev
```
### Remote Caching
Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup), then enter the following commands:
```
cd my-turborepo
npx turbo login
```
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
```
npx turbo link
```
## Useful Links
Learn more about the power of Turborepo:
- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
- [Caching](https://turbo.build/repo/docs/core-concepts/caching)
- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
- [Configuration Options](https://turbo.build/repo/docs/reference/configuration)
- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference)

1
apps/backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

8
apps/backend/README.md Normal file
View File

@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

View File

@ -0,0 +1,17 @@
import "dotenv/config";
import type { Config } from "drizzle-kit";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is not set");
}
export default {
schema: "./src/drizzle/schema/*",
out: "./src/drizzle/migrations",
driver: "pg",
dbCredentials: {
connectionString: databaseUrl,
},
} satisfies Config;

36
apps/backend/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "backend",
"scripts": {
"dev": "tsx watch src/index.ts",
"db:generate": "drizzle-kit generate:pg",
"db:push": "drizzle-kit push:pg",
"db:seed": "tsx src/drizzle/seed.ts",
"db:migrate": "tsx src/drizzle/migration.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@hono/node-server": "^1.11.0",
"@hono/zod-validator": "^0.2.1",
"@paralleldrive/cuid2": "^2.2.2",
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.9",
"hono": "^4.2.9",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"postgres": "^3.4.4",
"zod": "^3.23.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.24",
"drizzle-kit": "^0.20.17",
"pg": "^8.11.5",
"tsx": "^4.7.1"
},
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.d.ts"
}
}

View File

@ -0,0 +1,42 @@
const permissionsData = [
{
code: "users.readAll",
},
{
code: "users.create",
},
{
code: "users.update",
},
{
code: "users.delete",
},
{
code: "users.restore",
},
{
code: "permissions.read",
},
{
code: "roles.read",
},
{
code: "roles.create",
},
{
code: "roles.update",
},
{
code: "roles.delete",
},
] as const;
export type SpecificPermissionCode = (typeof permissionsData)[number]["code"];
export type PermissionCode =
| SpecificPermissionCode
| "*"
| "authenticated-only"
| "guest-only";
export default permissionsData;

View File

@ -1,6 +1,4 @@
import exportedPermissionData, {
SpecificPermissionCode,
} from "../../permission/data/initialPermissions";
import permissionsData, { SpecificPermissionCode } from "./permissions";
export type RoleData = {
code: RoleCode;
@ -17,9 +15,7 @@ const roleData: RoleData[] = [
"Has full access to the system and can manage all features and settings",
isActive: true,
name: "Super Admin",
permissions: exportedPermissionData.map(
(x) => x.code as SpecificPermissionCode
),
permissions: permissionsData.map((permission) => permission.code),
},
];

View File

@ -0,0 +1,19 @@
import { SidebarMenu } from "../types";
const sidebarMenus: SidebarMenu[] = [
{
label: "Dashboard",
icon: { tb: "TbLayoutDashboard" },
allowedPermissions: ["*"],
link: "/",
},
{
label: "Users",
icon: { tb: "TbUsers" },
allowedPermissions: ["permissions.read"],
link: "/users",
color: "red",
},
];
export default sidebarMenus;

View File

@ -0,0 +1,29 @@
import { configDotenv } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as usersSchema from "./schema/users";
import * as permissionsSchema from "./schema/permissions";
import * as rolesSchema from "./schema/roles";
import * as permissionsToRolesSchema from "./schema/permissionsToRoles";
import * as permissionsToUsersSchema from "./schema/permissionsToUsers";
import * as rolesToUsersSchema from "./schema/rolesToUsers";
configDotenv();
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is not set");
const queryClient = postgres(dbUrl);
const db = drizzle(queryClient, {
schema: {
...usersSchema,
...permissionsSchema,
...rolesSchema,
...permissionsToRolesSchema,
...permissionsToUsersSchema,
...rolesToUsersSchema,
},
});
export default db;

View File

@ -0,0 +1,19 @@
import { configDotenv } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
configDotenv();
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is not set");
const migrationClient = postgres(dbUrl, { max: 1 });
migrate(drizzle(migrationClient), {
migrationsFolder: "./src/drizzle/migrations",
}).then(() => {
console.log("Migrations complete");
process.exit(0);
});

View File

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"username" varchar NOT NULL,
"email" varchar,
"password" text NOT NULL,
"is_enable" boolean DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"deleted_at" timestamp,
CONSTRAINT "users_username_unique" UNIQUE("username")
);

View File

@ -0,0 +1,2 @@
ALTER TABLE "users" ALTER COLUMN "id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "id" DROP DEFAULT;

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS "permissions" (
"id" text PRIMARY KEY NOT NULL,
"code" varchar(50) NOT NULL,
"description" varchar(255),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "permissions_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "permissions_to_users" (
"id" text NOT NULL,
CONSTRAINT "permissions_to_users_id_id_pk" PRIMARY KEY("id","id")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "permissions_to_users" ADD CONSTRAINT "permissions_to_users_id_users_id_fk" FOREIGN KEY ("id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "permissions_to_users" ADD CONSTRAINT "permissions_to_users_id_permissions_id_fk" FOREIGN KEY ("id") REFERENCES "permissions"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -0,0 +1,19 @@
ALTER TABLE "permissions_to_users" RENAME COLUMN "id" TO "userId";--> statement-breakpoint
ALTER TABLE "permissions_to_users" DROP CONSTRAINT "permissions_to_users_id_users_id_fk";
--> statement-breakpoint
ALTER TABLE "permissions_to_users" DROP CONSTRAINT "permissions_to_users_id_permissions_id_fk";
--> statement-breakpoint
ALTER TABLE "permissions_to_users" DROP CONSTRAINT "permissions_to_users_id_id_pk";--> statement-breakpoint
ALTER TABLE "permissions_to_users" ADD CONSTRAINT "permissions_to_users_userId_permissionId_pk" PRIMARY KEY("userId","permissionId");--> statement-breakpoint
ALTER TABLE "permissions_to_users" ADD COLUMN "permissionId" text NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "permissions_to_users" ADD CONSTRAINT "permissions_to_users_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "permissions_to_users" ADD CONSTRAINT "permissions_to_users_permissionId_permissions_id_fk" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS "permissions_to_roles" (
"roleId" text NOT NULL,
"permissionId" text NOT NULL,
CONSTRAINT "permissions_to_roles_roleId_permissionId_pk" PRIMARY KEY("roleId","permissionId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "roles" (
"id" text PRIMARY KEY NOT NULL,
"code" varchar(50) NOT NULL,
"name" varchar(255) NOT NULL,
"description" varchar(255),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "roles_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "roles_to_users" (
"userId" text NOT NULL,
"roleId" text NOT NULL,
CONSTRAINT "roles_to_users_userId_roleId_pk" PRIMARY KEY("userId","roleId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "permissions_to_roles" ADD CONSTRAINT "permissions_to_roles_roleId_users_id_fk" FOREIGN KEY ("roleId") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "permissions_to_roles" ADD CONSTRAINT "permissions_to_roles_permissionId_permissions_id_fk" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "roles_to_users" ADD CONSTRAINT "roles_to_users_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "roles_to_users" ADD CONSTRAINT "roles_to_users_roleId_roles_id_fk" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -0,0 +1,91 @@
{
"id": "c5fd8300-ffba-40cf-b8d1-fb2d5cb17334",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "5",
"dialect": "pg",
"tables": {
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_enable": {
"name": "is_enable",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,90 @@
{
"id": "1d1b923d-587d-4c8f-91f0-fe32e4dc9907",
"prevId": "c5fd8300-ffba-40cf-b8d1-fb2d5cb17334",
"version": "5",
"dialect": "pg",
"tables": {
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_enable": {
"name": "is_enable",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,191 @@
{
"id": "2bf359eb-f1b7-4049-9743-5713887eae25",
"prevId": "1d1b923d-587d-4c8f-91f0-fe32e4dc9907",
"version": "5",
"dialect": "pg",
"tables": {
"permissions": {
"name": "permissions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"permissions_code_unique": {
"name": "permissions_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
}
},
"permissions_to_users": {
"name": "permissions_to_users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"permissions_to_users_id_users_id_fk": {
"name": "permissions_to_users_id_users_id_fk",
"tableFrom": "permissions_to_users",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"permissions_to_users_id_permissions_id_fk": {
"name": "permissions_to_users_id_permissions_id_fk",
"tableFrom": "permissions_to_users",
"tableTo": "permissions",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"permissions_to_users_id_id_pk": {
"name": "permissions_to_users_id_id_pk",
"columns": [
"id",
"id"
]
}
},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_enable": {
"name": "is_enable",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,197 @@
{
"id": "0cf959e6-de9a-4b7c-94ea-5b49880302f1",
"prevId": "2bf359eb-f1b7-4049-9743-5713887eae25",
"version": "5",
"dialect": "pg",
"tables": {
"permissions": {
"name": "permissions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"permissions_code_unique": {
"name": "permissions_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
}
},
"permissions_to_users": {
"name": "permissions_to_users",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permissionId": {
"name": "permissionId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"permissions_to_users_userId_users_id_fk": {
"name": "permissions_to_users_userId_users_id_fk",
"tableFrom": "permissions_to_users",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"permissions_to_users_permissionId_permissions_id_fk": {
"name": "permissions_to_users_permissionId_permissions_id_fk",
"tableFrom": "permissions_to_users",
"tableTo": "permissions",
"columnsFrom": [
"permissionId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"permissions_to_users_userId_permissionId_pk": {
"name": "permissions_to_users_userId_permissionId_pk",
"columns": [
"userId",
"permissionId"
]
}
},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_enable": {
"name": "is_enable",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,367 @@
{
"id": "06ebf3b9-d0c5-414c-bcab-9f22fab750c4",
"prevId": "0cf959e6-de9a-4b7c-94ea-5b49880302f1",
"version": "5",
"dialect": "pg",
"tables": {
"permissions": {
"name": "permissions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"permissions_code_unique": {
"name": "permissions_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
}
},
"permissions_to_roles": {
"name": "permissions_to_roles",
"schema": "",
"columns": {
"roleId": {
"name": "roleId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permissionId": {
"name": "permissionId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"permissions_to_roles_roleId_users_id_fk": {
"name": "permissions_to_roles_roleId_users_id_fk",
"tableFrom": "permissions_to_roles",
"tableTo": "users",
"columnsFrom": [
"roleId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"permissions_to_roles_permissionId_permissions_id_fk": {
"name": "permissions_to_roles_permissionId_permissions_id_fk",
"tableFrom": "permissions_to_roles",
"tableTo": "permissions",
"columnsFrom": [
"permissionId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"permissions_to_roles_roleId_permissionId_pk": {
"name": "permissions_to_roles_roleId_permissionId_pk",
"columns": [
"roleId",
"permissionId"
]
}
},
"uniqueConstraints": {}
},
"permissions_to_users": {
"name": "permissions_to_users",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permissionId": {
"name": "permissionId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"permissions_to_users_userId_users_id_fk": {
"name": "permissions_to_users_userId_users_id_fk",
"tableFrom": "permissions_to_users",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"permissions_to_users_permissionId_permissions_id_fk": {
"name": "permissions_to_users_permissionId_permissions_id_fk",
"tableFrom": "permissions_to_users",
"tableTo": "permissions",
"columnsFrom": [
"permissionId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"permissions_to_users_userId_permissionId_pk": {
"name": "permissions_to_users_userId_permissionId_pk",
"columns": [
"userId",
"permissionId"
]
}
},
"uniqueConstraints": {}
},
"roles": {
"name": "roles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"roles_code_unique": {
"name": "roles_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
}
},
"roles_to_users": {
"name": "roles_to_users",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"roleId": {
"name": "roleId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"roles_to_users_userId_users_id_fk": {
"name": "roles_to_users_userId_users_id_fk",
"tableFrom": "roles_to_users",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"roles_to_users_roleId_roles_id_fk": {
"name": "roles_to_users_roleId_roles_id_fk",
"tableFrom": "roles_to_users",
"tableTo": "roles",
"columnsFrom": [
"roleId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"roles_to_users_userId_roleId_pk": {
"name": "roles_to_users_userId_roleId_pk",
"columns": [
"userId",
"roleId"
]
}
},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_enable": {
"name": "is_enable",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,41 @@
{
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1714235312241,
"tag": "0000_watery_phantom_reporter",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1714304745571,
"tag": "0001_robust_misty_knight",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1714314063411,
"tag": "0002_smooth_prism",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1714314149914,
"tag": "0003_brave_khan",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1714321209072,
"tag": "0004_uneven_jamie_braddock",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,23 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
import { permissionsToUsers } from "./permissionsToUsers";
import { permissionsToRoles } from "./permissionsToRoles";
export const permissionsSchema = pgTable("permissions", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
code: varchar("code", { length: 50 }).notNull().unique(),
description: varchar("description", { length: 255 }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const permissionsRelations = relations(
permissionsSchema,
({ many }) => ({
permissionsToUsers: many(permissionsToUsers),
permissionsToRoles: many(permissionsToRoles),
})
);

View File

@ -0,0 +1,35 @@
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { permissionsSchema } from "./permissions";
import { relations } from "drizzle-orm";
import { rolesSchema } from "./roles";
export const permissionsToRoles = pgTable(
"permissions_to_roles",
{
roleId: text("roleId")
.notNull()
.references(() => rolesSchema.id),
permissionId: text("permissionId")
.notNull()
.references(() => permissionsSchema.id),
},
(table) => ({
pk: primaryKey({
columns: [table.roleId, table.permissionId],
}),
})
);
export const permissionsToRolesRelations = relations(
permissionsToRoles,
({ one }) => ({
role: one(rolesSchema, {
fields: [permissionsToRoles.roleId],
references: [rolesSchema.id],
}),
permission: one(permissionsSchema, {
fields: [permissionsToRoles.permissionId],
references: [permissionsSchema.id],
}),
})
);

View File

@ -0,0 +1,35 @@
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { users } from "./users";
import { permissionsSchema } from "./permissions";
import { relations } from "drizzle-orm";
export const permissionsToUsers = pgTable(
"permissions_to_users",
{
userId: text("userId")
.notNull()
.references(() => users.id),
permissionId: text("permissionId")
.notNull()
.references(() => permissionsSchema.id),
},
(table) => ({
pk: primaryKey({
columns: [table.userId, table.permissionId],
}),
})
);
export const permissionsToUsersRelations = relations(
permissionsToUsers,
({ one }) => ({
user: one(users, {
fields: [permissionsToUsers.userId],
references: [users.id],
}),
permission: one(permissionsSchema, {
fields: [permissionsToUsers.permissionId],
references: [permissionsSchema.id],
}),
})
);

View File

@ -0,0 +1,19 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
import { permissionsToRoles } from "./permissionsToRoles";
export const rolesSchema = pgTable("roles", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
code: varchar("code", { length: 50 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
description: varchar("description", { length: 255 }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const rolesRelations = relations(rolesSchema, ({ many }) => ({
permissionsToRoles: many(permissionsToRoles),
}));

View File

@ -0,0 +1,32 @@
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { users } from "./users";
import { relations } from "drizzle-orm";
import { rolesSchema } from "./roles";
export const rolesToUsers = pgTable(
"roles_to_users",
{
userId: text("userId")
.notNull()
.references(() => users.id),
roleId: text("roleId")
.notNull()
.references(() => rolesSchema.id),
},
(table) => ({
pk: primaryKey({
columns: [table.userId, table.roleId],
}),
})
);
export const rolesToUsersRelations = relations(rolesToUsers, ({ one }) => ({
user: one(users, {
fields: [rolesToUsers.userId],
references: [users.id],
}),
role: one(rolesSchema, {
fields: [rolesToUsers.roleId],
references: [rolesSchema.id],
}),
}));

View File

@ -0,0 +1,30 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
boolean,
pgTable,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { permissionsToUsers } from "./permissionsToUsers";
import { rolesToUsers } from "./rolesToUsers";
export const users = pgTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
name: varchar("name", { length: 255 }).notNull(),
username: varchar("username").notNull().unique(),
email: varchar("email"),
password: text("password").notNull(),
isEnabled: boolean("is_enable").default(true),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deleted_at", { mode: "date" }),
});
export const usersRelations = relations(users, ({ many }) => ({
permissionsToUsers: many(permissionsToUsers),
rolesToUsers: many(rolesToUsers),
}));

View File

@ -0,0 +1,18 @@
import db from ".";
import permissionSeeder from "./seeds/permissionSeeder";
import roleSeeder from "./seeds/rolesSeeder";
import userSeeder from "./seeds/userSeeder";
(async () => {
console.time("Done seeding");
// await userSeeder
await permissionSeeder();
await roleSeeder();
await userSeeder();
})().then(() => {
console.log("\n");
console.timeEnd("Done seeding");
process.exit(0);
});
export {};

View File

@ -0,0 +1,17 @@
import permissionsData from "@/data/permissions";
import db from "..";
import { permissionsSchema } from "../schema/permissions";
const permissionSeeder = async () => {
const permissionsSeedData =
permissionsData as unknown as (typeof permissionsSchema.$inferInsert)[];
console.log("Seeding permissions...");
await db
.insert(permissionsSchema)
.values(permissionsSeedData)
.onConflictDoNothing();
};
export default permissionSeeder;

View File

@ -0,0 +1,78 @@
import exportedRoleData from "@/data/roles";
import { rolesSchema } from "../schema/roles";
import db from "..";
import { permissionsToRoles } from "../schema/permissionsToRoles";
import { permissionsSchema } from "../schema/permissions";
import { eq } from "drizzle-orm";
const roleSeeder = async () => {
console.log("Seeding roles...");
const memoizedPermissions: Map<string, string> = new Map();
for (let role of exportedRoleData) {
let insertedRole = (
await db
.insert(rolesSchema)
.values(role)
.returning()
.onConflictDoNothing()
)[0];
if (insertedRole) {
console.log(`Role ${role.name} inserted`);
} else {
console.warn(`Role ${role.name} already exists`);
insertedRole = (
await db
.select()
.from(rolesSchema)
.where(eq(rolesSchema.name, role.name))
)[0];
}
for (let permissionCode of role.permissions) {
if (!memoizedPermissions.has(permissionCode)) {
const permission = (
await db
.select({ id: permissionsSchema.id })
.from(permissionsSchema)
.where(eq(permissionsSchema.code, permissionCode))
)[0];
if (!permission)
throw new Error(
`Permission ${permissionCode} does not exists in database`
);
memoizedPermissions.set(permissionCode, permission.id);
}
console.log("here");
const insertedPermission = await db
.insert(permissionsToRoles)
.values({
roleId: insertedRole.id,
permissionId: memoizedPermissions.get(permissionCode)!,
})
.onConflictDoNothing()
.returning();
// .catch((e) =>
// console.log("The permission might already been set")
// );
if (insertedPermission) {
console.log(
`Permission ${permissionCode} inserted to role ${role.name}`
);
} else {
console.warn(
`Permission ${permissionCode} already exists in role ${role.name}`
);
}
}
}
};
export default roleSeeder;

View File

@ -0,0 +1,63 @@
import { hashPassword } from "@/utils/passwordUtils";
import { users } from "../schema/users";
import db from "..";
import { rolesSchema } from "../schema/roles";
import { eq } from "drizzle-orm";
import { rolesToUsers } from "../schema/rolesToUsers";
const userSeeder = async () => {
const usersData: (typeof users.$inferInsert & { roles: string[] })[] = [
{
name: "Super Admin",
password: await hashPassword("123456"),
username: "superadmin",
roles: ["super-admin"],
},
];
console.log("Seeding users...");
const memoizedRoleIds: Map<string, string> = new Map();
for (let user of usersData) {
const insertedUser = (
await db
.insert(users)
.values(usersData)
.onConflictDoNothing()
.returning()
)[0];
if (insertedUser) {
for (let roleCode of user.roles) {
if (!memoizedRoleIds.has(roleCode)) {
const role = (
await db
.select({ id: rolesSchema.id })
.from(rolesSchema)
.where(eq(rolesSchema.code, roleCode))
)[0];
if (!role)
throw new Error(
`Role ${roleCode} does not exists on database`
);
memoizedRoleIds.set(roleCode, role.id);
}
await db.insert(rolesToUsers).values({
roleId: memoizedRoleIds.get(roleCode)!,
userId: insertedUser.id,
});
console.log(`User ${user.name} created`);
}
} else {
console.log(`User ${user.name} already exists`);
}
}
// await db.insert(users).values(usersData).onConflictDoNothing().returning();
};
export default userSeeder;

122
apps/backend/src/index.ts Normal file
View File

@ -0,0 +1,122 @@
import { serve } from "@hono/node-server";
import { configDotenv } from "dotenv";
import { Hono } from "hono";
import authRoutes from "./routes/auth/route";
import usersRoute from "./routes/users/route";
import { verifyAccessToken } from "./utils/authUtils";
import permissionRoutes from "./routes/permissions/route";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { getSignedCookie } from "hono/cookie";
import dashboardRoutes from "./routes/dashboard/routes";
import rolesRoute from "./routes/roles/route";
import { logger } from "hono/logger";
configDotenv();
export type HonoVariables = {
uid?: string;
};
const app = new Hono<{ Variables: HonoVariables }>();
// app.use(async (c, next) => {
// const authHeader = c.req.header("Authorization");
// if (authHeader && authHeader.startsWith("Bearer ")) {
// const token = authHeader.substring(7);
// const payload = await verifyAccessToken(token);
// if (payload) c.set("uid", payload.uid);
// }
// await next();
// });
const routes = app
.use(logger())
.use(
cors({
origin: "*",
})
)
.use(async (c, next) => {
const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret)
throw new HTTPException(500, {
message: "The 'COOKIE_SECRET' env is not set",
});
const accessToken = await getSignedCookie(
c,
cookieSecret,
"access_token",
"secure"
);
if (accessToken) {
const payload = await verifyAccessToken(accessToken);
if (payload) c.set("uid", payload.uid);
} else {
const authHeader = c.req.header("Authorization");
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
const payload = await verifyAccessToken(token);
if (payload) c.set("uid", payload.uid);
}
}
await next();
})
.use(async (c, next) => {
console.log("Incoming request:", c.req.path);
await next();
console.log("Outgoing response:", c.res.status);
if (c.res.status !== 200) {
console.log(await c.res.text());
}
})
.get("/test", (c) => {
return c.json({
message: "Server is up",
} as const);
})
.route("/auth", authRoutes)
.route("/users", usersRoute)
.route("/permissions", permissionRoutes)
.route("/dashboard", dashboardRoutes)
.route("/roles", rolesRoute)
.onError((err, c) => {
if (err instanceof HTTPException) {
console.log(err);
return c.json(
{
message: err.message,
},
err.status
);
} else {
console.error(err);
return c.json(
{
message:
"Something is wrong in our side. We're working to fix it",
},
500
);
}
});
const port = +(process.env.APP_PORT ?? 3000);
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port,
});
export type AppType = typeof routes;

View File

@ -0,0 +1,190 @@
import { zValidator } from "@hono/zod-validator";
import { and, eq, isNull, or } from "drizzle-orm";
import { Hono } from "hono";
import { deleteCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { HonoVariables } from "../..";
import db from "../../drizzle";
import { users } from "../../drizzle/schema/users";
import { checkPassword } from "../../utils/passwordUtils";
import {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
} from "../../utils/authUtils";
import { rolesSchema } from "../../drizzle/schema/roles";
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
const authRoutes = new Hono<{ Variables: HonoVariables }>()
.post(
"/login",
zValidator(
"form",
z.object({
username: z.string(),
password: z.string(),
___jwt: z.string().default("false"),
})
),
async (c) => {
const formData = c.req.valid("form");
const user = (
await db
.select({
id: users.id,
username: users.username,
email: users.email,
password: users.password,
})
.from(users)
.where(
and(
isNull(users.deletedAt),
eq(users.isEnabled, true),
or(
eq(users.username, formData.username),
eq(users.email, formData.username)
)
)
)
)[0];
if (!user) {
throw new HTTPException(400, {
message: "Invalid username or password",
});
}
const isSuccess = await checkPassword(
formData.password,
user.password
);
if (!isSuccess) {
throw new HTTPException(400, {
message: "Invalid username or password",
});
}
const accessToken = await generateAccessToken({
uid: user.id,
});
const refreshToken = await generateRefreshToken({
uid: user.id,
});
const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret)
throw new HTTPException(500, {
message: "The 'COOKIE_SECRET' env var is not set",
});
// await setSignedCookie(
// c,
// "access_token",
// accessToken,
// cookieSecret,
// {
// secure: true,
// httpOnly: true,
// prefix: "secure",
// expires:
// }
// );
return c.json({
accessToken,
refreshToken,
});
}
)
.post(
"/refresh-token",
zValidator(
"json",
z.object({
refreshToken: z.string(),
})
),
async (c) => {
const { refreshToken } = c.req.valid("json");
const decoded = await verifyRefreshToken(refreshToken);
if (!decoded) {
throw new HTTPException(401, {
message: "Invalid refresh token",
});
}
const accessToken = await generateAccessToken({
uid: decoded.uid,
});
return c.json({
accessToken,
});
}
)
.get("/my-profile", async (c) => {
const uid = c.var.uid;
if (!uid) {
throw new HTTPException(401, { message: "Unauthorized" });
}
const user = await db
.select({
id: users.id,
username: users.username,
email: users.email,
roles: rolesSchema.code,
})
.from(rolesToUsers)
.innerJoin(users, eq(users.id, rolesToUsers.userId))
.innerJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.where(eq(users.id, uid))
.then((userData) => {
return userData.reduce(
(prev, curr) => ({
...prev,
roles: [...prev.roles, curr.roles],
}),
{
id: uid,
username: userData[0].username,
email: userData[0].email,
roles: [] as string[],
}
);
});
if (!user) {
throw new HTTPException(401, { message: "Unauthorized" });
}
return c.json(user);
})
.get("/logout", (c) => {
const uid = c.var.uid;
if (!uid) {
return c.notFound();
}
deleteCookie(c, "access_token", {
secure: true,
httpOnly: true,
prefix: "secure",
});
return c.json({
message: "Logged out successfully",
});
});
export default authRoutes;

View File

@ -0,0 +1,115 @@
import sidebarMenus from "../../data/sidebarMenus";
import db from "../../drizzle";
import { permissionsSchema } from "../../drizzle/schema/permissions";
import { permissionsToRoles } from "../../drizzle/schema/permissionsToRoles";
import { permissionsToUsers } from "../../drizzle/schema/permissionsToUsers";
import { rolesSchema } from "../../drizzle/schema/roles";
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
import { users } from "../../drizzle/schema/users";
import { SidebarMenu } from "../../types";
import { forbidden } from "../../utils/httpErrors";
import { and, eq, or } from "drizzle-orm";
import { Hono } from "hono";
import { HonoVariables } from "../..";
const router = new Hono<{ Variables: HonoVariables }>();
const dashboardRoutes = router.get("/getSidebarItems", async (c) => {
const uid = c.var.uid;
if (!uid) throw forbidden();
const queryResult = await db
.selectDistinctOn([permissionsSchema.id], {
id: users.id,
name: users.name,
email: users.email,
isEnabled: users.isEnabled,
role: {
id: rolesSchema.id,
code: rolesSchema.code,
},
permission: {
id: permissionsSchema.id,
code: permissionsSchema.code,
},
})
.from(users)
.where(and(eq(users.id, uid), eq(users.isEnabled, true)))
.leftJoin(permissionsToUsers, eq(permissionsToUsers.userId, users.id))
.leftJoin(rolesToUsers, eq(rolesToUsers.userId, users.id))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.leftJoin(
permissionsToRoles,
eq(permissionsToRoles.roleId, rolesSchema.id)
)
.innerJoin(
permissionsSchema,
or(
eq(permissionsSchema.id, permissionsToUsers.permissionId),
eq(permissionsSchema.id, permissionsToRoles.permissionId)
)
);
if (!queryResult.length) throw forbidden();
const permissions = [...new Set(queryResult.map((r) => r.permission.code))];
const filteredMenus = sidebarMenus.reduce(
(prev, menu) => {
//if menu has children, check if any permission match
if (menu.children) {
const children = menu.children.filter(
(child) =>
child.allowedPermissions?.some((perm) =>
permissions.includes(perm)
) || child.allowedPermissions?.includes("*")
);
if (children.length) {
//add children and hide the allowed permissions field
return [
...prev,
{ ...menu, children, allowedPermissions: undefined },
];
}
}
//if menu has no children, check if permission match
else {
if (
menu.allowedPermissions?.some((perm) =>
permissions.includes(perm)
) ||
menu.allowedPermissions?.includes("*")
) {
//add menu and hide the allowed permissions field
return [
...prev,
{ ...menu, allowedPermissions: undefined },
];
}
}
//dont add permission to menu if it doesnt match
return prev;
},
[] as Omit<SidebarMenu, "allowedPermissions">[]
);
//I don't know why but it is not working without redefining the type
return c.json(
filteredMenus as {
label: string;
icon: Record<"tb", string>;
children?: {
label: string;
link: string;
}[];
link?: string;
color?: string;
}[]
);
});
export default dashboardRoutes;

View File

@ -0,0 +1,18 @@
import db from "../../drizzle";
import { permissionsSchema } from "../../drizzle/schema/permissions";
import { Hono } from "hono";
const permissionRoutes = new Hono()
//get all permissions
.get("/", async (c) => {
const permissions = await db
.select({
id: permissionsSchema.id,
code: permissionsSchema.code,
})
.from(permissionsSchema);
return c.json(permissions);
});
export default permissionRoutes;

View File

@ -0,0 +1,19 @@
import { Hono } from "hono";
import db from "../../drizzle";
import { rolesSchema } from "../../drizzle/schema/roles";
const rolesRoute = new Hono()
//get all permissions
.get("/", async (c) => {
const roles = await db
.select({
id: rolesSchema.id,
code: rolesSchema.code,
name: rolesSchema.name,
})
.from(rolesSchema);
return c.json(roles);
});
export default rolesRoute;

View File

@ -0,0 +1,327 @@
import { and, eq, isNull } from "drizzle-orm";
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { users } from "../../drizzle/schema/users";
import { HonoVariables } from "../..";
import { hashPassword } from "../../utils/passwordUtils";
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
import { rolesSchema } from "../../drizzle/schema/roles";
const userFormSchema = z.object({
name: z.string().min(1).max(255),
username: z.string().min(1).max(255),
email: z.string().email().optional().or(z.literal("")),
password: z.string().min(6),
isEnabled: z.string().default("false"),
roles: z
.string()
.refine(
(data) => {
console.log(data);
try {
const parsed = JSON.parse(data);
return Array.isArray(parsed);
} catch {
return false;
}
},
{
message: "Roles must be an array",
}
)
.optional(),
});
const userUpdateSchema = userFormSchema.extend({
password: z.string().min(6).optional().or(z.literal("")),
});
const usersRoute = new Hono<{ Variables: HonoVariables }>()
.use(async (c, next) => {
const uid = c.get("uid");
if (uid) {
await next();
} else {
throw new HTTPException(401, {
message: "Unauthorized",
});
}
})
.get(
"/",
zValidator(
"query",
z.object({
includeTrashed: z.string().default("false"),
})
),
async (c) => {
const includeTrashed =
c.req.query("includeTrashed")?.toLowerCase() === "true";
let usersData = await db
.select({
id: users.id,
name: users.name,
email: users.email,
username: users.username,
isEnabled: users.isEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
...(includeTrashed ? { deletedAt: users.deletedAt } : {}),
// password: users.password,
})
.from(users)
.where(!includeTrashed ? isNull(users.deletedAt) : undefined);
return c.json(usersData);
}
)
//get user by id
.get(
"/:id",
zValidator(
"query",
z.object({
includeTrashed: z.string().default("false"),
})
),
async (c) => {
const userId = c.req.param("id");
const includeTrashed =
c.req.query("includeTrashed")?.toLowerCase() === "true";
const queryResult = await db
.select({
id: users.id,
name: users.name,
email: users.email,
username: users.username,
isEnabled: users.isEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
...(includeTrashed ? { deletedAt: users.deletedAt } : {}),
role: {
name: rolesSchema.name,
id: rolesSchema.id,
},
})
.from(users)
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.where(
and(
eq(users.id, userId),
!includeTrashed ? isNull(users.deletedAt) : undefined
)
);
if (!queryResult.length)
throw new HTTPException(404, {
message: "The user does not exists",
});
const roles = queryResult.reduce((prev, curr) => {
if (!curr.role) return prev;
prev.set(curr.role.id, curr.role.name);
return prev;
}, new Map<string, string>()); //Map<id, name>
const userData = {
...queryResult[0],
role: undefined,
roles: Array.from(roles, ([id, name]) => ({ id, name })),
};
return c.json(userData);
}
)
//create user
.post(
"/",
zValidator("form", userFormSchema, (result) => {
if (!result.success) {
let errors = result.error.flatten().fieldErrors as Record<
string,
string[]
>;
let firstErrors: Record<string, string> = {};
for (let field in errors) {
firstErrors[field] = errors[field][0]; // Grabbing the first error message for each field
}
throw new HTTPException(422, {
message: JSON.stringify(firstErrors),
});
}
}),
async (c) => {
const userData = c.req.valid("form");
const user = await db
.insert(users)
.values({
name: userData.name,
username: userData.username,
email: userData.email,
password: await hashPassword(userData.password),
isEnabled: userData.isEnabled.toLowerCase() === "true",
})
.returning();
if (userData.roles) {
const roles = JSON.parse(userData.roles) as string[];
console.log(roles);
if (roles.length) {
await db.insert(rolesToUsers).values(
roles.map((role) => ({
userId: user[0].id,
roleId: role,
}))
);
}
}
return c.json(
{
message: "User created successfully",
},
201
);
}
)
//update user
.patch(
"/:id",
zValidator("form", userUpdateSchema, (result) => {
if (!result.success) {
let errors = result.error.flatten().fieldErrors as Record<
string,
string[]
>;
let firstErrors: Record<string, string> = {};
for (let field in errors) {
firstErrors[field] = errors[field][0]; // Grabbing the first error message for each field
}
throw new HTTPException(422, {
message: JSON.stringify(firstErrors),
});
}
}),
async (c) => {
const userId = c.req.param("id");
const userData = c.req.valid("form");
const user = await db
.select()
.from(users)
.where(and(eq(users.id, userId), isNull(users.deletedAt)));
if (!user[0]) return c.notFound();
await db
.update(users)
.set({
...userData,
...(userData.password
? { password: await hashPassword(userData.password) }
: {}),
updatedAt: new Date(),
isEnabled: userData.isEnabled.toLowerCase() === "true",
})
.where(eq(users.id, userId));
return c.json({
message: "User updated successfully",
});
}
)
//delete user
.delete(
"/:id",
zValidator(
"form",
z.object({
skipTrash: z.string().default("false"),
})
),
async (c) => {
const userId = c.req.param("id");
const currentUserId = c.var.uid;
const skipTrash =
c.req.valid("form").skipTrash.toLowerCase() === "true";
const user = await db
.select()
.from(users)
.where(
and(
eq(users.id, userId),
skipTrash ? undefined : isNull(users.deletedAt)
)
);
if (!user[0])
throw new HTTPException(404, {
message: "The user is not found",
});
if (user[0].id === currentUserId) {
throw new HTTPException(400, {
message: "You cannot delete yourself",
});
}
if (skipTrash) {
await db.delete(users).where(eq(users.id, userId));
} else {
await db
.update(users)
.set({
deletedAt: new Date(),
})
.where(and(eq(users.id, userId), isNull(users.deletedAt)));
}
return c.json({
message: "User deleted successfully",
});
}
)
//undo delete
.patch("/restore/:id", async (c) => {
const userId = c.req.param("id");
const user = (
await db.select().from(users).where(eq(users.id, userId))
)[0];
if (!user) return c.notFound();
if (!user.deletedAt) {
throw new HTTPException(400, {
message: "The user is not deleted",
});
}
await db
.update(users)
.set({ deletedAt: null })
.where(eq(users.id, userId));
return c.json({
message: "User restored successfully",
});
});
export default usersRoute;

16
apps/backend/src/types/SidebarMenu.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import { PermissionCode } from "@/data/permissions";
interface SidebarMenu {
label: string;
icon: Record<"tb", string>;
children?: {
label: string;
link: string;
allowedPermissions?: PermissionCode[];
}[];
link?: string;
color?: string;
allowedPermissions?: PermissionCode[];
}
export default SidebarMenu;

3
apps/backend/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import type SidebarMenu from "./SidebarMenu";
export { SidebarMenu };

View File

@ -0,0 +1,58 @@
import jwt from "jsonwebtoken";
const accessTokenSecret =
process.env.ACCESS_TOKEN_SECRET ?? "some-random-secret";
const refreshTokenSecret =
process.env.REFRESH_TOKEN_SECRET ?? "some-very-random-secret";
const algorithm: jwt.Algorithm = "HS256";
export const accessTokenExpiry: number | string | null = null; // null for no expiry
export const refreshTokenExpiry: number | string | null = "30d"; // null for no expiry
interface AccessTokenPayload {
uid: string;
}
interface RefreshTokenPayload {
uid: string;
}
export const generateAccessToken = async (payload: AccessTokenPayload) => {
const token = jwt.sign(payload, accessTokenSecret, {
algorithm,
...(accessTokenExpiry ? { expiresIn: accessTokenExpiry } : {}),
});
return token;
};
export const generateRefreshToken = async (payload: RefreshTokenPayload) => {
const token = jwt.sign(payload, refreshTokenSecret, {
algorithm,
...(refreshTokenExpiry ? { expiresIn: refreshTokenExpiry } : {}),
});
return token;
};
export const verifyAccessToken = async (token: string) => {
try {
const payload = jwt.verify(
token,
accessTokenSecret
) as AccessTokenPayload;
return payload;
} catch {
return null;
}
};
export const verifyRefreshToken = async (token: string) => {
try {
const payload = jwt.verify(
token,
refreshTokenSecret
) as RefreshTokenPayload;
return payload;
} catch {
return null;
}
};

View File

@ -0,0 +1,7 @@
import { HTTPException } from "hono/http-exception";
export const forbidden = () => {
throw new HTTPException(403, {
message: "You are not allowed nor authorized to do this action",
});
};

View File

@ -0,0 +1,11 @@
import bcrypt from "bcrypt";
const saltRounds = 10;
export const hashPassword = async (password: string) => {
return await bcrypt.hash(password, saltRounds);
};
export const checkPassword = async (password: string, hash: string) => {
return await bcrypt.compare(password, hash);
};

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
apps/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
apps/frontend/README.md Normal file
View File

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

13
apps/frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{
"name": "frontend",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@mantine/core": "^7.9.0",
"@mantine/form": "^7.8.1",
"@mantine/hooks": "^7.9.0",
"@mantine/notifications": "^7.9.0",
"@tanstack/react-query": "^5.32.0",
"@tanstack/react-router": "^1.31.1",
"@tanstack/react-table": "^8.16.0",
"backend": "workspace:*",
"clsx": "^2.1.1",
"hono": "^4.2.9",
"mantine-form-zod-resolver": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.1.0",
"zod": "^3.23.4"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.28.11",
"@tanstack/router-devtools": "^1.31.1",
"@tanstack/router-vite-plugin": "^1.30.0",
"@types/node": "^20.11.24",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"postcss-preset-mantine": "^1.15.0",
"postcss-simple-vars": "^7.0.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

View File

@ -0,0 +1,16 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

37
apps/frontend/src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import { routeTree } from "./routeTree.gen";
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: { queryClient: queryClient },
defaultPreloadStaleTime: 0,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function App() {
return (
<MantineProvider>
<Notifications />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</MantineProvider>
);
}
export default App;

View File

@ -0,0 +1,9 @@
# Logos Folder
This folder contains graphic assets for logos.
## Important File
- `logo.png`: This file is mandatory and will be used as the logo on the dashboard.
Ensure that the `logo.png` file is always updated as it is crucial for the dashboard's branding.

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import {
AppShell,
Avatar,
@ -9,14 +9,13 @@ import {
Text,
rem,
} from "@mantine/core";
import Image from "next/image";
import logo from "@/assets/logos/logo-dsg.png";
import logo from "@/assets/logos/logo.png";
import cx from "clsx";
import classNames from "./styles/appHeader.module.css";
import { TbChevronDown } from "react-icons/tb";
import getUserMenus from "../actions/getUserMenus";
import { useAuth } from "@/modules/auth/contexts/AuthContext";
import UserMenuItem from "./UserMenuItem";
// import getUserMenus from "../actions/getUserMenus";
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
// import UserMenuItem from "./UserMenuItem";
interface Props {
openNavbar: boolean;
@ -32,11 +31,11 @@ interface Props {
export default function AppHeader(props: Props) {
const [userMenuOpened, setUserMenuOpened] = useState(false);
const { user } = useAuth();
// const { user } = useAuth();
const userMenus = getUserMenus().map((item, i) => (
<UserMenuItem item={item} key={i} />
));
// const userMenus = getUserMenus().map((item, i) => (
// <UserMenuItem item={item} key={i} />
// ));
return (
<AppShell.Header>
@ -47,7 +46,7 @@ export default function AppHeader(props: Props) {
hiddenFrom="sm"
size="sm"
/>
<Image src={logo} alt="" height={57} />
<img src={logo} alt="" className="h-8" />
<Menu
width={260}
position="bottom-end"
@ -64,13 +63,14 @@ export default function AppHeader(props: Props) {
>
<Group gap={7}>
<Avatar
src={user?.photoProfile}
alt={user?.name}
// src={user?.photoProfile}
// alt={user?.name}
radius="xl"
size={20}
/>
<Text fw={500} size="sm" lh={1} mr={3}>
{user?.name}
{/* {user?.name} */}
Username
</Text>
<TbChevronDown
style={{ width: rem(12), height: rem(12) }}
@ -83,7 +83,7 @@ export default function AppHeader(props: Props) {
<Menu.Dropdown>
<Menu.Label>Settings</Menu.Label>
{userMenus}
{/* {userMenus} */}
</Menu.Dropdown>
</Menu>
</Group>

View File

@ -0,0 +1,44 @@
import { AppShell, ScrollArea } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import client from "../honoClient";
import MenuItem from "./NavbarMenuItem";
// import MenuItem from "./SidebarMenuItem";
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
/**
* `AppNavbar` is a React functional component that renders the application's navigation bar.
* It utilizes data from `allMenu` to create a list of menu items displayed in a scrollable area.
*
* @returns A React element representing the application's navigation bar.
*/
export default function AppNavbar() {
// const {user} = useAuth();
const { data } = useQuery({
queryKey: ["sidebarData"],
queryFn: async () => {
const res = await client.dashboard.getSidebarItems.$get();
if (res.ok) {
const data = await res.json();
return data;
}
console.error("Error:", res.status, res.statusText);
//TODO: Handle error properly
throw new Error("Error fetching sidebar data");
},
});
return (
<AppShell.Navbar p="md">
<ScrollArea style={{ flex: "1" }}>
{data?.map((menu, i) => <MenuItem menu={menu} key={i} />)}
{/* {user?.sidebarMenus.map((menu, i) => (
<MenuItem menu={menu} key={i} />
)) ?? null} */}
</ScrollArea>
</AppShell.Navbar>
);
}

View File

@ -1,6 +1,3 @@
"use client";
import React from "react";
import { Table, Center, ScrollArea } from "@mantine/core";
import { Table as ReactTable, flexRender } from "@tanstack/react-table";

View File

@ -1,10 +1,7 @@
import React from "react";
import { Text } from "@mantine/core";
import classNames from "./styles/sidebarChildMenu.module.css";
import SidebarMenu from "../types/SidebarMenu";
import dashboardConfig from "../dashboard.config";
import classNames from "./styles/navbarChildMenu.module.css";
import { SidebarMenu } from "backend/types";
interface Props {
item: NonNullable<SidebarMenu["children"]>[number];
@ -28,7 +25,7 @@ export default function ChildMenu(props: Props) {
<Text<"a">
component="a"
className={classNames.link}
href={`${dashboardConfig.baseRoute}${linkPath}`}
href={`${linkPath}`}
fw={props.active ? "bold" : "normal"}
>
{props.item.label}

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import {
Box,
@ -11,17 +11,21 @@ import {
import { TbChevronRight } from "react-icons/tb";
import * as TbIcons from "react-icons/tb";
import ChildMenu from "./SidebarChildMenu";
import classNames from "./styles/sidebarMenuItem.module.css";
import SidebarMenu from "../types/SidebarMenu";
import dashboardConfig from "../dashboard.config";
import { usePathname } from "next/navigation";
import areURLsSame from "@/utils/areUrlSame";
import classNames from "./styles/navbarMenuItem.module.css";
// import dashboardConfig from "../dashboard.config";
// import { usePathname } from "next/navigation";
// import areURLsSame from "@/utils/areUrlSame";
import { SidebarMenu } from "backend/types";
import ChildMenu from "./NavbarChildMenu";
import { Link } from "@tanstack/react-router";
interface Props {
menu: SidebarMenu;
}
//TODO: Make bold and collapsed when the item is active
/**
* `MenuItem` is a React functional component that displays an individual menu item.
* It can optionally include a collapsible sub-menu for items with children.
@ -33,9 +37,14 @@ interface Props {
export default function MenuItem({ menu }: Props) {
const hasChildren = Array.isArray(menu.children);
const pathname = usePathname()
// const pathname = usePathname();
const [opened, setOpened] = useState(menu.children?.some(child => areURLsSame(`${dashboardConfig.baseRoute}${child.link}`, pathname)) ?? false);
const [opened, setOpened] = useState(
// menu.children?.some((child) =>
// areURLsSame(`${dashboardConfig.baseRoute}${child.link}`, pathname)
// ) ?? false
false
);
const toggleOpenMenu = () => {
setOpened((prev) => !prev);
@ -43,23 +52,28 @@ export default function MenuItem({ menu }: Props) {
// Mapping children menu items if available
const subItems = (hasChildren ? menu.children! : []).map((child, index) => (
<ChildMenu key={index} item={child} active={areURLsSame(`${dashboardConfig.baseRoute}${child.link}`, pathname)} />
<ChildMenu key={index} item={child} active={false} />
));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Icons = TbIcons as any;
const Icon = typeof menu.icon === "string" ? Icons[menu.icon] : menu.icon;
const Icon =
typeof menu.icon.tb === "string" ? Icons[menu.icon.tb] : menu.icon.tb;
const isActive = areURLsSame(`${dashboardConfig.baseRoute}${menu.link}`, pathname)
// const isActive = areURLsSame(
// `${dashboardConfig.baseRoute}${menu.link}`,
// pathname
// );
return (
<>
{/* Main Menu Item */}
<UnstyledButton<"a" | "button">
<UnstyledButton<typeof Link | "button">
onClick={toggleOpenMenu}
className={classNames.control}
href={menu.link ? dashboardConfig.baseRoute + menu.link : ""}
component={menu.link ? "a" : "button"}
className={`${classNames.control} py-2`}
to={menu.link}
component={menu.link ? Link : "button"}
>
<Group justify="space-between" gap={0}>
{/* Icon and Label */}
@ -68,7 +82,9 @@ export default function MenuItem({ menu }: Props) {
<Icon style={{ width: rem(18), height: rem(18) }} />
</ThemeIcon>
<Box ml="md" fw={isActive ? 700 : 500}>{menu.label}</Box>
<Box ml="md" fw={500}>
{menu.label}
</Box>
</Box>
{/* Chevron Icon for collapsible items */}

View File

@ -0,0 +1,23 @@
.link {
font-weight: 500;
display: block;
text-decoration: none;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
padding-left: var(--mantine-spacing-md);
margin-left: var(--mantine-spacing-xl);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
border-left: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
color: light-dark(
var(--mantine-color-black),
var(--mantine-color-dark-0)
);
}
}

View File

@ -0,0 +1,23 @@
.control {
font-weight: 500;
display: block;
width: 100%;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
color: light-dark(
var(--mantine-color-black),
var(--mantine-color-dark-0)
);
}
}
.chevron {
transition: transform 200ms ease;
}

View File

@ -0,0 +1,10 @@
import { hc } from "hono/client";
import { AppType } from "backend";
const client = hc<AppType>("http://localhost:3000", {
headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJkeG5icHQ0N3BwdXZ6NW1vbTF1NjF5cnMiLCJpYXQiOjE3MTQ1NjY4MDB9.920-iiSuLiPK0t0GiWIuT_n8BHngPxp1FyvYZ7SBJ7E`,
},
});
export default client;

View File

View File

@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "./styles/tailwind.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,106 @@
import client from "@/honoClient";
import { Button, Flex, Modal, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi, useSearch } from "@tanstack/react-router";
import { deleteUser } from "../queries/userQueries";
import { notifications } from "@mantine/notifications";
const routeApi = getRouteApi("/_dashboardLayout/users/");
export default function UserDeleteModal() {
const queryClient = useQueryClient();
const searchParams = useSearch({ from: "/_dashboardLayout/users/" }) as {
delete: string;
};
const userId = searchParams.delete;
const navigate = routeApi.useNavigate();
const userQuery = useQuery({
queryKey: ["users", userId],
queryFn: async () => {
if (!userId) return null;
const res = await client.users[":id"].$get({
param: {
id: userId,
},
query: {},
});
if (res.ok) {
console.log("ok");
return await res.json();
}
console.log("not ok");
throw new Error(await res.text());
},
});
const mutation = useMutation({
mutationKey: ["deleteUserMutation"],
mutationFn: async ({ id }: { id: string }) => {
return await deleteUser(id);
},
onError: (error) => {
try {
const message = JSON.parse(error.message);
notifications.show({
message: message.message ?? "Failed to delete User.",
color: "red",
});
} catch (e) {
console.log(error);
}
},
onSuccess: () => {
notifications.show({
message: "User deleted successfully.",
color: "green",
});
queryClient.removeQueries({ queryKey: ["user", userId] });
queryClient.invalidateQueries({ queryKey: ["users"] });
navigate({ search: {} });
},
});
const isModalOpen = Boolean(searchParams.delete && userQuery.data);
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={`Delete confirmation`}
>
<Text size="sm">
Are you sure you want to delete user{" "}
<Text span fw={700}>
{userQuery.data?.name}
</Text>
? This action is irreversible.
</Text>
{/* {errorMessage && <Alert color="red">{errorMessage}</Alert>} */}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
variant="subtle"
// leftSection={<TbDeviceFloppy size={20} />}
type="submit"
color="red"
loading={mutation.isPending}
onClick={() => mutation.mutate({ id: userId })}
>
Delete User
</Button>
</Flex>
</Modal>
);
}

View File

@ -0,0 +1,275 @@
import stringToColorHex from "@/utils/stringToColorHex";
import {
Avatar,
Button,
Center,
Flex,
Modal,
MultiSelect,
PasswordInput,
ScrollArea,
Stack,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi, useSearch } from "@tanstack/react-router";
import { createUser, updateUser } from "../queries/userQueries";
import { TbDeviceFloppy } from "react-icons/tb";
import client from "../../../honoClient";
import { useEffect } from "react";
import { notifications } from "@mantine/notifications";
const routeApi = getRouteApi("/_dashboardLayout/users/");
export default function UserFormModal() {
const searchParams = useSearch({ from: "/_dashboardLayout/users/" }) as {
detail: string;
edit: string;
create: boolean;
};
const queryClient = useQueryClient();
const userId = searchParams.detail || searchParams.edit;
const rolesQuery = useQuery({
queryKey: ["roles"],
queryFn: async () => {
const res = await client.roles.$get();
if (res.ok) {
return await res.json();
}
throw new Error(await res.text());
},
});
const userQuery = useQuery({
queryKey: ["users", userId],
enabled: Boolean(userId),
queryFn: async () => {
if (!userId) return null;
const res = await client.users[":id"].$get({
param: {
id: userId,
},
query: {},
});
if (res.ok) {
console.log("ok");
return await res.json();
}
console.log("not ok");
throw new Error(await res.text());
},
});
const isModalOpen =
Boolean(userId && userQuery.data) || searchParams.create;
const mutation = useMutation({
mutationKey: ["usersMutation"],
mutationFn: async (
options:
| { action: "edit"; data: Parameters<typeof updateUser>[0] }
| { action: "create"; data: Parameters<typeof createUser>[0] }
) => {
console.log("called");
return options.action === "edit"
? await updateUser(options.data)
: await createUser(options.data);
},
onError: (error) => {
try {
form.setErrors(JSON.parse(JSON.parse(error.message).message));
} catch (e) {
console.error(e);
}
},
});
const navigate = routeApi.useNavigate();
const detailId = searchParams.detail;
const editId = searchParams.edit;
const formType = detailId ? "detail" : editId ? "edit" : "create";
const modalTitle =
formType.charAt(0).toUpperCase() + formType.slice(1) + " User";
const form = useForm({
initialValues: {
id: "",
email: "",
name: "",
username: "",
photoProfileUrl: "",
password: "",
roles: [] as string[],
},
// validate: zodResolver(userFormDataSchema),
// validateInputOnChange: false,
});
useEffect(() => {
const data = userQuery.data;
if (!data) {
form.reset();
return;
}
form.setValues({
id: data.id,
email: data.email ?? "",
name: data.name,
photoProfileUrl: "",
username: data.username,
password: "",
roles: data.roles.map((v) => v.id), //only extract the id
});
form.setErrors({});
}, [userQuery.data]);
const handleSubmit = async (values: typeof form.values) => {
if (formType === "detail") return;
//TODO: OPtimize this code
if (formType === "create") {
await mutation.mutateAsync({
action: formType,
data: {
email: values.email,
name: values.name,
password: values.password,
roles: JSON.stringify(values.roles),
isEnabled: "true",
username: values.username,
},
});
} else {
await mutation.mutateAsync({
action: formType,
data: {
id: values.id,
email: values.email,
name: values.name,
password: values.password,
roles: JSON.stringify(values.roles),
isEnabled: "true",
username: values.username,
},
});
}
queryClient.invalidateQueries({ queryKey: ["users"] });
notifications.show({
message: `The ser is ${formType === "create" ? "created" : "edited"}`,
});
navigate({ search: {} });
};
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle} //Uppercase first letter
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack mt="sm" gap="lg" px="lg">
{/* Avatar */}
<Center>
<Avatar
color={stringToColorHex(form.values.id ?? "")}
src={form.values.photoProfileUrl}
size={120}
>
{form.values.name?.[0]?.toUpperCase()}
</Avatar>
</Center>
</Stack>
{form.values.id && (
<TextInput
label="ID"
readOnly
variant="filled"
disabled={mutation.isPending}
{...form.getInputProps("id")}
/>
)}
<TextInput
data-autofocus
label="Name"
readOnly={formType === "detail"}
disabled={mutation.isPending}
{...form.getInputProps("name")}
/>
<TextInput
label="Username"
readOnly={formType === "detail"}
disabled={mutation.isPending}
{...form.getInputProps("username")}
/>
<TextInput
label="Email"
readOnly={formType === "detail"}
disabled={mutation.isPending}
{...form.getInputProps("email")}
/>
{formType === "create" && (
<PasswordInput
label="Password"
disabled={mutation.isPending}
{...form.getInputProps("password")}
/>
)}
{/* Role */}
<MultiSelect
label="Roles"
readOnly={formType === "detail"}
disabled={mutation.isPending}
value={form.values.roles}
onChange={(values) => form.setFieldValue("roles", values)}
data={rolesQuery.data?.map((role) => ({
value: role.id,
label: role.name,
}))}
error={form.errors.roles}
/>
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Close
</Button>
{formType !== "detail" && (
<Button
variant="filled"
leftSection={<TbDeviceFloppy size={20} />}
type="submit"
loading={mutation.isPending}
>
Save
</Button>
)}
</Flex>
</form>
</Modal>
);
}

View File

@ -0,0 +1,73 @@
import client from "@/honoClient";
import { queryOptions } from "@tanstack/react-query";
import { InferRequestType } from "hono";
export const userQueryOptions = queryOptions({
queryKey: ["users"],
queryFn: () => fetchUsers(),
});
export const fetchUsers = async () => {
const res = await client.users.$get({
query: {},
});
if (res.ok) {
return await res.json();
}
//TODO: Handle error
throw new Error(res.statusText);
};
export const createUser = async (
form: InferRequestType<typeof client.users.$post>["form"]
) => {
const res = await client.users.$post({
form,
});
if (res.ok) {
return await res.json();
}
//TODO: Handle error
throw Error(await res.text());
};
export const updateUser = async (
form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & {
id: string;
}
) => {
form;
const res = await client.users[":id"].$patch({
param: {
id: form.id,
},
form,
});
if (res.ok) {
return await res.json();
}
//TODO: Handle error
throw new Error(await res.text());
};
export const deleteUser = async (id: string) => {
const res = await client.users[":id"].$delete({
param: {
id,
},
form: {},
});
if (res.ok) {
return await res.json();
}
//TODO: Handle error
throw new Error(await res.text());
};

View File

@ -0,0 +1,74 @@
import { Button, Flex, Text } from "@mantine/core";
import { Link, getRouteApi } from "@tanstack/react-router";
import React from "react";
import { TbPlus } from "react-icons/tb";
import DashboardTable from "../../../components/DashboardTable";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import createColumns from "./columns";
import { useSuspenseQuery } from "@tanstack/react-query";
import { userQueryOptions } from "../queries/userQueries";
import UserFormModal from "../modals/UserFormModal";
import UserDeleteModal from "../modals/UserDeleteModal";
const routeApi = getRouteApi("/_dashboardLayout/users/");
export default function UsersTable() {
const navigate = routeApi.useNavigate();
const usersQuery = useSuspenseQuery(userQueryOptions);
const table = useReactTable({
data: usersQuery.data,
columns: createColumns({
permissions: {
create: true,
read: true,
delete: true,
update: true,
},
actions: {
detail: (id: string) =>
navigate({
search: {
detail: id,
},
}),
edit: (id: string) =>
navigate({
search: {
edit: id,
},
}),
delete: (id: string) =>
navigate({
search: {
delete: id,
},
}),
},
}),
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
cell: (props) => <Text>{props.getValue() as React.ReactNode}</Text>,
},
});
return (
<>
<Flex justify="flex-end">
<Button
leftSection={<TbPlus />}
component={Link}
search={{ create: true }}
>
New User
</Button>
</Flex>
<DashboardTable table={table} />
<UserFormModal />
<UserDeleteModal />
</>
);
}

View File

@ -1,21 +1,15 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Badge, Flex, Group, Avatar, Text, Anchor } from "@mantine/core";
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
import CrudPermissions from "@/modules/dashboard/types/CrudPermissions";
import stringToColorHex from "@/core/utils/stringToColorHex";
import Link from "next/link";
import createActionButtons from "@/modules/dashboard/utils/createActionButton";
export interface UserRow {
id: string;
name: string | null;
email: string | null;
photoUrl: string | null;
roles: string[]
}
import { CrudPermission } from "@/types";
import stringToColorHex from "@/utils/stringToColorHex";
import createActionButtons from "@/utils/createActionButton";
import client from "@/honoClient";
import { InferResponseType } from "hono";
import { Link } from "@tanstack/react-router";
interface ColumnOptions {
permissions: Partial<CrudPermissions>;
permissions: Partial<CrudPermission>;
actions: {
detail: (id: string) => void;
edit: (id: string) => void;
@ -24,7 +18,10 @@ interface ColumnOptions {
}
const createColumns = (options: ColumnOptions) => {
const columnHelper = createColumnHelper<UserRow>();
const columnHelper =
createColumnHelper<
InferResponseType<typeof client.users.$get>[number]
>();
const columns = [
columnHelper.display({
@ -40,7 +37,7 @@ const createColumns = (options: ColumnOptions) => {
<Group>
<Avatar
color={stringToColorHex(props.row.original.id)}
src={props.row.original.photoUrl}
// src={props.row.original.photoUrl}
size={26}
>
{props.getValue()?.[0].toUpperCase()}
@ -56,7 +53,7 @@ const createColumns = (options: ColumnOptions) => {
header: "Email",
cell: (props) => (
<Anchor
href={`mailto:${props.getValue()}`}
to={`mailto:${props.getValue()}`}
size="sm"
component={Link}
>
@ -65,10 +62,10 @@ const createColumns = (options: ColumnOptions) => {
),
}),
columnHelper.accessor("roles", {
header: "Role",
cell: (props) => <Text>{props.getValue()[0]}</Text>
}),
// columnHelper.accessor("roles", {
// header: "Role",
// cell: (props) => <Text>{props.getValue()[0]}</Text>,
// }),
columnHelper.display({
id: "status",

View File

@ -0,0 +1,96 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
import { Route as LoginIndexImport } from './routes/login/index'
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
// Create Virtual Routes
const IndexLazyImport = createFileRoute('/')()
const DashboardLayoutUsersIndexLazyImport = createFileRoute(
'/_dashboardLayout/users/',
)()
// Create/Update Routes
const DashboardLayoutRoute = DashboardLayoutImport.update({
id: '/_dashboardLayout',
getParentRoute: () => rootRoute,
} as any)
const IndexLazyRoute = IndexLazyImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const LoginIndexRoute = LoginIndexImport.update({
path: '/login/',
getParentRoute: () => rootRoute,
} as any)
const DashboardLayoutUsersIndexLazyRoute =
DashboardLayoutUsersIndexLazyImport.update({
path: '/users/',
getParentRoute: () => DashboardLayoutRoute,
} as any).lazy(() =>
import('./routes/_dashboardLayout/users/index.lazy').then((d) => d.Route),
)
const DashboardLayoutDashboardIndexRoute =
DashboardLayoutDashboardIndexImport.update({
path: '/dashboard/',
getParentRoute: () => DashboardLayoutRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/_dashboardLayout': {
preLoaderRoute: typeof DashboardLayoutImport
parentRoute: typeof rootRoute
}
'/login/': {
preLoaderRoute: typeof LoginIndexImport
parentRoute: typeof rootRoute
}
'/_dashboardLayout/dashboard/': {
preLoaderRoute: typeof DashboardLayoutDashboardIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/users/': {
preLoaderRoute: typeof DashboardLayoutUsersIndexLazyImport
parentRoute: typeof DashboardLayoutImport
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([
IndexLazyRoute,
DashboardLayoutRoute.addChildren([
DashboardLayoutDashboardIndexRoute,
DashboardLayoutUsersIndexLazyRoute,
]),
LoginIndexRoute,
])
/* prettier-ignore-end */

View File

@ -0,0 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
interface RouteContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouteContext>()({
component: () => (
<>
<Outlet />
<TanStackRouterDevtools />
</>
),
});

View File

@ -0,0 +1,54 @@
import { AppShell } from "@mantine/core";
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useDisclosure } from "@mantine/hooks";
import AppHeader from "../components/AppHeader";
import AppNavbar from "../components/AppNavbar";
import isAuthenticated from "@/utils/isAuthenticated";
import { useEffect } from "react";
export const Route = createFileRoute("/_dashboardLayout")({
component: DashboardLayout,
// beforeLoad: ({ location }) => {
// if (true) {
// throw redirect({
// to: "/login",
// });
// }
// },
});
function DashboardLayout() {
const [openNavbar, { toggle }] = useDisclosure(false);
const navigate = useNavigate();
useEffect(() => {
if (!isAuthenticated()) {
navigate({ to: "/login", replace: true });
}
}, [navigate]);
return (
<AppShell
padding="md"
header={{ height: 70 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !openNavbar },
}}
>
<AppHeader openNavbar={openNavbar} toggle={toggle} />
<AppNavbar />
<AppShell.Main
className="bg-slate-100"
styles={{ main: { backgroundColor: "rgb(241 245 249)" } }}
>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@ -0,0 +1,3 @@
This is dashboard layout folder
Any routes that inside this folder will have Dashboard Header and Navbar

View File

@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_dashboardLayout/dashboard/")({
component: () => <div>Hello /dashboard/!</div>,
});

View File

@ -0,0 +1,33 @@
import { Card, Stack, Title } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router";
import UsersTable from "../../../modules/usersManagement/tables/UsersTable";
import { z } from "zod";
import { userQueryOptions } from "@/modules/usersManagement/queries/userQueries";
const searchParamSchema = z.object({
create: z.boolean().default(false).optional(),
edit: z.string().default("").optional(),
delete: z.string().default("").optional(),
detail: z.string().default("").optional(),
});
export const Route = createFileRoute("/_dashboardLayout/users/")({
component: UsersPage,
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(userQueryOptions);
},
});
export default function UsersPage() {
return (
<Stack>
<Title order={1}>Users</Title>
<Card>
<UsersTable />
</Card>
</Stack>
);
}

View File

@ -0,0 +1,5 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/")({
component: () => <div className="text-red-500">Hello !</div>,
});

View File

@ -0,0 +1,153 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import {
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Group,
Button,
Alert,
} from "@mantine/core";
import client from "../../honoClient";
import { useForm } from "@mantine/form";
import { z } from "zod";
import { zodResolver } from "mantine-form-zod-resolver";
import { useEffect, useState } from "react";
import isAuthenticated from "@/utils/isAuthenticated";
export const Route = createFileRoute("/login/")({
component: LoginPage,
});
type FormSchema = {
username: string;
password: string;
};
const formSchema = z.object({
username: z.string().min(1, "This field is required"),
password: z.string().min(1, "This field is required"),
});
export default function LoginPage() {
const [errorMessage, setErrorMessage] = useState("");
const navigate = useNavigate();
const form = useForm<FormSchema>({
initialValues: {
username: "",
password: "",
},
validate: zodResolver(formSchema),
});
useEffect(() => {
if (isAuthenticated()) {
navigate({
to: "/dashboard",
replace: true,
});
}
}, []);
const loginMutation = useMutation({
mutationFn: async (values: FormSchema) => {
const res = await client.auth.login.$post({
form: values,
});
if (res.ok) {
return await res.json();
}
throw res;
},
onSuccess: (data) => {
console.log(data);
localStorage.setItem("accessToken", data.accessToken);
navigate({
to: "/dashboard",
});
},
onError: async (error) => {
console.log("error!");
if (error instanceof Response) {
const body = await error.json();
setErrorMessage(body.message as string);
return;
}
console.log("bukan error");
},
});
const handleSubmit = (values: FormSchema) => {
loginMutation.mutate(values);
};
return (
<div className="w-screen h-screen flex items-center justify-center">
<Paper radius="md" p="xl" withBorder w={400}>
<Text size="lg" fw={500} mb={30}>
Welcome
</Text>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
{errorMessage ? (
<Alert
variant="filled"
color="pink"
title=""
// icon={icon}
>
{errorMessage}
</Alert>
) : null}
<TextInput
label="Username or Email"
placeholder="Enter your username or email"
name="username"
autoComplete="username"
disabled={loginMutation.isPending}
{...form.getInputProps("username")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
name="password"
autoComplete="password"
disabled={loginMutation.isPending}
{...form.getInputProps("password")}
/>
</Stack>
<Group justify="space-between" mt="xl">
{/* <Anchor
component="a"
type="button"
c="dimmed"
size="xs"
href="/register"
>
Don&apos;t have an account? Register
</Anchor> */}
<div />
<Button
type="submit"
radius="xl"
disabled={loginMutation.isPending}
>
Login
</Button>
</Group>
</form>
</Paper>
</div>
);
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,8 @@
type CrudPermissions = {
create: boolean;
read: boolean;
update: boolean;
delete: boolean;
};
export default CrudPermissions;

3
apps/frontend/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import type CrudPermission from "./CrudPermission";
export { CrudPermission };

View File

@ -4,7 +4,7 @@ import {
MantineColor,
Tooltip,
} from "@mantine/core";
import Link from "next/link";
import { Link } from "@tanstack/react-router";
import React from "react";
interface Action {
@ -24,12 +24,13 @@ export default function createActionButtons(actions: Action[]) {
const elements = actions.map((action, i) =>
action.permission ? (
<Tooltip label={action.label} key={i}>
{typeof action.action === "string" || action.action === undefined ? (
{typeof action.action === "string" ||
action.action === undefined ? (
<ActionIcon
variant={action.variant ?? defaults.variant}
color={action.color}
component={Link}
href={action.action ?? "#"}
to={action.action ?? "#"}
>
{action.icon}
</ActionIcon>

View File

@ -0,0 +1,6 @@
function isAuthenticated(): boolean {
const accessToken = localStorage.getItem("accessToken");
return accessToken !== null;
}
export default isAuthenticated;

View File

@ -16,10 +16,10 @@ export default function stringToColorHex(inputString: string): string {
}
// Convert the number to a hex color code
let color = '#';
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xFF;
color += ('00' + value.toString(16)).substr(-2);
const value = (hash >> (i * 8)) & 0xff;
color += ("00" + value.toString(16)).substr(-2);
}
return color;

1
apps/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), TanStackRouterVite()],
resolve: {
alias: {
"@": "/src",
},
},
});

15
env.ts
View File

@ -1,15 +0,0 @@
import { z } from "zod";
const envVariables = z.object({
DATABASE_URL: z.string(),
JWT_SECRET: z.string(),
WS_PORT: z.string().optional(),
WS_HOST: z.string().optional(),
ERROR_LOG_PATH: z.string()
});
envVariables.parse(process.env);
declare global {
namespace NodeJS {
interface ProcessEnv extends z.infer<typeof envVariables> {}
}
}

1
logs/.gitignore vendored
View File

@ -1 +0,0 @@
*.log

Some files were not shown because too many files have changed in this diff Show More