feat(i18n): localize portal account settings

This commit is contained in:
poyrazavsever
2026-07-21 13:37:23 +03:00
parent 48f9d7bc87
commit 86a9c79511
11 changed files with 397 additions and 40 deletions
+55
View File
@@ -0,0 +1,55 @@
"use server";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { auth } from "@/server/auth/auth";
import { getSqliteConnection } from "@/server/db/client";
import { appProfiles } from "@/server/db/schema";
import { getFileService } from "@/server/files/runtime";
import { requirePortalBackend } from "@/server/web/portal";
import { cleanText } from "@/server/web/form-data";
export async function updatePortalProfileAction(formData: FormData) {
try {
const { actor, context } = await requirePortalBackend();
const firstName = cleanText(formData.get("firstName"));
const lastName = cleanText(formData.get("lastName"));
if (!firstName || firstName.length > 80) {
return { errorKey: "settings.profile.errors.firstName" };
}
if (!lastName || lastName.length > 120) {
return { errorKey: "settings.profile.errors.lastName" };
}
const displayName = `${firstName} ${lastName}`;
const avatar = formData.get("avatar");
const avatarChanged = avatar instanceof File && avatar.size > 0;
if (avatarChanged) {
getFileService().upload(actor, {
kind: "avatar",
originalName: avatar.name,
claimedMimeType: avatar.type,
bytes: new Uint8Array(await avatar.arrayBuffer()),
});
}
await auth.api.updateUser({
headers: await headers(),
body: { name: displayName },
});
getSqliteConnection().db
.update(appProfiles)
.set({ displayName, updatedAt: new Date() })
.where(eq(appProfiles.authUserId, context.user.id))
.run();
revalidatePath("/", "layout");
revalidatePath("/portal", "layout");
revalidatePath("/portal/settings/profile");
return { success: true, avatarChanged };
} catch (error) {
console.error("Portal profile update failed", error);
return { errorKey: "settings.profile.errors.updateFailed" };
}
}
+10 -15
View File
@@ -1,23 +1,18 @@
import { Card, CardContent } from "poyraz-ui/atoms";
import { createTranslator } from "@/server/i18n/translator";
import { resolvePortalLocale } from "@/server/i18n/resolver";
import { requirePortalBackend } from "@/server/web/portal";
import { PortalProfileForm } from "./portal-profile-form";
export default async function PortalProfileSettingsPage() {
const { context } = await requirePortalBackend();
const locale = await resolvePortalLocale(context);
const t = createTranslator(locale.locale, ["settings"]).t;
const [firstName = "", ...lastNameParts] = context.profile.displayName.trim().split(/\s+/);
return (
<Card>
<CardContent className="space-y-2 p-6 sm:p-8">
<h2 className="text-xl font-semibold text-foreground">
{t("settings.portal.profile.title")}
</h2>
<p className="text-sm text-muted-foreground">
{t("settings.portal.profile.description")}
</p>
</CardContent>
</Card>
<PortalProfileForm
initial={{
firstName,
lastName: lastNameParts.join(" "),
email: context.user.email,
avatarUrl: context.user.image ?? "",
}}
/>
);
}
@@ -0,0 +1,126 @@
"use client";
import Image from "next/image";
import { useState, useTransition } from "react";
import { Save, User } from "lucide-react";
import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import { toast } from "poyraz-ui/molecules";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { updatePortalProfileAction } from "./actions";
export function PortalProfileForm({
initial,
}: {
initial: {
avatarUrl: string;
email: string;
firstName: string;
lastName: string;
};
}) {
const t = useTranslations();
const [pending, startTransition] = useTransition();
const [firstName, setFirstName] = useState(initial.firstName);
const [lastName, setLastName] = useState(initial.lastName);
function submit(formData: FormData) {
startTransition(async () => {
const result = await updatePortalProfileAction(formData);
if (result.errorKey) {
toast.error(t(result.errorKey));
return;
}
toast.success(t("settings.profile.messages.saved"));
if (result.avatarChanged) {
window.location.reload();
}
});
}
return (
<Card>
<CardContent className="space-y-8 p-6 sm:p-8">
<div className="space-y-1.5">
<h2 className="text-xl font-semibold text-foreground">
{t("settings.portal.profile.title")}
</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
{t("settings.portal.profile.description")}
</p>
</div>
<form action={submit} className="max-w-2xl space-y-7">
<section className="flex flex-col gap-5 sm:flex-row sm:items-center">
{initial.avatarUrl ? (
<Image
src={initial.avatarUrl}
alt={t("settings.profile.avatarAlt")}
width={80}
height={80}
unoptimized
className="h-20 w-20 rounded-full border border-border object-cover"
/>
) : (
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-full border border-border bg-muted/50">
<User className="h-9 w-9 text-muted-foreground" aria-hidden="true" />
</div>
)}
<div className="flex-1 space-y-2">
<Label htmlFor="avatar">{t("settings.profile.fields.avatar")}</Label>
<Input
id="avatar"
name="avatar"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="cursor-pointer"
/>
<p className="text-xs text-muted-foreground">
{t("settings.profile.help.avatar")}
</p>
</div>
</section>
<section className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">{t("settings.profile.fields.firstName")}</Label>
<Input
id="firstName"
name="firstName"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
maxLength={80}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t("settings.profile.fields.lastName")}</Label>
<Input
id="lastName"
name="lastName"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
maxLength={120}
required
/>
</div>
</section>
<div className="space-y-2">
<Label htmlFor="portal-profile-email">{t("settings.profile.fields.email")}</Label>
<Input id="portal-profile-email" value={initial.email} disabled readOnly />
<p className="text-xs text-muted-foreground">
{t("settings.profile.help.email")}
</p>
</div>
<div className="flex justify-end border-t border-border pt-6">
<Button type="submit" variant="default" effect="shine" loading={pending} className="gap-2">
<Save className="h-4 w-4" aria-hidden="true" />
{t("settings.profile.actions.save")}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { auth } from "@/server/auth/auth";
import { requirePortalBackend } from "@/server/web/portal";
import { cleanText } from "@/server/web/form-data";
export async function changePortalPasswordAction(formData: FormData) {
const currentPassword = cleanText(formData.get("currentPassword")) ?? "";
const newPassword = cleanText(formData.get("newPassword")) ?? "";
const confirmPassword = cleanText(formData.get("confirmPassword")) ?? "";
if (!currentPassword) {
return { errorKey: "settings.security.errors.currentRequired" };
}
if (newPassword.length < 8 || newPassword.length > 128) {
return { errorKey: "settings.security.errors.newLength" };
}
if (newPassword !== confirmPassword) {
return { errorKey: "settings.security.errors.confirmMismatch" };
}
if (newPassword === currentPassword) {
return { errorKey: "settings.security.errors.samePassword" };
}
try {
await requirePortalBackend();
await auth.api.changePassword({
headers: await headers(),
body: {
currentPassword,
newPassword,
revokeOtherSessions: true,
},
});
revalidatePath("/portal/settings/security");
return { success: true };
} catch (error) {
console.error("Portal password change failed", error);
return { errorKey: "settings.security.errors.changeFailed" };
}
}
+3 -19
View File
@@ -1,23 +1,7 @@
import { Card, CardContent } from "poyraz-ui/atoms";
import { createTranslator } from "@/server/i18n/translator";
import { resolvePortalLocale } from "@/server/i18n/resolver";
import { requirePortalBackend } from "@/server/web/portal";
import { PortalSecurityForm } from "./portal-security-form";
export default async function PortalSecuritySettingsPage() {
const { context } = await requirePortalBackend();
const locale = await resolvePortalLocale(context);
const t = createTranslator(locale.locale, ["settings"]).t;
return (
<Card>
<CardContent className="space-y-2 p-6 sm:p-8">
<h2 className="text-xl font-semibold text-foreground">
{t("settings.portal.security.title")}
</h2>
<p className="text-sm text-muted-foreground">
{t("settings.portal.security.description")}
</p>
</CardContent>
</Card>
);
await requirePortalBackend();
return <PortalSecurityForm />;
}
@@ -0,0 +1,107 @@
"use client";
import { useRef, useTransition } from "react";
import { KeyRound, Save, ShieldCheck } from "lucide-react";
import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import { Alert, AlertDescription, AlertTitle, toast } from "poyraz-ui/molecules";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { changePortalPasswordAction } from "./actions";
export function PortalSecurityForm() {
const t = useTranslations();
const formRef = useRef<HTMLFormElement>(null);
const [pending, startTransition] = useTransition();
function submit(formData: FormData) {
startTransition(async () => {
const result = await changePortalPasswordAction(formData);
if (result.errorKey) {
toast.error(t(result.errorKey));
return;
}
formRef.current?.reset();
toast.success(t("settings.security.messages.saved"));
});
}
return (
<Card>
<CardContent className="space-y-8 p-6 sm:p-8">
<div className="space-y-1.5">
<h2 className="text-xl font-semibold text-foreground">
{t("settings.portal.security.title")}
</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
{t("settings.portal.security.description")}
</p>
</div>
<Alert>
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
<AlertTitle>{t("settings.security.sessions.title")}</AlertTitle>
<AlertDescription>
{t("settings.security.sessions.description")}
</AlertDescription>
</Alert>
<form ref={formRef} action={submit} className="max-w-2xl space-y-6">
<div className="space-y-2">
<Label htmlFor="currentPassword">
{t("settings.security.fields.currentPassword")}
</Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
autoComplete="current-password"
required
/>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="newPassword">
{t("settings.security.fields.newPassword")}
</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
minLength={8}
maxLength={128}
autoComplete="new-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
{t("settings.security.fields.confirmPassword")}
</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
minLength={8}
maxLength={128}
autoComplete="new-password"
required
/>
</div>
</div>
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<KeyRound className="h-4 w-4 shrink-0" aria-hidden="true" />
{t("settings.security.help.password")}
</p>
<div className="flex justify-end border-t border-border pt-6">
<Button type="submit" variant="default" effect="shine" loading={pending} className="gap-2">
<Save className="h-4 w-4" aria-hidden="true" />
{t("settings.security.actions.save")}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}