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
+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);
}
}