diff --git a/app/api/create-client-user/route.ts b/app/api/create-client-user/route.ts index e54eb11..d2e6fa8 100644 --- a/app/api/create-client-user/route.ts +++ b/app/api/create-client-user/route.ts @@ -17,8 +17,8 @@ export async function POST(request: Request) { } try { - const { email, client_id: clientId } = await request.json(); - const invitation = await createPortalInvitation(actor, { email, clientId }); + const { email, client_id: clientId, locale } = await request.json(); + const invitation = await createPortalInvitation(actor, { email, clientId, locale }); return NextResponse.json({ success: true, invitation }, { status: 201 }); } catch (error) { diff --git a/app/api/portal-clients/[clientId]/locale/route.ts b/app/api/portal-clients/[clientId]/locale/route.ts new file mode 100644 index 0000000..7f550af --- /dev/null +++ b/app/api/portal-clients/[clientId]/locale/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { + PortalInvitationError, + setClientPortalLocale, +} from "@/server/auth/invitations"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; + +export async function PATCH(request: Request, { params }: { params: Promise<{ clientId: string }> }) { + const actor = await getSessionContextFromHeaders(new Headers(request.headers)); + + if (!actor) { + return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 }); + } + + try { + const body = await request.json(); + const result = setClientPortalLocale(actor, (await params).clientId, body.locale); + return NextResponse.json(result); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 }); + } + if (error instanceof PortalInvitationError) { + const status = error.code === "FORBIDDEN" ? 403 : error.code === "CLIENT_NOT_FOUND" ? 404 : 400; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + + console.error("Client portal locale update failed", error); + return NextResponse.json({ error: "Portal dili güncellenemedi." }, { status: 500 }); + } +} diff --git a/app/api/portal-invitations/route.ts b/app/api/portal-invitations/route.ts index 2c825cd..8838f82 100644 --- a/app/api/portal-invitations/route.ts +++ b/app/api/portal-invitations/route.ts @@ -17,6 +17,7 @@ export async function POST(request: Request) { const invitation = await createPortalInvitation(actor, { clientId: body.clientId, email: body.email, + locale: body.locale, expiresInHours: body.expiresInHours, }); diff --git a/app/invite/[token]/actions.ts b/app/invite/[token]/actions.ts index 1ced07b..6c0d0f3 100644 --- a/app/invite/[token]/actions.ts +++ b/app/invite/[token]/actions.ts @@ -1,10 +1,20 @@ "use server"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { acceptPortalInvitation, + getPortalInvitationPreview, PortalInvitationError, } from "@/server/auth/invitations"; +import { buildLocaleCookie } from "@/server/i18n/locale"; + +function inviteErrorCode(error: unknown): string { + if (!(error instanceof PortalInvitationError)) return "auth.messages.portalInviteFailed"; + if (error.code === "INVITATION_EXPIRED") return "auth.invite.expired"; + if (error.code === "INVITATION_NOT_PENDING") return "auth.invite.accepted"; + return "auth.messages.portalInviteFailed"; +} export async function acceptInvitation(formData: FormData) { const token = String(formData.get("token") ?? ""); @@ -14,14 +24,10 @@ export async function acceptInvitation(formData: FormData) { try { await acceptPortalInvitation({ token, displayName, password }); } catch (error) { - const message = - error instanceof PortalInvitationError - ? error.message - : "Portal hesabı oluşturulamadı."; - redirect(`/invite/${encodeURIComponent(token)}?error=true&message=${encodeURIComponent(message)}`); + redirect(`/invite/${encodeURIComponent(token)}?error=true&code=${inviteErrorCode(error)}`); } - redirect( - `/login?message=${encodeURIComponent("Portal hesabın oluşturuldu. Şimdi giriş yapabilirsin.")}`, - ); + const locale = getPortalInvitationPreview(token)?.locale ?? "tr"; + (await cookies()).set(buildLocaleCookie(locale)); + redirect("/login?code=auth.invite.success"); } diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index e93a3c5..ea90175 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -9,6 +9,7 @@ import { Input, Label } from "poyraz-ui/atoms"; import { Alert, AlertDescription } from "poyraz-ui/molecules"; import { getPortalInvitationPreview } from "@/server/auth/invitations"; import { getPublicBranding } from "@/server/branding/runtime"; +import { createTranslator } from "@/server/i18n/translator"; export const dynamic = "force-dynamic"; @@ -17,7 +18,7 @@ export default async function InvitationPage({ searchParams, }: { params: Promise<{ token: string }>; - searchParams: Promise<{ error?: string; message?: string }>; + searchParams: Promise<{ error?: string; code?: string; message?: string }>; }) { const { token } = await params; const invitation = getPortalInvitationPreview(token); @@ -27,28 +28,47 @@ export default async function InvitationPage({ notFound(); } + const t = createTranslator(invitation.locale, ["auth"]).t; const query = await searchParams; + const queryCode = query.code ?? null; + const queryMessage = queryCode ? t(queryCode) : query.message; + const resolvedQueryMessage = queryMessage === queryCode ? query.message : queryMessage; + const marketing = { + headline: t("auth.marketing.headline"), + description: t("auth.marketing.description", { app: branding.organizationName ?? branding.applicationName }), + openSource: t("auth.marketing.openSource"), + github: t("auth.marketing.github"), + via: t("auth.marketing.via"), + builtBy: t("auth.marketing.builtBy"), + highlights: [ + t("auth.highlights.clients"), + t("auth.highlights.calendar"), + t("auth.highlights.finance"), + t("auth.highlights.reports"), + ] as [string, string, string, string], + }; const isUsable = invitation.status === "pending"; const unavailableMessage = invitation.status === "expired" - ? "Bu davetin süresi dolmuş. Freelancer'dan yeni bir bağlantı istemelisin." + ? t("auth.invite.expired") : invitation.status === "accepted" - ? "Bu davet daha önce kullanılmış. Hesabınla giriş yapabilirsin." + ? t("auth.invite.accepted") : invitation.status === "revoked" - ? "Bu davet iptal edilmiş. Freelancer'dan yeni bir bağlantı istemelisin." + ? t("auth.invite.revoked") : null; return ( <> - {query.error && query.message ? : null} + {query.error && resolvedQueryMessage ? : null} @@ -57,28 +77,28 @@ export default async function InvitationPage({ - E-posta + {t("auth.invite.email")} - Ad soyad + {t("auth.invite.displayName")} - Şifre + {t("auth.invite.password")} - En az 8 karakter kullan. + {t("auth.invite.passwordHelp")} - - Portal hesabını oluştur + + {t("auth.invite.submit")} ) : ( @@ -90,7 +110,7 @@ export default async function InvitationPage({ secondaryAction={null} footer={ - Giriş sayfasına dön + {t("auth.invite.backToLogin")} } /> diff --git a/app/portal/layout.tsx b/app/portal/layout.tsx index 6e0567b..e97d801 100644 --- a/app/portal/layout.tsx +++ b/app/portal/layout.tsx @@ -1,5 +1,7 @@ import { PortalShell } from "@/components/layout/portal-shell"; import { getPublicBranding } from "@/server/branding/runtime"; +import { resolveRequestLocale } from "@/server/i18n/resolver"; +import { createTranslator, getClientI18nPayload } from "@/server/i18n/translator"; import { getUserPreferences } from "@/server/settings/preferences"; import { requirePortalBackend } from "@/server/web/portal"; @@ -9,6 +11,8 @@ export default async function PortalLayout({ children: React.ReactNode; }>) { const { context, actor, service } = await requirePortalBackend(); + const resolvedLocale = await resolveRequestLocale(); + const t = createTranslator(resolvedLocale.locale, ["navigation", "portal", "common"]).t; const { user, profile } = context; const branding = getPublicBranding(); const preferences = getUserPreferences(actor); @@ -43,6 +47,22 @@ export default async function PortalLayout({ avatarUrl: user.image || null, }} progress={progress} + i18n={getClientI18nPayload(resolvedLocale.locale, ["navigation", "portal", "common", "status", "validation"])} + labels={{ + skipToContent: t("navigation.shell.skipToContent"), + homeAriaLabel: t("navigation.shell.homeAriaLabel", { app: branding.organizationName ?? branding.applicationName }), + mobileMenuAriaLabel: t("navigation.shell.mobileMenuAriaLabel"), + mobileMenuTooltip: t("navigation.shell.mobileMenuTooltip"), + logoAlt: t("navigation.shell.logoAlt", { app: branding.organizationName ?? branding.applicationName }), + progressTitle: t("navigation.shell.progressTitle"), + progressValue: t("navigation.shell.progressValue", { progress }), + progressAriaLabel: t("navigation.shell.progressAriaLabel"), + accountMenuAriaLabel: t("navigation.shell.accountMenuAriaLabel", { name: displayName }), + signOut: t("navigation.account.signOut"), + signingOut: t("navigation.account.signingOut"), + signOutError: t("navigation.account.signOutError"), + settings: t("navigation.items.settings"), + }} > {children} diff --git a/components/layout/portal-shell.tsx b/components/layout/portal-shell.tsx index b73a1fc..f3e84e8 100644 --- a/components/layout/portal-shell.tsx +++ b/components/layout/portal-shell.tsx @@ -1,8 +1,11 @@ "use client"; +import type { ComponentProps } from "react"; import { AppShell, type AppShellBranding } from "@/components/layout/app-shell"; -import { portalSidebarData } from "@/config/portal-sidebar"; +import { I18nProvider } from "@/components/i18n/i18n-provider"; +import { localizePortalSidebarData } from "@/config/portal-sidebar"; import type { ColorMode } from "@/lib/color-mode"; +import { createTranslatorFromMessages } from "@/lib/i18n"; type PortalShellProps = { branding: AppShellBranding; @@ -15,20 +18,31 @@ type PortalShellProps = { avatarUrl: string | null; }; progress?: number; + i18n: { + locale: string; + messages: Record; + }; + labels?: ComponentProps["labels"]; }; -export function PortalShell({ branding, children, colorMode, user, progress }: PortalShellProps) { +export function PortalShell({ branding, children, colorMode, user, progress, i18n, labels }: PortalShellProps) { + const translator = createTranslatorFromMessages(i18n.locale, i18n.messages); + const navGroups = localizePortalSidebarData(translator.t); + return ( - - {children} - + + + {children} + + ); } diff --git a/config/portal-sidebar.ts b/config/portal-sidebar.ts index 5ac71b0..66e6ec7 100644 --- a/config/portal-sidebar.ts +++ b/config/portal-sidebar.ts @@ -7,6 +7,7 @@ import { export type PortalSidebarNavItem = { title: string; + titleKey?: string; href?: string; icon?: LucideIcon; items?: PortalSidebarNavItem[]; @@ -14,22 +15,36 @@ export type PortalSidebarNavItem = { export type PortalSidebarNavGroup = { title: string; + titleKey?: string; items: PortalSidebarNavItem[]; }; export const portalSidebarData: PortalSidebarNavGroup[] = [ { title: "GENEL BAKIŞ", + titleKey: "navigation.groups.overview", items: [ - { title: "Dashboard", href: "/portal", icon: Sparkles }, + { title: "Dashboard", titleKey: "navigation.items.dashboard", href: "/portal", icon: Sparkles }, ], }, { title: "SÜREÇLER", + titleKey: "navigation.groups.processes", items: [ - { title: "Projeleriniz", href: "/portal/projects", icon: FolderKanban }, - { title: "Yapılan Görevler", href: "/portal/tasks", icon: CheckSquare2 }, - { title: "Revizyon Talepleri", href: "/portal/revisions", icon: Sparkles }, + { title: "Projeleriniz", titleKey: "navigation.items.portalProjects", href: "/portal/projects", icon: FolderKanban }, + { title: "Yapılan Görevler", titleKey: "navigation.items.portalTasks", href: "/portal/tasks", icon: CheckSquare2 }, + { title: "Revizyon Talepleri", titleKey: "navigation.items.portalRevisions", href: "/portal/revisions", icon: Sparkles }, ], } ]; + +export function localizePortalSidebarData(t: (key: string) => string): PortalSidebarNavGroup[] { + return portalSidebarData.map((group) => ({ + ...group, + title: group.titleKey ? t(group.titleKey) : group.title, + items: group.items.map((item) => ({ + ...item, + title: item.titleKey ? t(item.titleKey) : item.title, + })), + })); +} diff --git a/server/auth/invitations.ts b/server/auth/invitations.ts index be569a9..5ad8b57 100644 --- a/server/auth/invitations.ts +++ b/server/auth/invitations.ts @@ -12,9 +12,11 @@ import { appProfiles, authAuditEvents, clients, + instanceLocales, portalInvitations, session, user, + userPreferences, } from "@/server/db/schema"; import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation"; @@ -23,6 +25,7 @@ const DEFAULT_INVITATION_TTL_HOURS = 72; const createInvitationSchema = z.object({ clientId: z.string().trim().min(1).max(128), email: z.email().transform(normalizeAuthEmail), + locale: z.string().trim().min(2).max(12).default("tr"), expiresInHours: z.number().int().min(1).max(168).default(DEFAULT_INVITATION_TTL_HOURS), }); @@ -56,12 +59,13 @@ export type PortalInvitationPreview = { email: string; expiresAt: Date; status: "pending" | "accepted" | "revoked" | "expired"; + locale: string; }; export async function createPortalInvitation( actor: SessionContext, input: z.input, -): Promise<{ id: number; invitationUrl: string; expiresAt: Date }> { +): Promise<{ id: number; invitationUrl: string; expiresAt: Date; locale: string }> { assertFreelancerActor(actor); const parsed = parseOrThrow(createInvitationSchema, input); @@ -83,6 +87,7 @@ export async function createPortalInvitation( if (!client) { throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı."); } + const locale = assertPortalReadyLocale(tx, parsed.locale); if (client.authUserId) { throw new PortalInvitationError( @@ -143,6 +148,7 @@ export async function createPortalInvitation( tokenHash, clientId: parsed.clientId, email: parsed.email, + locale, status: "pending", expiresAt, createdByUserId: actor.user.id, @@ -159,6 +165,7 @@ export async function createPortalInvitation( metadata: { invitationId: inserted.id, clientId: parsed.clientId, + locale, expiresAt: expiresAt.toISOString(), }, }) @@ -171,6 +178,7 @@ export async function createPortalInvitation( id: invitationId, invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`, expiresAt, + locale: parsed.locale, }; } @@ -186,6 +194,7 @@ export function getPortalInvitationPreview(rawToken: string): PortalInvitationPr email: portalInvitations.email, status: portalInvitations.status, expiresAt: portalInvitations.expiresAt, + locale: portalInvitations.locale, }) .from(portalInvitations) .where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken))) @@ -214,7 +223,7 @@ export function getPortalInvitationPreview(rawToken: string): PortalInvitationPr .values({ type: "invitation_expired", email: invitation.email, - metadata: { invitationId: invitation.id }, + metadata: { invitationId: invitation.id }, }) .run(); } @@ -356,7 +365,7 @@ export async function acceptPortalInvitation(input: { const linkedClient = tx .update(clients) - .set({ authUserId, updatedAt: now }) + .set({ authUserId, portalLocale: invitation.locale, updatedAt: now }) .where( and( eq(clients.id, invitation.clientId), @@ -372,6 +381,20 @@ export async function acceptPortalInvitation(input: { ); } + tx.insert(userPreferences) + .values({ + ownerUserId: authUserId, + language: invitation.locale, + }) + .onConflictDoUpdate({ + target: userPreferences.ownerUserId, + set: { + language: invitation.locale, + updatedAt: now.toISOString(), + }, + }) + .run(); + const accepted = tx .update(portalInvitations) .set({ status: "accepted", acceptedAt: now }) @@ -395,7 +418,7 @@ export async function acceptPortalInvitation(input: { type: "invitation_accepted", authUserId, email: invitation.email, - metadata: { invitationId: invitation.id, clientId: invitation.clientId }, + metadata: { invitationId: invitation.id, clientId: invitation.clientId, locale: invitation.locale }, }) .run(); @@ -497,6 +520,60 @@ export function setClientPortalAccess( }); } +export function setClientPortalLocale( + actor: SessionContext, + clientId: string, + localeInput: string, +): { locale: string } { + assertFreelancerActor(actor); + const { db } = getSqliteConnection(); + + return db.transaction((tx) => { + const ownedClient = tx + .select({ id: clients.id, authUserId: clients.authUserId }) + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.ownerUserId, actor.user.id))) + .get(); + + if (!ownedClient) { + throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı."); + } + + const locale = assertPortalReadyLocale(tx, localeInput); + tx.update(clients) + .set({ portalLocale: locale, updatedAt: new Date() }) + .where(eq(clients.id, clientId)) + .run(); + + if (ownedClient.authUserId) { + tx.insert(userPreferences) + .values({ + ownerUserId: ownedClient.authUserId, + language: locale, + }) + .onConflictDoUpdate({ + target: userPreferences.ownerUserId, + set: { + language: locale, + updatedAt: new Date().toISOString(), + }, + }) + .run(); + tx.delete(session).where(eq(session.userId, ownedClient.authUserId)).run(); + } + + tx.insert(authAuditEvents) + .values({ + type: "client_locale_updated", + authUserId: actor.user.id, + metadata: { clientId, locale, targetAuthUserId: ownedClient.authUserId }, + }) + .run(); + + return { locale }; + }); +} + export function hashInvitationToken(rawToken: string): string { return createHash("sha256").update(rawToken, "utf8").digest("hex"); } @@ -534,3 +611,21 @@ async function recordInvitationFailure(email: string | null, reason: string): Pr }) .run(); } + +function assertPortalReadyLocale( + tx: Pick["db"], "select">, + localeInput: string, +): string { + const locale = localeInput.trim(); + const row = tx + .select({ code: instanceLocales.code, status: instanceLocales.status }) + .from(instanceLocales) + .where(eq(instanceLocales.code, locale)) + .get(); + + if (!row || row.status !== "active") { + throw new PortalInvitationError("INVALID_INPUT", "Portal dili aktif değil."); + } + + return row.code; +} diff --git a/server/auth/types.ts b/server/auth/types.ts index 8274201..b1442e9 100644 --- a/server/auth/types.ts +++ b/server/auth/types.ts @@ -22,4 +22,5 @@ export type AuthAuditEventType = | "invitation_accepted" | "invitation_accept_failed" | "client_access_disabled" - | "client_access_enabled"; + | "client_access_enabled" + | "client_locale_updated";
En az 8 karakter kullan.
{t("auth.invite.passwordHelp")}