feat: polish admin UI, phone login, and product details

Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
S. Esmaeili
2026-10-22 17:18:17 +03:30
co-authored by Cursor
parent 789d224ea0
commit 49f30a96b1
54 changed files with 3632 additions and 574 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ REFRESH_TTL_DAYS=30
COOKIE_SECURE=false
# first admin user, created by `pnpm db:seed`
ADMIN_EMAIL=admin@imenpishgaman.ir
ADMIN_PHONE=09120000000
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=مدیر سایت
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "applications" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "attributes" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "brand" TEXT NOT NULL DEFAULT '',
ADD COLUMN "details" TEXT NOT NULL DEFAULT '',
ADD COLUMN "features" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "minOrder" TEXT NOT NULL DEFAULT '',
ADD COLUMN "origin" TEXT NOT NULL DEFAULT '',
ADD COLUMN "packaging" TEXT NOT NULL DEFAULT '',
ADD COLUMN "seoDescription" TEXT NOT NULL DEFAULT '',
ADD COLUMN "seoTitle" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sizes" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "standards" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,3 @@
-- Rename login identifier from email to phone
ALTER TABLE "AdminUser" RENAME COLUMN "email" TO "phone";
ALTER INDEX "AdminUser_email_key" RENAME TO "AdminUser_phone_key";
+16 -1
View File
@@ -22,7 +22,7 @@ enum MessageStatus {
model AdminUser {
id Int @id @default(autoincrement())
email String @unique
phone String @unique
name String
passwordHash String
role Role @default(EDITOR)
@@ -114,6 +114,21 @@ model Product {
desc String @default("")
specs String[]
variants String[]
/// long description for the product page; a blank line starts a new paragraph
details String @default("")
brand String @default("")
/// country of manufacture
origin String @default("")
sizes String[] @default([])
standards String[] @default([])
features String[] @default([])
applications String[] @default([])
/// technical specification table: [{ "name": "...", "value": "..." }]
attributes Json @default("[]")
packaging String @default("")
minOrder String @default("")
seoTitle String @default("")
seoDescription String @default("")
imageId Int?
image Media? @relation("ProductImage", fields: [imageId], references: [id], onDelete: SetNull)
gallery ProductImage[]
+7 -7
View File
@@ -15,7 +15,7 @@ const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
export const toUserDto = (u: AdminUser): AdminUserDto => ({
id: u.id,
email: u.email,
phone: u.phone,
name: u.name,
role: u.role,
isActive: u.isActive,
@@ -28,9 +28,9 @@ export interface IssuedTokens extends AuthResponse {
refreshExpiresAt: Date;
}
const INVALID_LOGIN = "ایمیل یا رمز عبور اشتباه است";
const INVALID_LOGIN = "شماره موبایل یا رمز عبور اشتباه است";
/** hash of a random password, verified against when the email is unknown so timing stays the same */
/** hash of a random password, verified against when the phone is unknown so timing stays the same */
const DUMMY_HASH = hashPassword(randomBytes(16).toString("hex"));
@Injectable()
@@ -42,8 +42,8 @@ export class AuthService {
) {}
async login(input: LoginInput, userAgent: string): Promise<IssuedTokens> {
const user = await this.prisma.adminUser.findUnique({ where: { email: input.email } });
// verify even when the user is missing so response time does not reveal valid emails
const user = await this.prisma.adminUser.findUnique({ where: { phone: input.phone } });
// verify even when the user is missing so response time does not reveal valid phones
const ok = await verify(user?.passwordHash ?? (await DUMMY_HASH), input.password).catch(
() => false,
);
@@ -53,7 +53,7 @@ export class AuthService {
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
this.audit.log(user.id, "login", "user", user.id, user.email);
this.audit.log(user.id, "login", "user", user.id, user.phone);
return this.issue(updated, userAgent);
}
@@ -100,7 +100,7 @@ export class AuthService {
where: { id: userId },
data: { passwordHash: await hashPassword(input.newPassword) },
});
this.audit.log(userId, "change-password", "user", userId, user.email);
this.audit.log(userId, "change-password", "user", userId, user.phone);
}
async me(userId: number) {
+12
View File
@@ -127,6 +127,18 @@ export class ProductsService {
desc: src.desc,
specs: src.specs,
variants: src.variants,
details: src.details,
brand: src.brand,
origin: src.origin,
sizes: src.sizes,
standards: src.standards,
features: src.features,
applications: src.applications,
attributes: (src.attributes ?? []) as Prisma.InputJsonValue,
packaging: src.packaging,
minOrder: src.minOrder,
seoTitle: src.seoTitle,
seoDescription: src.seoDescription,
imageId: src.imageId,
sortOrder: src.sortOrder + 1,
isPublished: false,
+2 -2
View File
@@ -22,7 +22,7 @@ export interface JwtPayload {
export interface AuthUser {
id: number;
email: string;
phone: string;
name: string;
role: Role;
}
@@ -54,7 +54,7 @@ export class JwtAuthGuard implements CanActivate {
// re-check the user so deactivation and role changes apply immediately
const user = await this.prisma.adminUser.findUnique({ where: { id: payload.sub } });
if (!user?.isActive) throw new UnauthorizedException("حساب کاربری غیرفعال است");
req.user = { id: user.id, email: user.email, name: user.name, role: user.role };
req.user = { id: user.id, phone: user.phone, name: user.name, role: user.role };
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
ctx.getHandler(),
@@ -6,6 +6,7 @@ const FIELD_LABELS: Record<string, string> = {
slug: "اسلاگ",
code: "کد",
email: "ایمیل",
phone: "شماره موبایل",
};
interface UniqueMeta {
+1 -1
View File
@@ -38,7 +38,7 @@ const schema = z.object({
WEB_REVALIDATE_URL: z.string().optional(),
REVALIDATE_SECRET: z.string().optional(),
ADMIN_EMAIL: z.string().optional(),
ADMIN_PHONE: z.string().optional(),
ADMIN_PASSWORD: z.string().optional(),
ADMIN_NAME: z.string().default("مدیر سایت"),
});
+109 -11
View File
@@ -1,15 +1,27 @@
import { Controller, Get, Injectable, Module, NotFoundException, Param } from "@nestjs/common";
import type {
ProductAttribute,
PublicCategory,
PublicCategorySummary,
PublicProduct,
PublicProductDetail,
PublicProductRef,
PublicSite,
} from "@imen/shared";
import type { Media, Product } from "../generated/prisma/client.js";
import { MediaModule } from "../media/media.controller.js";
import { MediaService } from "../media/media.service.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { SectionsModule, SectionsService } from "../sections/sections.module.js";
/** a product is visible on the site only when it and its category are published */
const visible = { isPublished: true, category: { isPublished: true } } as const;
const RELATED_LIMIT = 4;
const isAttribute = (a: unknown): a is ProductAttribute =>
!!a && typeof a === "object" && typeof (a as ProductAttribute).name === "string" && typeof (a as ProductAttribute).value === "string";
/** Read-only data for the website. Only published categories and products are exposed. */
@Injectable()
export class PublicService {
@@ -60,17 +72,7 @@ export class PublicService {
});
if (!c) throw new NotFoundException("دسته‌بندی یافت نشد");
const products: PublicProduct[] = c.products.map((p) => ({
code: p.code,
slug: p.slug,
name: p.name,
nameEn: p.nameEn,
desc: p.desc,
specs: p.specs,
variants: p.variants,
image: p.image && this.media.url(p.image.key),
group: p.group?.slug ?? null,
}));
const products = c.products.map((p) => this.toPublicProduct(p));
// hide groups that have no published products
const usedGroups = new Set(products.map((p) => p.group));
@@ -92,6 +94,92 @@ export class PublicService {
products,
};
}
async product(slug: string): Promise<PublicProductDetail> {
const p = await this.prisma.product.findFirst({
where: { slug, ...visible },
include: {
image: true,
gallery: { orderBy: { sortOrder: "asc" }, include: { media: true } },
group: true,
category: { include: { coverImage: true } },
},
});
if (!p) throw new NotFoundException("محصول یافت نشد");
// main image first, then the gallery, without duplicates
const media = new Map<number, Media>();
for (const m of [p.image, ...p.gallery.map((g) => g.media)]) if (m) media.set(m.id, m);
// related: same subgroup first, then the rest of the category
const siblings = await this.prisma.product.findMany({
where: { categoryId: p.categoryId, isPublished: true, id: { not: p.id } },
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
include: { image: true, group: { select: { slug: true } } },
});
const sameGroup = (s: { groupId: number | null }) => p.groupId !== null && s.groupId === p.groupId;
const related = [...siblings.filter(sameGroup), ...siblings.filter((s) => !sameGroup(s))]
.slice(0, RELATED_LIMIT)
.map((s) => this.toPublicProduct(s));
return {
...this.toPublicProduct(p),
details: p.details,
brand: p.brand,
origin: p.origin,
sizes: p.sizes,
standards: p.standards,
features: p.features,
applications: p.applications,
attributes: Array.isArray(p.attributes) ? p.attributes.filter(isAttribute) : [],
packaging: p.packaging,
minOrder: p.minOrder,
seoTitle: p.seoTitle,
seoDescription: p.seoDescription,
images: [...media.values()].map((m) => ({
url: this.media.url(m.key),
alt: m.alt || p.name,
width: m.width,
height: m.height,
})),
groupName: p.group?.name ?? null,
category: {
slug: p.category.slug,
code: p.category.code,
name: p.category.name,
nameEn: p.category.nameEn,
fullDesc: p.category.fullDesc,
coverImage: p.category.coverImage && this.media.url(p.category.coverImage.key),
},
related,
updatedAt: p.updatedAt.toISOString(),
};
}
async productRefs(): Promise<PublicProductRef[]> {
const rows = await this.prisma.product.findMany({
where: visible,
orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }],
select: { slug: true, updatedAt: true },
});
return rows.map((r) => ({ slug: r.slug, updatedAt: r.updatedAt.toISOString() }));
}
private toPublicProduct(
p: Product & { image: Media | null; group: { slug: string } | null },
): PublicProduct {
return {
code: p.code,
slug: p.slug,
name: p.name,
nameEn: p.nameEn,
desc: p.desc,
specs: p.specs,
variants: p.variants,
image: p.image && this.media.url(p.image.key),
group: p.group?.slug ?? null,
};
}
}
@Controller("public")
@@ -112,6 +200,16 @@ export class PublicController {
category(@Param("slug") slug: string) {
return this.publicData.category(slug);
}
@Get("products")
products() {
return this.publicData.productRefs();
}
@Get("products/:slug")
product(@Param("slug") slug: string) {
return this.publicData.product(slug);
}
}
@Module({
+27 -7
View File
@@ -10,7 +10,7 @@ import { env } from "../config/env.js";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SECTION_KEYS, sectionSchemas } from "@imen/shared";
import { normalizeIrMobile, SECTION_KEYS, sectionSchemas } from "@imen/shared";
import { hashPassword } from "../auth/auth.service.js";
import { S3Storage, toWebp } from "../media/s3-storage.js";
import { createPrismaClient } from "../prisma/prisma.service.js";
@@ -133,21 +133,41 @@ async function seedCatalog() {
}
async function seedAdmin() {
if (!env.ADMIN_EMAIL || !env.ADMIN_PASSWORD) {
console.log(" ADMIN_EMAIL / ADMIN_PASSWORD not set — skipping admin user");
if (!env.ADMIN_PHONE || !env.ADMIN_PASSWORD) {
console.log(" ADMIN_PHONE / ADMIN_PASSWORD not set — skipping admin user");
return;
}
const email = env.ADMIN_EMAIL.trim().toLowerCase();
if (await prisma.adminUser.findUnique({ where: { email } })) return;
const phone = normalizeIrMobile(env.ADMIN_PHONE);
if (!/^09\d{9}$/.test(phone)) {
console.log(` ADMIN_PHONE invalid (${env.ADMIN_PHONE}) — skipping admin user`);
return;
}
const existing = await prisma.adminUser.findUnique({ where: { phone } });
if (existing) return;
// After email→phone migration, upgrade the first admin whose phone still looks like an email
const legacy = await prisma.adminUser.findFirst({
where: { role: "ADMIN", phone: { contains: "@" } },
orderBy: { id: "asc" },
});
if (legacy) {
await prisma.adminUser.update({
where: { id: legacy.id },
data: { phone, name: env.ADMIN_NAME, passwordHash: await hashPassword(env.ADMIN_PASSWORD) },
});
console.log(` admin user migrated to ${phone}`);
return;
}
await prisma.adminUser.create({
data: {
email,
phone,
name: env.ADMIN_NAME,
role: "ADMIN",
passwordHash: await hashPassword(env.ADMIN_PASSWORD),
},
});
console.log(` admin user ${email}`);
console.log(` admin user ${phone}`);
}
async function main() {
+4 -4
View File
@@ -40,13 +40,13 @@ export class UsersService {
async create(input: UserCreateInput, actor: AuthUser) {
const user = await this.prisma.adminUser.create({
data: {
email: input.email,
phone: input.phone,
name: input.name,
role: input.role,
passwordHash: await hashPassword(input.password),
},
});
this.audit.log(actor.id, "create", "user", user.id, user.email);
this.audit.log(actor.id, "create", "user", user.id, user.phone);
return toUserDto(user);
}
@@ -65,14 +65,14 @@ export class UsersService {
});
// a password reset or deactivation ends the user's existing sessions
if (input.password || !input.isActive) await this.auth.revokeAll(id);
this.audit.log(actor.id, "update", "user", id, user.email);
this.audit.log(actor.id, "update", "user", id, user.phone);
return toUserDto(user);
}
async remove(id: number, actor: AuthUser) {
if (id === actor.id) throw new BadRequestException("نمی‌توانید حساب خودتان را حذف کنید");
const user = await this.prisma.adminUser.delete({ where: { id } });
this.audit.log(actor.id, "delete", "user", id, user.email);
this.audit.log(actor.id, "delete", "user", id, user.phone);
}
}
+19 -8
View File
@@ -15,12 +15,12 @@ import { PrismaService } from "../src/prisma/prisma.service.js";
describe("API (e2e)", () => {
let app: INestApplication;
let prisma: PrismaService;
const suffix = randomBytes(4).toString("hex");
const admin = { email: `e2e-admin-${suffix}@test.local`, password: "e2e-Password-1" };
const editor = { email: `e2e-editor-${suffix}@test.local`, password: "e2e-Password-2" };
const suffix = String(randomBytes(4).readUInt32BE(0) % 10_000_000).padStart(7, "0");
const admin = { phone: `0912${suffix}`, password: "e2e-Password-1" };
const editor = { phone: `0935${suffix}`, password: "e2e-Password-2" };
const http = () => request(app.getHttpServer());
const login = async (u: { email: string; password: string }) => {
const login = async (u: { phone: string; password: string }) => {
const res = await http().post("/api/admin/auth/login").send(u).expect(200);
return { token: res.body.accessToken as string, cookie: res.headers["set-cookie"] as unknown as string[] };
};
@@ -35,14 +35,14 @@ describe("API (e2e)", () => {
prisma = app.get(PrismaService);
await prisma.adminUser.createMany({
data: [
{ email: admin.email, name: "e2e admin", role: "ADMIN", passwordHash: await hashPassword(admin.password) },
{ email: editor.email, name: "e2e editor", role: "EDITOR", passwordHash: await hashPassword(editor.password) },
{ phone: admin.phone, name: "e2e admin", role: "ADMIN", passwordHash: await hashPassword(admin.password) },
{ phone: editor.phone, name: "e2e editor", role: "EDITOR", passwordHash: await hashPassword(editor.password) },
],
});
});
afterAll(async () => {
await prisma.adminUser.deleteMany({ where: { email: { in: [admin.email, editor.email] } } });
await prisma.adminUser.deleteMany({ where: { phone: { in: [admin.phone, editor.phone] } } });
await app.close();
});
@@ -63,6 +63,17 @@ describe("API (e2e)", () => {
await http().get("/api/public/categories/no-such-category").expect(404);
});
it("returns a product page with related products, 404 for unknown slugs", async () => {
const { body: refs } = await http().get("/api/public/products").expect(200);
expect(refs.length).toBeGreaterThan(0);
const res = await http().get(`/api/public/products/${refs[0].slug}`).expect(200);
expect(res.body.category.slug).toBeTruthy();
expect(Array.isArray(res.body.attributes)).toBe(true);
expect(res.body.related.length).toBeLessThanOrEqual(4);
expect(res.body.related.some((r: { slug: string }) => r.slug === refs[0].slug)).toBe(false);
await http().get("/api/public/products/no-such-product").expect(404);
});
it("validates the contact form", async () => {
const res = await http().post("/api/public/contact").send({ name: "", org: "x", phone: "abc" }).expect(400);
expect(res.body.issues.map((i: { path: string }) => i.path).sort()).toEqual(["name", "phone"]);
@@ -81,7 +92,7 @@ describe("API (e2e)", () => {
describe("auth", () => {
it("rejects admin routes without a token and wrong passwords", async () => {
await http().get("/api/admin/dashboard").expect(401);
await http().post("/api/admin/auth/login").send({ email: admin.email, password: "wrong-password" }).expect(401);
await http().post("/api/admin/auth/login").send({ phone: admin.phone, password: "wrong-password" }).expect(401);
});
it("sets an httpOnly refresh cookie and rotates it", async () => {