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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user