From d4fee8d1d75547c097af0de6b771449fab4a9b8a Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 5 Jun 2026 00:34:25 +0330 Subject: [PATCH] feat(profile): role-aware nav + avatar menu + full editable profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation: - UserMenu (avatar + role-aware dropdown: Dashboard, Admin Panel for admins, Profile, Sign out) replaces Sign In/Try Free when logged in (desktop + mobile). - Real avatars in dashboard sidebar + a new admin-shell profile section. - Shared Avatar primitive (image with initials fallback). SiteChrome excludes /admin. Profile (data-collection surface for future AI video generation): - SettingsProfile rebuilt: avatar upload + slogan, about, company, website, country, national code, birthdate, gender. No resume builder (per scope change). - /api/profile forwards all fields; new user-scoped /api/profile/upload (avatar → MinIO via file-svc, sets avatar). Identity UpdateUserRequest/UserResponse widened (country/national/method); no DB migration (columns already exist). - fa+en strings; verified GET/PATCH round-trip + logged-in SSR render. Co-Authored-By: Claude Opus 4.8 --- messages/en.json | 32 ++- messages/fa.json | 32 ++- .../Application/Services/AuthService.cs | 4 +- .../Application/Services/UserService.cs | 3 + .../Models/Requests/UserRequests.cs | 5 +- .../Models/Responses/Responses.cs | 11 +- src/app/[locale]/admin/layout.tsx | 14 +- src/app/[locale]/dashboard/layout.tsx | 1 + src/app/[locale]/dashboard/settings/page.tsx | 18 +- src/app/[locale]/layout.tsx | 4 +- src/app/api/profile/route.ts | 29 ++- src/app/api/profile/upload/route.ts | 91 +++++++++ src/components/admin/AdminShell.tsx | 30 +++ src/components/dashboard/DashboardShell.tsx | 3 + src/components/dashboard/DashboardSidebar.tsx | 30 +-- .../dashboard/settings/SettingsProfile.tsx | 187 +++++++++++++----- src/components/layout/Navbar.tsx | 113 ++++++++--- src/components/layout/SiteChrome.tsx | 11 +- src/components/layout/UserMenu.tsx | 76 +++++++ src/components/ui/Avatar.tsx | 59 ++++++ src/lib/auth/session.ts | 22 +++ 21 files changed, 659 insertions(+), 116 deletions(-) create mode 100644 src/app/api/profile/upload/route.ts create mode 100644 src/components/layout/UserMenu.tsx create mode 100644 src/components/ui/Avatar.tsx diff --git a/messages/en.json b/messages/en.json index 55ca92e..190913e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12,6 +12,13 @@ "learn": "Learn", "signIn": "Sign In", "tryForFree": "Try for Free", + "accountMenu": "Account menu", + "roleAdmin": "Admin", + "roleUser": "Member", + "menuDashboard": "Dashboard", + "menuAdminPanel": "Admin panel", + "menuProfile": "Profile & settings", + "menuSignOut": "Sign out", "openMenuAriaLabel": "Open navigation menu", "mobileMenuTitle": "Menu", "videoMakerBrowse": "Browse Templates", @@ -521,7 +528,7 @@ }, "componentsDashboardSettingsSettingsProfile": { "title": "Profile", - "subtitle": "Your public name and account email.", + "subtitle": "Your public profile, photo and account details.", "displayNameLabel": "Display name", "displayNamePlaceholder": "Your name", "emailLabel": "Email", @@ -530,7 +537,28 @@ "saveChanges": "Save changes", "updateFailed": "Could not update profile.", "updateSuccess": "Profile updated successfully.", - "networkError": "Network error. Please try again." + "networkError": "Network error. Please try again.", + "changeAvatar": "Change profile picture", + "uploading": "Uploading image…", + "avatarUpdated": "Profile picture updated.", + "uploadFailed": "Could not upload the image.", + "sloganLabel": "Slogan / headline", + "sloganPlaceholder": "e.g. Motion designer", + "aboutLabel": "About me", + "aboutPlaceholder": "Tell us a little about yourself…", + "companyLabel": "Company / business", + "websiteLabel": "Website", + "countryLabel": "Country", + "countryPlaceholder": "United States", + "nationalCodeLabel": "National ID", + "birthDateLabel": "Date of birth", + "genderLabel": "Gender", + "genderUnset": "Not specified", + "genderMale": "Male", + "genderFemale": "Female", + "genderOther": "Other", + "genderPreferNotToSay": "Prefer not to say", + "dataCollectionHint": "This information is used to personalize your experience and power future AI video generation." }, "componentsDashboardSettingsSettingsSecurity": { "title": "Security", diff --git a/messages/fa.json b/messages/fa.json index 4b2e322..d6565ef 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -12,6 +12,13 @@ "learn": "یادگیری", "signIn": "ورود", "tryForFree": "رایگان شروع کنید", + "accountMenu": "منوی حساب", + "roleAdmin": "مدیر", + "roleUser": "کاربر", + "menuDashboard": "داشبورد", + "menuAdminPanel": "پنل مدیریت", + "menuProfile": "پروفایل و تنظیمات", + "menuSignOut": "خروج", "openMenuAriaLabel": "باز کردن منو", "mobileMenuTitle": "منو", "videoMakerBrowse": "مرور قالب‌ها", @@ -521,7 +528,7 @@ }, "componentsDashboardSettingsSettingsProfile": { "title": "پروفایل", - "subtitle": "نام عمومی و ایمیل حساب شما.", + "subtitle": "پروفایل عمومی، تصویر و اطلاعات حساب شما.", "displayNameLabel": "نام نمایشی", "displayNamePlaceholder": "نام شما", "emailLabel": "ایمیل", @@ -530,7 +537,28 @@ "saveChanges": "ذخیره تغییرات", "updateFailed": "به‌روزرسانی پروفایل ممکن نشد.", "updateSuccess": "پروفایل با موفقیت به‌روزرسانی شد.", - "networkError": "خطای شبکه. لطفاً دوباره تلاش کنید." + "networkError": "خطای شبکه. لطفاً دوباره تلاش کنید.", + "changeAvatar": "تغییر تصویر پروفایل", + "uploading": "در حال بارگذاری تصویر…", + "avatarUpdated": "تصویر پروفایل به‌روزرسانی شد.", + "uploadFailed": "بارگذاری تصویر ممکن نشد.", + "sloganLabel": "شعار / عنوان", + "sloganPlaceholder": "مثلاً طراح موشن گرافیک", + "aboutLabel": "درباره من", + "aboutPlaceholder": "کمی درباره خودتان بنویسید…", + "companyLabel": "شرکت / کسب‌وکار", + "websiteLabel": "وب‌سایت", + "countryLabel": "کشور", + "countryPlaceholder": "ایران", + "nationalCodeLabel": "کد ملی", + "birthDateLabel": "تاریخ تولد", + "genderLabel": "جنسیت", + "genderUnset": "انتخاب نشده", + "genderMale": "مرد", + "genderFemale": "زن", + "genderOther": "سایر", + "genderPreferNotToSay": "ترجیح می‌دهم نگویم", + "dataCollectionHint": "این اطلاعات برای شخصی‌سازی و ساخت خودکار ویدیو با هوش مصنوعی در آینده استفاده می‌شود." }, "componentsDashboardSettingsSettingsSecurity": { "title": "امنیت", diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs index 3f87bc1..6c4e49a 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs @@ -488,7 +488,9 @@ public class AuthService( u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(), u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor, u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount, - u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate + u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate, + u.Slogan, u.AboutMe, u.CompanyName, u.WebsiteName, + u.BirthDate, u.Gender?.ToString(), u.NationalCode, u.CountryCode ); internal static TenantResponse MapTenantResponse(Tenant t) => new( diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/UserService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/UserService.cs index 2a09f8d..cb60c5f 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/UserService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/UserService.cs @@ -33,6 +33,9 @@ public class UserService(IdentityDbContext db) : IUserService if (request.SmsTellMe.HasValue) user.SmsTellMe = request.SmsTellMe.Value; if (request.PushTellMe.HasValue) user.PushTellMe = request.PushTellMe.Value; if (request.TelegramTellMe.HasValue) user.TelegramTellMe = request.TelegramTellMe.Value; + if (request.CountryCode != null) user.CountryCode = request.CountryCode; + if (request.NationalCode != null) user.NationalCode = request.NationalCode; + if (request.MethodOfIntroduction != null) user.MethodOfIntroduction = request.MethodOfIntroduction; user.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); diff --git a/services/identity/FlatRender.IdentitySvc/Models/Requests/UserRequests.cs b/services/identity/FlatRender.IdentitySvc/Models/Requests/UserRequests.cs index 9ad68fb..4ee269e 100644 --- a/services/identity/FlatRender.IdentitySvc/Models/Requests/UserRequests.cs +++ b/services/identity/FlatRender.IdentitySvc/Models/Requests/UserRequests.cs @@ -11,7 +11,10 @@ public record UpdateUserRequest( bool? EmailTellMe, bool? SmsTellMe, bool? PushTellMe, - bool? TelegramTellMe + bool? TelegramTellMe, + string? CountryCode = null, + string? NationalCode = null, + string? MethodOfIntroduction = null ); public record SetAvatarRequest(Guid? AvatarId, string? AvatarUrl); diff --git a/services/identity/FlatRender.IdentitySvc/Models/Responses/Responses.cs b/services/identity/FlatRender.IdentitySvc/Models/Responses/Responses.cs index 67d0aac..7a504ee 100644 --- a/services/identity/FlatRender.IdentitySvc/Models/Responses/Responses.cs +++ b/services/identity/FlatRender.IdentitySvc/Models/Responses/Responses.cs @@ -48,7 +48,16 @@ public record UserResponse( int MaxDailyRenderCount, int ParallelRenderingCeiling, long UsedStorageBytes, - DateTime RegisterDate + DateTime RegisterDate, + // Profile data (collected over time; powers future AI video generation) + string? Slogan = null, + string? AboutMe = null, + string? CompanyName = null, + string? WebsiteName = null, + DateOnly? BirthDate = null, + string? Gender = null, + string? NationalCode = null, + string? CountryCode = null ); public record BalanceResponse( diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index b0fd79f..c5a5685 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -73,8 +73,20 @@ export default async function AdminLayout({ }, ]; + const email = user.email ?? ""; + const fullName = typeof user.full_name === "string" ? user.full_name.trim() : ""; + return ( - + {children} ); diff --git a/src/app/[locale]/dashboard/layout.tsx b/src/app/[locale]/dashboard/layout.tsx index ede0477..fa23e5a 100644 --- a/src/app/[locale]/dashboard/layout.tsx +++ b/src/app/[locale]/dashboard/layout.tsx @@ -21,6 +21,7 @@ export default async function DashboardLayout({ userEmail={user.email ?? ""} userName={user.full_name ?? null} userId={user.id} + avatarUrl={(user.avatar_url as string | null) ?? null} > {children} diff --git a/src/app/[locale]/dashboard/settings/page.tsx b/src/app/[locale]/dashboard/settings/page.tsx index 49e5528..095753c 100644 --- a/src/app/[locale]/dashboard/settings/page.tsx +++ b/src/app/[locale]/dashboard/settings/page.tsx @@ -23,8 +23,20 @@ export default async function DashboardSettingsPage() { const user = await getCurrentUser(); const email = user?.email ?? ""; - const displayName = - typeof user?.full_name === "string" ? user.full_name : null; + const u = (user ?? {}) as Record; + const str = (v: unknown) => (typeof v === "string" ? v : ""); + const initialProfile = { + full_name: str(u.full_name), + avatar_url: typeof u.avatar_url === "string" ? u.avatar_url : null, + slogan: str(u.slogan), + about_me: str(u.about_me), + company_name: str(u.company_name), + website_name: str(u.website_name), + country_code: str(u.country_code), + national_code: str(u.national_code), + birth_date: str(u.birth_date), + gender: str(u.gender), + }; const profile = user ? await getUserProfile(user.id) : null; const plan = profile?.plan ?? "free"; @@ -42,7 +54,7 @@ export default async function DashboardSettingsPage() { {/* Content */}
- + diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index b6f33a5..29f5da7 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl"; import { DirectionProvider } from "@/components/layout/DirectionProvider"; import { SiteChrome } from "@/components/layout/SiteChrome"; +import { getNavUser } from "@/lib/auth/session"; import { routing } from "@/i18n/routing"; import type { Locale } from "@/i18n/routing"; @@ -85,6 +86,7 @@ export default async function LocaleLayout({ const messages = await getMessages(); const isRtl = locale === "fa"; + const navUser = await getNavUser(); /** * Font class strategy: @@ -112,7 +114,7 @@ export default async function LocaleLayout({ > - {children} + {children} diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index c60a272..76fe82d 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -6,10 +6,24 @@ import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants"; export const dynamic = "force-dynamic"; +// Profile fields the user may edit; forwarded as-is (snake_case) to the Identity +// PATCH DTO. This is the data-collection surface that later powers AI video gen. +const EDITABLE_FIELDS = [ + "full_name", + "slogan", + "about_me", + "company_name", + "website_name", + "birth_date", + "gender", + "country_code", + "national_code", + "method_of_introduction", +] as const; + /** * Update the signed-in user's profile via Identity (`PATCH /v1/users/me`). - * Currently surfaces the display name (full_name); the Identity DTO accepts more - * fields that can be added here as the settings UI grows. + * Forwards any of the editable profile fields that are present in the request body. */ export async function PATCH(req: Request) { const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value; @@ -18,15 +32,20 @@ export async function PATCH(req: Request) { } const body = await req.json().catch(() => null); - const fullName = typeof body?.full_name === "string" ? body.full_name.trim() : undefined; - if (fullName === undefined) { + const payload: Record = {}; + for (const key of EDITABLE_FIELDS) { + const v = body?.[key]; + if (v === undefined || v === null) continue; + payload[key] = typeof v === "string" ? v.trim() : v; + } + if (Object.keys(payload).length === 0) { return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); } const res = await gatewayFetch("/v1/users/me", { method: "PATCH", headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify({ full_name: fullName }), + body: JSON.stringify(payload), }); if (!res.ok) { diff --git a/src/app/api/profile/upload/route.ts b/src/app/api/profile/upload/route.ts new file mode 100644 index 0000000..39cee5e --- /dev/null +++ b/src/app/api/profile/upload/route.ts @@ -0,0 +1,91 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; +import { MINIO_PUBLIC_URL } from "@/lib/files"; + +export const dynamic = "force-dynamic"; + +/** + * User-scoped upload (avatar / profile media). Same Browser → Next → MinIO proxy as + * the admin uploader, but available to ANY logged-in user (file-svc only requires + * auth, not admin). Returns the public object URL. + */ +export async function POST(req: NextRequest) { + const token = await getAccessToken(); + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const form = await req.formData().catch(() => null); + const file = form?.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + // Avatars are images and small — guard so a profile upload can't be abused for + // arbitrary large files. (5 MB is generous for an avatar.) + if (file.size > 5 * 1024 * 1024) { + return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 413 }); + } + if (!file.type.startsWith("image/")) { + return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 }); + } + + const auth = { Authorization: `Bearer ${token}` }; + + // 1. presigned PUT URL + const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), { + method: "POST", + cache: "no-store", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + mime_type: file.type || "application/octet-stream", + size_bytes: file.size, + }), + }); + const presign = await presignRes.json().catch(() => null); + if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) { + return NextResponse.json( + { error: presign?.error?.message ?? "Could not start upload" }, + { status: presignRes.status || 502 } + ); + } + + // 2. PUT the bytes to MinIO (server-side; reaches minio:9000) + const put = await fetch(presign.upload_url, { + method: "PUT", + headers: { "Content-Type": file.type || "application/octet-stream" }, + body: Buffer.from(await file.arrayBuffer()), + }); + if (!put.ok) { + return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 }); + } + + // 3. confirm + await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), { + method: "POST", + cache: "no-store", + headers: auth, + }); + + // 4. fetch the record → build the public URL + const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), { + cache: "no-store", + headers: auth, + }); + const detail = await detailRes.json().catch(() => null); + const bucket = detail?.minio_bucket ?? "user-uploads"; + const key = detail?.minio_key; + const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null; + + // 5. persist as the user's avatar (Identity `POST /v1/users/me/avatar`) + if (url) { + await fetch(gatewayUrl("/v1/users/me/avatar"), { + method: "POST", + cache: "no-store", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ avatar_url: url }), + }).catch(() => null); + } + + return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: file.type, url }); +} diff --git a/src/components/admin/AdminShell.tsx b/src/components/admin/AdminShell.tsx index 2268aa2..31b5bf4 100644 --- a/src/components/admin/AdminShell.tsx +++ b/src/components/admin/AdminShell.tsx @@ -1,25 +1,31 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { Link, usePathname } from "@/i18n/navigation"; +import { Avatar } from "@/components/ui/Avatar"; export interface NavItem { href: string; label: string } export interface NavGroup { title: string; items: NavItem[] } +export interface AdminUser { name: string; email: string; avatarUrl: string | null } export function AdminShell({ groups, brand, back, + user, children, }: { groups: NavGroup[]; brand: string; back: string; + user?: AdminUser; children: React.ReactNode; }) { const pathname = usePathname() ?? ""; // next-intl: already without the locale prefix const [open, setOpen] = useState(false); + const tNav = useTranslations("nav"); const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/"); const current = groups.flatMap((g) => g.items).find((i) => isActive(i.href)); @@ -66,9 +72,33 @@ export function AdminShell({ ))}
+ {user && ( + setOpen(false)} + > + +
+

{user.name}

+

{user.email}

+
+ + )} ← {back} +
+ +
diff --git a/src/components/dashboard/DashboardShell.tsx b/src/components/dashboard/DashboardShell.tsx index d497d0d..6b6592f 100644 --- a/src/components/dashboard/DashboardShell.tsx +++ b/src/components/dashboard/DashboardShell.tsx @@ -4,6 +4,7 @@ interface DashboardShellProps { userEmail: string; userName?: string | null; userId: string; + avatarUrl?: string | null; children: React.ReactNode; } @@ -11,6 +12,7 @@ export function DashboardShell({ userEmail, userName, userId, + avatarUrl, children, }: DashboardShellProps) { return ( @@ -19,6 +21,7 @@ export function DashboardShell({ userEmail={userEmail} userName={userName} userId={userId} + avatarUrl={avatarUrl} />
{children}
diff --git a/src/components/dashboard/DashboardSidebar.tsx b/src/components/dashboard/DashboardSidebar.tsx index 29ef593..d03dda6 100644 --- a/src/components/dashboard/DashboardSidebar.tsx +++ b/src/components/dashboard/DashboardSidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { Suspense } from "react"; import { getTranslations } from "next-intl/server"; import { LogoMark } from "@/components/ui/LogoMark"; +import { Avatar } from "@/components/ui/Avatar"; import { DashboardPlanBadge, @@ -13,27 +14,16 @@ interface DashboardSidebarProps { userEmail: string; userName?: string | null; userId: string; -} - -function getInitials(email: string, name?: string | null): string { - if (name?.trim()) { - const parts = name.trim().split(/\s+/); - return parts - .slice(0, 2) - .map((part) => part[0]?.toUpperCase() ?? "") - .join(""); - } - - return email.slice(0, 2).toUpperCase(); + avatarUrl?: string | null; } export async function DashboardSidebar({ userEmail, userName, userId, + avatarUrl, }: DashboardSidebarProps) { const t = await getTranslations("auto.componentsDashboardDashboardSidebar"); - const initials = getInitials(userEmail, userName); return (
-
-
- {initials} -
+ +

{userName ?? userEmail.split("@")[0]}

{userEmail}

-
+
+ void handleAvatar(e)} />
-

{displayName ?? email.split("@")[0]}

-

{email}

+

{form.full_name || email.split("@")[0]}

+

{uploading ? t("uploading") : email}

void handleSave(e)} className="mt-6 space-y-4"> -
- - setName(e.target.value)} - placeholder={t("displayNamePlaceholder")} - className="mt-1.5 block w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" - /> +
+
+ + set("full_name", e.target.value)} placeholder={t("displayNamePlaceholder")} className={field} /> +
+
+ + set("slogan", e.target.value)} placeholder={t("sloganPlaceholder")} className={field} /> +
- + +