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:
@@ -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";
|
||||
@@ -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[]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("مدیر سایت"),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user