From 29fb53a7ae2bb8b897877b9c06189a0d5f5ed4eb Mon Sep 17 00:00:00 2001 From: progof <34421475+progof@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:40:33 +0100 Subject: [PATCH] added a not found page (404) and admin routes --- backend/db/seed.ts | 53 ++++++++++++++ backend/package.json | 3 +- backend/src/admin/admin.controller.ts | 36 +++++++++ backend/src/admin/admin.service.ts | 51 +++++++++++++ backend/src/auth/password/password.service.ts | 73 +++++++++++++++---- backend/src/main.ts | 10 +++ backend/src/middlewares/admin.middlewares.ts | 20 +++++ frontend/src/assets/icons/people.svg | 5 ++ frontend/src/components/ui/button/Button.vue | 1 + frontend/src/layouts/ProfileDropdownMenu.vue | 10 +++ frontend/src/pages/PageNotFound.vue | 54 ++++++++++++++ .../src/pages/admin/AdminUsersPageContent.vue | 12 +++ frontend/src/router/index.ts | 28 +++++++ frontend/src/services/admin.service.ts | 29 ++++++++ shared/index.ts | 1 + 15 files changed, 371 insertions(+), 15 deletions(-) create mode 100644 backend/db/seed.ts create mode 100644 backend/src/admin/admin.controller.ts create mode 100644 backend/src/admin/admin.service.ts create mode 100644 backend/src/middlewares/admin.middlewares.ts create mode 100644 frontend/src/assets/icons/people.svg create mode 100644 frontend/src/pages/PageNotFound.vue create mode 100644 frontend/src/pages/admin/AdminUsersPageContent.vue create mode 100644 frontend/src/services/admin.service.ts diff --git a/backend/db/seed.ts b/backend/db/seed.ts new file mode 100644 index 0000000..c2ec222 --- /dev/null +++ b/backend/db/seed.ts @@ -0,0 +1,53 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { z } from "zod"; +import * as schema from "./schema.js"; +import dotenv from "dotenv"; +import postgres from "postgres"; + +dotenv.config(); + +const env = z + .object({ + DB_CONNECTION_URI: z.string().url(), + ADMIN_EMAILS: z + .string() + .transform((value) => { + return value.split(",").filter((maybeEmail) => { + const result = z.string().email().safeParse(maybeEmail); + + if (!result.success) { + console.warn("Skipping invalid email:", maybeEmail); + return false; + } + + return true; + }); + }) + .optional(), + }) + .parse(process.env); + +const sql = postgres(env.DB_CONNECTION_URI, { max: 1 }); +const db = drizzle(sql, { schema }); + + + +if (env.ADMIN_EMAILS) { + await Promise.all( + env.ADMIN_EMAILS.map(async (email) => { + try { + await db.insert(schema.adminsTable).values({ email }); + + console.log("Created admin:", email); + } catch (error) { + if (error instanceof postgres.PostgresError && error.code === "23505") { + console.warn("Admin already exists:", email); + return; + } + console.error(error); + } + }), + ); +} + +await sql.end({ timeout: 1 }); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 5e8ffa5..8129ca6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push", + "db:seed": "tsx ./db/seed.ts" }, "dependencies": { "@aws-sdk/client-s3": "3.622.0", diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..5c72511 --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,36 @@ +import { Request, Response, Router } from "express"; +import { db } from "@/db.js"; + +import { AdminService } from "./admin.service.js"; +import { checkAdmin } from "@/middlewares/admin.middlewares.js"; + +export class AdminController { + public readonly router = Router(); + + // Constructor initialization of routes + constructor( + private readonly adminService: AdminService, + ) { + this.router.get( + "/admin/users", + checkAdmin, + this.getAllUsers.bind(this) + ); + + } + + // Get all users with their details + + public async getAllUsers(req: Request, res: Response) { + try { + const users = await this.adminService.getAllUsers(db); + + return res.status(200).send({ users }); + } catch (error) { + console.error(error); + res.status(500).send({ errors: [{ message: "Internal server error" }] }); + } + } + + +} \ No newline at end of file diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..ceecc05 --- /dev/null +++ b/backend/src/admin/admin.service.ts @@ -0,0 +1,51 @@ +import { dateToUTCTimestamp } from "@/utils.js"; +import { adminsTable, usersTable } from "@db/schema.js"; +import { User } from "@shared/index.js"; +import { z, ZodType } from "zod"; + + +export const userSchema = z.object({ + id: z.string(), + username: z.string(), + email: z.string(), + emailVerified: z.boolean(), + avatarKey: z.string().nullable(), + createdAt: z.number(), + role: z.string(), + score: z.number(), + level: z.number(), + currency: z.number(), + isAdmin: z.boolean(), +}) satisfies ZodType; + +export class AdminService { + constructor() {} + + async isAdminEmail(db: any, email: string): Promise { + const admin = await db + .select(adminsTable) + .where({ email }) + .single(); + return !!admin; + } + + async getAllUsers(db: any): Promise { + const user = await db + .select(usersTable) + .all(); + return user.map((u: any) => userSchema.parse({ + id: u.id, + username: u.username, + email: u.email, + emailVerified: u.emailVerified, + avatarKey: u.avatarKey, + createdAt: dateToUTCTimestamp(u.createdAt), + role: u.role, + score: u.score, + level: u.level, + currency: u.currency, + isAdmin: u.isAdmin, + } satisfies User)); + } + +} \ No newline at end of file diff --git a/backend/src/auth/password/password.service.ts b/backend/src/auth/password/password.service.ts index cc294bd..8eb6d1e 100644 --- a/backend/src/auth/password/password.service.ts +++ b/backend/src/auth/password/password.service.ts @@ -8,9 +8,10 @@ import { userSessionsTable, passwordAccountsTable, PasswordAccount, + adminsTable, } from "../../../db/schema.js"; import { eq } from "drizzle-orm"; -import { HTTPError, dateToUTCTimestamp } from "@/utils.js"; +import { Database, HTTPError, Transaction, dateToUTCTimestamp } from "@/utils.js"; import { User } from "@shared/index.js"; import { z, ZodType } from "zod"; @@ -26,6 +27,7 @@ export const userSchema = z.object({ score: z.number(), level: z.number(), currency: z.number(), + isAdmin: z.boolean(), }) satisfies ZodType; export class AuthPasswordService { @@ -118,33 +120,39 @@ export class AuthPasswordService { .where(eq(resetPasswordRequestsTable.userId, userId)); } - async getUserById(userId: string):Promise { + + + + async getUserById(userId: string): Promise { const user = ( await db .select() .from(usersTable) + .leftJoin(adminsTable, eq(usersTable.email, adminsTable.email)) .where(eq(usersTable.id, userId)) ).at(0); if (!user) { throw new HTTPError({ status: 404, message: "User not found" }); } - + return userSchema.parse({ - id: user.id, - username: user.username, - email: user.email, - emailVerified: user.emailVerified, - avatarKey: user.avatarKey, - createdAt: dateToUTCTimestamp(user.createdAt), - role: user.role, - score: user.score, - level: user.level, - currency: user.currency, + id: user.users.id, + username: user.users.username, + email: user.users.email, + emailVerified: user.users.emailVerified, + avatarKey: user.users.avatarKey, + createdAt: dateToUTCTimestamp(user.users.createdAt), + role: user.users.role, + score: user.users.score, + level: user.users.level, + currency: user.users.currency, + isAdmin: user.admins !== null, + } satisfies User); } - // async getUserById(db: Database | Transaction, userId: string): Promise { + // async getUserById(userId: string):Promise { // const user = ( // await db // .select() @@ -155,7 +163,39 @@ export class AuthPasswordService { // if (!user) { // throw new HTTPError({ status: 404, message: "User not found" }); // } + + // return userSchema.parse({ + // id: user.id, + // username: user.username, + // email: user.email, + // emailVerified: user.emailVerified, + // avatarKey: user.avatarKey, + // createdAt: dateToUTCTimestamp(user.createdAt), + // role: user.role, + // score: user.score, + // level: user.level, + // currency: user.currency, + // } satisfies User); + // } + + + + // function for get user by userId with checking if the user is an admin join with adminsTable + + // async getUserById(userId: string): Promise { + // const user = ( + // await db + // .select() + // .from(usersTable) + // .leftJoin(adminsTable, eq(usersTable.email, adminsTable.email)) + // .where(eq(usersTable.id, userId)) + // ).at(0); + + // if (!user) { + // throw new HTTPError({ status: 404, message: "User not found" }); + // } + // return userSchema.parse({ // id: user.id, // username: user.username, @@ -165,9 +205,14 @@ export class AuthPasswordService { // createdAt: dateToUTCTimestamp(user.createdAt), // role: user.role, // score: user.score, + // level: user.level, + // currency: user.currency, + // isAdmin: user.admins !== null, // } satisfies User); // } + + async getUserRoleById(userId: string) { const [user] = await db .select({ role: usersTable.role }) diff --git a/backend/src/main.ts b/backend/src/main.ts index 793ecaa..aeee37c 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -18,8 +18,13 @@ import { CommentsService } from "@/dayquest/comment.service.js"; import { AuthGoogleService } from "./auth/google/google.service.js"; import { AuthGoogleController } from "./auth/google/google.controller.js"; + +import { AdminService } from "./admin/admin.service.js"; +import { AdminController } from "./admin/admin.controller.js"; + import { db } from "./db.js"; + const app = express(); app.use( @@ -72,6 +77,11 @@ const dayQuestController = new DayQuestController( ); app.use(dayQuestController.router); + +export const adminService = new AdminService(); +const adminController = new AdminController(adminService); +app.use(adminController.router); + app.listen(config.APP_PORT, () => { console.log(`Server running on port: ${config.APP_PORT}`); }); diff --git a/backend/src/middlewares/admin.middlewares.ts b/backend/src/middlewares/admin.middlewares.ts new file mode 100644 index 0000000..7e2f93f --- /dev/null +++ b/backend/src/middlewares/admin.middlewares.ts @@ -0,0 +1,20 @@ +import type { NextFunction, Request, Response } from "express"; +import { AdminService } from "@/admin/admin.service.js"; +import { db } from "@/db.js"; + + +// Middleware to check if the user is an admin. +export async function checkAdmin(req: Request, res: Response, next: NextFunction) { + if (!req.session.user) { + return res.status(401).send({ errors: [{ message: "Unauthorized" }] }); + } + + const adminService = new AdminService(); + const isAdmin = await adminService.isAdminEmail(db, req.session.user.email); + + if (!isAdmin) { + return res.status(403).send({ errors: [{ message: "Forbidden" }] }); + } + + next(); +} \ No newline at end of file diff --git a/frontend/src/assets/icons/people.svg b/frontend/src/assets/icons/people.svg new file mode 100644 index 0000000..0800284 --- /dev/null +++ b/frontend/src/assets/icons/people.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ui/button/Button.vue b/frontend/src/components/ui/button/Button.vue index ffcfabb..1e31238 100644 --- a/frontend/src/components/ui/button/Button.vue +++ b/frontend/src/components/ui/button/Button.vue @@ -17,6 +17,7 @@ export const buttonVariants = cva( primary: "bg-blue-500 text-zinc-200 hover:bg-blue-600 disabled:bg-zinc-400", secondary: "text-zinc-100 disabled:bg-zinc-400 border border-zinc-100", + tertiary: "text-blue-500 disabled:bg-zinc-400 border border-blue-500", }, }, defaultVariants: { diff --git a/frontend/src/layouts/ProfileDropdownMenu.vue b/frontend/src/layouts/ProfileDropdownMenu.vue index 56a2074..88ef1c7 100644 --- a/frontend/src/layouts/ProfileDropdownMenu.vue +++ b/frontend/src/layouts/ProfileDropdownMenu.vue @@ -14,6 +14,7 @@ import ScalesIcon from "@/assets/icons/scales.svg?component"; import SettingsIcon from "@/assets/icons/settings.svg?component"; import LogoutIcon from "@/assets/icons/logout.svg?component"; import LoaderIcon from "@/assets/icons/loader.svg?component"; +import PeopleIcon from "@/assets/icons/people.svg?component"; import { getMeQueryOptions } from "@/services/user.service"; import { computed } from "vue"; import type { User } from "@shared/index"; @@ -82,6 +83,15 @@ const avatar = computed(() => { Balances + diff --git a/frontend/src/pages/PageNotFound.vue b/frontend/src/pages/PageNotFound.vue new file mode 100644 index 0000000..2fcc31a --- /dev/null +++ b/frontend/src/pages/PageNotFound.vue @@ -0,0 +1,54 @@ + + diff --git a/frontend/src/pages/admin/AdminUsersPageContent.vue b/frontend/src/pages/admin/AdminUsersPageContent.vue new file mode 100644 index 0000000..289b69b --- /dev/null +++ b/frontend/src/pages/admin/AdminUsersPageContent.vue @@ -0,0 +1,12 @@ + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 915117e..06e3074 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -46,6 +46,21 @@ const router = createRouter({ path: "/user/settings", component: () => import("../pages/user/settings.vue"), }, + { + path: '/admin', + children: [ + { + path: 'dashboard', + name: 'AdminUsersPage', + component: () => import("../pages/admin/AdminUsersPageContent.vue") + } + ], + meta: { requiresAdmin: true } // добавляем мета-тег для маршрутов администратора + }, + { + path: "/:pathMatch(.*)*", + component: () => import("../pages/PageNotFound.vue"), + }, ], history: createWebHistory(), }); @@ -56,6 +71,19 @@ router.beforeEach(async (to) => { return true; } + + if (to.meta.requiresAdmin) { + try { + const user = await queryClient.ensureQueryData(getMeQueryOptions); + + if (!user.isAdmin) { + return { path: "/" }; + } + } catch (error) { + return { path: "/auth/login" }; + } + } + if (to.meta.requiresAuth) { try { await queryClient.ensureQueryData(getMeQueryOptions); diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts new file mode 100644 index 0000000..a93059e --- /dev/null +++ b/frontend/src/services/admin.service.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import type { User } from "@shared/index"; + +const errorSchema = z.object({ + errors: z.array( + z.object({ + message: z.string(), + }) + ), +}); + + +// function for checking if the user is an admin + +export const checkAdmin = async () => { + const res = await fetch(`/api/auth/admin/`, { + headers: { + Accept: "application/json", + }, + }); + + if (!res.ok) { + const errors = errorSchema.parse(await res.json()).errors; + throw new Error(errors.at(0)?.message); + } + + const responseData = await res.json(); + return responseData.data as User; +}; \ No newline at end of file diff --git a/shared/index.ts b/shared/index.ts index 305128f..2bb579f 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -10,6 +10,7 @@ export interface User { level: number; currency: number; createdAt: number; + isAdmin: boolean; } // Task type