feat: persist portal invitation locales

This commit is contained in:
poyrazavsever
2026-07-19 03:07:35 +03:00
parent c1c473469a
commit 0158f2f25a
10 changed files with 249 additions and 46 deletions
+2 -2
View File
@@ -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) {
@@ -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 });
}
}
+1
View File
@@ -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,
});
+14 -8
View File
@@ -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");
}
+34 -14
View File
@@ -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 ? <ErrorToaster message={query.message} /> : null}
{query.error && resolvedQueryMessage ? <ErrorToaster message={resolvedQueryMessage} /> : null}
<AuthPageShell
branding={{
applicationName: branding.organizationName ?? branding.applicationName,
lightLogoUrl: branding.lightLogoUrl,
darkLogoUrl: branding.darkLogoUrl,
}}
title="Müşteri portalına katıl"
description="Davet edilen hesabın için adını ve şifreni belirle."
title={t("auth.invite.title")}
description={t("auth.invite.description")}
marketing={marketing}
form={
isUsable ? (
<form className="space-y-6">
@@ -57,28 +77,28 @@ export default async function InvitationPage({
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
E-posta
{t("auth.invite.email")}
</Label>
<Input id="email" type="email" value={invitation.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="displayName" className="flex items-center gap-2">
<UserRound className="h-4 w-4 text-muted-foreground" />
Ad soyad
{t("auth.invite.displayName")}
</Label>
<Input id="displayName" name="displayName" required maxLength={120} />
</div>
<div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2">
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
Şifre
{t("auth.invite.password")}
</Label>
<Input id="password" name="password" type="password" required minLength={8} maxLength={128} />
<p className="text-xs text-muted-foreground">En az 8 karakter kullan.</p>
<p className="text-xs text-muted-foreground">{t("auth.invite.passwordHelp")}</p>
</div>
</div>
<SubmitButton size="lg" formAction={acceptInvitation} className="w-full" pendingText="Hesap oluşturuluyor...">
Portal hesabını oluştur
<SubmitButton size="lg" formAction={acceptInvitation} className="w-full" pendingText={t("auth.invite.pending")}>
{t("auth.invite.submit")}
</SubmitButton>
</form>
) : (
@@ -90,7 +110,7 @@ export default async function InvitationPage({
secondaryAction={null}
footer={
<Link href="/login" className="text-sm font-medium text-primary hover:text-primary-hover">
Giriş sayfasına dön
{t("auth.invite.backToLogin")}
</Link>
}
/>
+20
View File
@@ -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}
</PortalShell>