From 86a9c7951110d780ac83825f6e6fb91993488bb4 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Tue, 21 Jul 2026 13:37:23 +0300 Subject: [PATCH] feat(i18n): localize portal account settings --- app/invite/[token]/actions.ts | 6 +- app/portal/settings/profile/actions.ts | 55 ++++++++ app/portal/settings/profile/page.tsx | 25 ++-- .../settings/profile/portal-profile-form.tsx | 126 ++++++++++++++++++ app/portal/settings/security/actions.ts | 43 ++++++ app/portal/settings/security/page.tsx | 22 +-- .../security/portal-security-form.tsx | 107 +++++++++++++++ server/auth/invitations.ts | 42 +++++- .../0012_portal_revision_locale.sql | 1 + server/db/migrations/meta/_journal.json | 9 +- server/db/schema/domain.ts | 1 + 11 files changed, 397 insertions(+), 40 deletions(-) create mode 100644 app/portal/settings/profile/actions.ts create mode 100644 app/portal/settings/profile/portal-profile-form.tsx create mode 100644 app/portal/settings/security/actions.ts create mode 100644 app/portal/settings/security/portal-security-form.tsx create mode 100644 server/db/migrations/0012_portal_revision_locale.sql diff --git a/app/invite/[token]/actions.ts b/app/invite/[token]/actions.ts index 8789647..7b37ba1 100644 --- a/app/invite/[token]/actions.ts +++ b/app/invite/[token]/actions.ts @@ -8,8 +8,12 @@ import { function inviteErrorCode(error: unknown): string { if (!(error instanceof PortalInvitationError)) return "auth.messages.portalInviteFailed"; + if (error.code === "INVALID_INPUT") return "auth.invite.invalidInput"; if (error.code === "INVITATION_EXPIRED") return "auth.invite.expired"; - if (error.code === "INVITATION_NOT_PENDING") return "auth.invite.accepted"; + if (error.code === "INVITATION_NOT_FOUND") return "auth.invite.notFound"; + if (error.code === "INVITATION_NOT_PENDING") return "auth.invite.unavailable"; + if (error.code === "EMAIL_ALREADY_REGISTERED") return "auth.invite.emailRegistered"; + if (error.code === "CLIENT_ALREADY_LINKED") return "auth.invite.clientLinked"; return "auth.messages.portalInviteFailed"; } diff --git a/app/portal/settings/profile/actions.ts b/app/portal/settings/profile/actions.ts new file mode 100644 index 0000000..c2989c1 --- /dev/null +++ b/app/portal/settings/profile/actions.ts @@ -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" }; + } +} diff --git a/app/portal/settings/profile/page.tsx b/app/portal/settings/profile/page.tsx index d861c9c..41e1dbc 100644 --- a/app/portal/settings/profile/page.tsx +++ b/app/portal/settings/profile/page.tsx @@ -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 ( - - -

- {t("settings.portal.profile.title")} -

-

- {t("settings.portal.profile.description")} -

-
-
+ ); } diff --git a/app/portal/settings/profile/portal-profile-form.tsx b/app/portal/settings/profile/portal-profile-form.tsx new file mode 100644 index 0000000..2ce07d2 --- /dev/null +++ b/app/portal/settings/profile/portal-profile-form.tsx @@ -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 ( + + +
+

+ {t("settings.portal.profile.title")} +

+

+ {t("settings.portal.profile.description")} +

+
+ +
+
+ {initial.avatarUrl ? ( + {t("settings.profile.avatarAlt")} + ) : ( +
+
+ )} +
+ + +

+ {t("settings.profile.help.avatar")} +

+
+
+ +
+
+ + setFirstName(event.target.value)} + maxLength={80} + required + /> +
+
+ + setLastName(event.target.value)} + maxLength={120} + required + /> +
+
+ +
+ + +

+ {t("settings.profile.help.email")} +

+
+ +
+ +
+
+
+
+ ); +} diff --git a/app/portal/settings/security/actions.ts b/app/portal/settings/security/actions.ts new file mode 100644 index 0000000..f2c155f --- /dev/null +++ b/app/portal/settings/security/actions.ts @@ -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" }; + } +} diff --git a/app/portal/settings/security/page.tsx b/app/portal/settings/security/page.tsx index 61ba3d8..090919b 100644 --- a/app/portal/settings/security/page.tsx +++ b/app/portal/settings/security/page.tsx @@ -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 ( - - -

