feat(profile): role-aware nav + avatar menu + full editable profile
Build backend images / build content-svc (push) Failing after 1m59s
Build backend images / build file-svc (push) Failing after 3m18s
Build backend images / build gateway (push) Failing after 3m28s
Build backend images / build identity-svc (push) Failing after 2m1s
Build backend images / build notification-svc (push) Failing after 4m45s
Build backend images / build render-svc (push) Failing after 5m18s
Build backend images / build studio-svc (push) Failing after 2m12s
Build backend images / build content-svc (push) Failing after 1m59s
Build backend images / build file-svc (push) Failing after 3m18s
Build backend images / build gateway (push) Failing after 3m28s
Build backend images / build identity-svc (push) Failing after 2m1s
Build backend images / build notification-svc (push) Failing after 4m45s
Build backend images / build render-svc (push) Failing after 5m18s
Build backend images / build studio-svc (push) Failing after 2m12s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<AdminShell groups={groups} brand={t("brand")} back={t("backToDashboard")}>
|
||||
<AdminShell
|
||||
groups={groups}
|
||||
brand={t("brand")}
|
||||
back={t("backToDashboard")}
|
||||
user={{
|
||||
name: fullName || (email ? email.split("@")[0] : "Admin"),
|
||||
email,
|
||||
avatarUrl: (user.avatar_url as string | null) ?? null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AdminShell>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</DashboardShell>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<SettingsProfile email={email} displayName={displayName} />
|
||||
<SettingsProfile email={email} initial={initialProfile} />
|
||||
<SettingsSecurity />
|
||||
<SettingsBilling plan={plan} />
|
||||
<SettingsNotifications />
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
||||
<SiteChrome>{children}</SiteChrome>
|
||||
<SiteChrome user={navUser}>{children}</SiteChrome>
|
||||
</DirectionProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user