- {t("settings.portal.security.title")} -

-

- {t("settings.portal.security.description")} -

-
-
- ); + await requirePortalBackend(); + return ; } diff --git a/app/portal/settings/security/portal-security-form.tsx b/app/portal/settings/security/portal-security-form.tsx new file mode 100644 index 0000000..04da4e1 --- /dev/null +++ b/app/portal/settings/security/portal-security-form.tsx @@ -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(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 ( + + +
+

+ {t("settings.portal.security.title")} +

+

+ {t("settings.portal.security.description")} +

+
+ + + + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +

+

+ +
+ +
+
+
+
+ ); +} diff --git a/server/auth/invitations.ts b/server/auth/invitations.ts index 1d3884d..7350e2f 100644 --- a/server/auth/invitations.ts +++ b/server/auth/invitations.ts @@ -12,6 +12,7 @@ import { appProfiles, authAuditEvents, clients, + instanceI18nSettings, instanceLocales, portalInvitations, session, @@ -298,6 +299,7 @@ export async function acceptPortalInvitation(input: { .run(); throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş."); } + const preferenceLocale = resolveActivePortalLocale(tx, invitation.locale); const [existingUser] = tx .select({ id: user.id }) @@ -365,7 +367,7 @@ export async function acceptPortalInvitation(input: { const linkedClient = tx .update(clients) - .set({ authUserId, portalLocale: invitation.locale, updatedAt: now }) + .set({ authUserId, portalLocale: preferenceLocale, updatedAt: now }) .where( and( eq(clients.id, invitation.clientId), @@ -384,12 +386,12 @@ export async function acceptPortalInvitation(input: { tx.insert(userPreferences) .values({ ownerUserId: authUserId, - language: invitation.locale, + language: preferenceLocale, }) .onConflictDoUpdate({ target: userPreferences.ownerUserId, set: { - language: invitation.locale, + language: preferenceLocale, updatedAt: now.toISOString(), }, }) @@ -418,7 +420,12 @@ export async function acceptPortalInvitation(input: { type: "invitation_accepted", authUserId, email: invitation.email, - metadata: { invitationId: invitation.id, clientId: invitation.clientId, locale: invitation.locale }, + metadata: { + invitationId: invitation.id, + clientId: invitation.clientId, + locale: invitation.locale, + preferenceLocale, + }, }) .run(); @@ -617,3 +624,30 @@ function assertPortalReadyLocale( return row.code; } + +function resolveActivePortalLocale( + tx: Pick["db"], "select">, + localeInput: string, +): string { + const defaultLocale = tx + .select({ defaultLocale: instanceI18nSettings.defaultLocale }) + .from(instanceI18nSettings) + .where(eq(instanceI18nSettings.key, "default")) + .get()?.defaultLocale ?? "tr"; + const candidates = [localeInput, defaultLocale, "tr", "en"]; + + for (const candidate of candidates) { + const locale = candidate.trim(); + const row = tx + .select({ code: instanceLocales.code, status: instanceLocales.status }) + .from(instanceLocales) + .where(eq(instanceLocales.code, locale)) + .get(); + + if (row?.status === "active") { + return row.code; + } + } + + return "tr"; +} diff --git a/server/db/migrations/0012_portal_revision_locale.sql b/server/db/migrations/0012_portal_revision_locale.sql new file mode 100644 index 0000000..b9012fd --- /dev/null +++ b/server/db/migrations/0012_portal_revision_locale.sql @@ -0,0 +1 @@ +ALTER TABLE `project_revisions` ADD `source_locale` text; diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 4adf704..361b8e4 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1784626279393, "tag": "0011_tricky_mordo", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1784627370000, + "tag": "0012_portal_revision_locale", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/db/schema/domain.ts b/server/db/schema/domain.ts index 289e1ec..aa001ee 100644 --- a/server/db/schema/domain.ts +++ b/server/db/schema/domain.ts @@ -326,6 +326,7 @@ export const projectRevisions = sqliteTable( .notNull() .references(() => user.id, { onDelete: "cascade" }), description: text("description").notNull(), + sourceLocale: text("source_locale"), status: text("status").$type().default("pending").notNull(), createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(), updatedAt: integer("updated_at", { mode: "timestamp_ms" })