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 { try {
const { email, client_id: clientId } = await request.json(); const { email, client_id: clientId, locale } = await request.json();
const invitation = await createPortalInvitation(actor, { email, clientId }); const invitation = await createPortalInvitation(actor, { email, clientId, locale });
return NextResponse.json({ success: true, invitation }, { status: 201 }); return NextResponse.json({ success: true, invitation }, { status: 201 });
} catch (error) { } 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, { const invitation = await createPortalInvitation(actor, {
clientId: body.clientId, clientId: body.clientId,
email: body.email, email: body.email,
locale: body.locale,
expiresInHours: body.expiresInHours, expiresInHours: body.expiresInHours,
}); });
+14 -8
View File
@@ -1,10 +1,20 @@
"use server"; "use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
acceptPortalInvitation, acceptPortalInvitation,
getPortalInvitationPreview,
PortalInvitationError, PortalInvitationError,
} from "@/server/auth/invitations"; } 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) { export async function acceptInvitation(formData: FormData) {
const token = String(formData.get("token") ?? ""); const token = String(formData.get("token") ?? "");
@@ -14,14 +24,10 @@ export async function acceptInvitation(formData: FormData) {
try { try {
await acceptPortalInvitation({ token, displayName, password }); await acceptPortalInvitation({ token, displayName, password });
} catch (error) { } catch (error) {
const message = redirect(`/invite/${encodeURIComponent(token)}?error=true&code=${inviteErrorCode(error)}`);
error instanceof PortalInvitationError
? error.message
: "Portal hesabı oluşturulamadı.";
redirect(`/invite/${encodeURIComponent(token)}?error=true&message=${encodeURIComponent(message)}`);
} }
redirect( const locale = getPortalInvitationPreview(token)?.locale ?? "tr";
`/login?message=${encodeURIComponent("Portal hesabın oluşturuldu. Şimdi giriş yapabilirsin.")}`, (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 { Alert, AlertDescription } from "poyraz-ui/molecules";
import { getPortalInvitationPreview } from "@/server/auth/invitations"; import { getPortalInvitationPreview } from "@/server/auth/invitations";
import { getPublicBranding } from "@/server/branding/runtime"; import { getPublicBranding } from "@/server/branding/runtime";
import { createTranslator } from "@/server/i18n/translator";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,7 +18,7 @@ export default async function InvitationPage({
searchParams, searchParams,
}: { }: {
params: Promise<{ token: string }>; params: Promise<{ token: string }>;
searchParams: Promise<{ error?: string; message?: string }>; searchParams: Promise<{ error?: string; code?: string; message?: string }>;
}) { }) {
const { token } = await params; const { token } = await params;
const invitation = getPortalInvitationPreview(token); const invitation = getPortalInvitationPreview(token);
@@ -27,28 +28,47 @@ export default async function InvitationPage({
notFound(); notFound();
} }
const t = createTranslator(invitation.locale, ["auth"]).t;
const query = await searchParams; 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 isUsable = invitation.status === "pending";
const unavailableMessage = const unavailableMessage =
invitation.status === "expired" invitation.status === "expired"
? "Bu davetin süresi dolmuş. Freelancer'dan yeni bir bağlantı istemelisin." ? t("auth.invite.expired")
: invitation.status === "accepted" : invitation.status === "accepted"
? "Bu davet daha önce kullanılmış. Hesabınla giriş yapabilirsin." ? t("auth.invite.accepted")
: invitation.status === "revoked" : invitation.status === "revoked"
? "Bu davet iptal edilmiş. Freelancer'dan yeni bir bağlantı istemelisin." ? t("auth.invite.revoked")
: null; : null;
return ( return (
<> <>
{query.error && query.message ? <ErrorToaster message={query.message} /> : null} {query.error && resolvedQueryMessage ? <ErrorToaster message={resolvedQueryMessage} /> : null}
<AuthPageShell <AuthPageShell
branding={{ branding={{
applicationName: branding.organizationName ?? branding.applicationName, applicationName: branding.organizationName ?? branding.applicationName,
lightLogoUrl: branding.lightLogoUrl, lightLogoUrl: branding.lightLogoUrl,
darkLogoUrl: branding.darkLogoUrl, darkLogoUrl: branding.darkLogoUrl,
}} }}
title="Müşteri portalına katıl" title={t("auth.invite.title")}
description="Davet edilen hesabın için adını ve şifreni belirle." description={t("auth.invite.description")}
marketing={marketing}
form={ form={
isUsable ? ( isUsable ? (
<form className="space-y-6"> <form className="space-y-6">
@@ -57,28 +77,28 @@ export default async function InvitationPage({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2"> <Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" /> <Mail className="h-4 w-4 text-muted-foreground" />
E-posta {t("auth.invite.email")}
</Label> </Label>
<Input id="email" type="email" value={invitation.email} disabled /> <Input id="email" type="email" value={invitation.email} disabled />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="displayName" className="flex items-center gap-2"> <Label htmlFor="displayName" className="flex items-center gap-2">
<UserRound className="h-4 w-4 text-muted-foreground" /> <UserRound className="h-4 w-4 text-muted-foreground" />
Ad soyad {t("auth.invite.displayName")}
</Label> </Label>
<Input id="displayName" name="displayName" required maxLength={120} /> <Input id="displayName" name="displayName" required maxLength={120} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2"> <Label htmlFor="password" className="flex items-center gap-2">
<LockKeyhole className="h-4 w-4 text-muted-foreground" /> <LockKeyhole className="h-4 w-4 text-muted-foreground" />
Şifre {t("auth.invite.password")}
</Label> </Label>
<Input id="password" name="password" type="password" required minLength={8} maxLength={128} /> <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>
</div> </div>
<SubmitButton size="lg" formAction={acceptInvitation} className="w-full" pendingText="Hesap oluşturuluyor..."> <SubmitButton size="lg" formAction={acceptInvitation} className="w-full" pendingText={t("auth.invite.pending")}>
Portal hesabını oluştur {t("auth.invite.submit")}
</SubmitButton> </SubmitButton>
</form> </form>
) : ( ) : (
@@ -90,7 +110,7 @@ export default async function InvitationPage({
secondaryAction={null} secondaryAction={null}
footer={ footer={
<Link href="/login" className="text-sm font-medium text-primary hover:text-primary-hover"> <Link href="/login" className="text-sm font-medium text-primary hover:text-primary-hover">
Giriş sayfasına dön {t("auth.invite.backToLogin")}
</Link> </Link>
} }
/> />
+20
View File
@@ -1,5 +1,7 @@
import { PortalShell } from "@/components/layout/portal-shell"; import { PortalShell } from "@/components/layout/portal-shell";
import { getPublicBranding } from "@/server/branding/runtime"; 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 { getUserPreferences } from "@/server/settings/preferences";
import { requirePortalBackend } from "@/server/web/portal"; import { requirePortalBackend } from "@/server/web/portal";
@@ -9,6 +11,8 @@ export default async function PortalLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const { context, actor, service } = await requirePortalBackend(); const { context, actor, service } = await requirePortalBackend();
const resolvedLocale = await resolveRequestLocale();
const t = createTranslator(resolvedLocale.locale, ["navigation", "portal", "common"]).t;
const { user, profile } = context; const { user, profile } = context;
const branding = getPublicBranding(); const branding = getPublicBranding();
const preferences = getUserPreferences(actor); const preferences = getUserPreferences(actor);
@@ -43,6 +47,22 @@ export default async function PortalLayout({
avatarUrl: user.image || null, avatarUrl: user.image || null,
}} }}
progress={progress} 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} {children}
</PortalShell> </PortalShell>
+27 -13
View File
@@ -1,8 +1,11 @@
"use client"; "use client";
import type { ComponentProps } from "react";
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell"; 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 type { ColorMode } from "@/lib/color-mode";
import { createTranslatorFromMessages } from "@/lib/i18n";
type PortalShellProps = { type PortalShellProps = {
branding: AppShellBranding; branding: AppShellBranding;
@@ -15,20 +18,31 @@ type PortalShellProps = {
avatarUrl: string | null; avatarUrl: string | null;
}; };
progress?: number; progress?: number;
i18n: {
locale: string;
messages: Record<string, string>;
};
labels?: ComponentProps<typeof AppShell>["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 ( return (
<AppShell <I18nProvider locale={i18n.locale} messages={i18n.messages}>
branding={branding} <AppShell
colorMode={colorMode} branding={branding}
homeHref="/portal" colorMode={colorMode}
navGroups={portalSidebarData} homeHref="/portal"
settingsHref="/portal/settings" navGroups={navGroups}
user={user} settingsHref="/portal/settings"
progress={progress} user={user}
> progress={progress}
{children} labels={labels}
</AppShell> >
{children}
</AppShell>
</I18nProvider>
); );
} }
+19 -4
View File
@@ -7,6 +7,7 @@ import {
export type PortalSidebarNavItem = { export type PortalSidebarNavItem = {
title: string; title: string;
titleKey?: string;
href?: string; href?: string;
icon?: LucideIcon; icon?: LucideIcon;
items?: PortalSidebarNavItem[]; items?: PortalSidebarNavItem[];
@@ -14,22 +15,36 @@ export type PortalSidebarNavItem = {
export type PortalSidebarNavGroup = { export type PortalSidebarNavGroup = {
title: string; title: string;
titleKey?: string;
items: PortalSidebarNavItem[]; items: PortalSidebarNavItem[];
}; };
export const portalSidebarData: PortalSidebarNavGroup[] = [ export const portalSidebarData: PortalSidebarNavGroup[] = [
{ {
title: "GENEL BAKIŞ", title: "GENEL BAKIŞ",
titleKey: "navigation.groups.overview",
items: [ items: [
{ title: "Dashboard", href: "/portal", icon: Sparkles }, { title: "Dashboard", titleKey: "navigation.items.dashboard", href: "/portal", icon: Sparkles },
], ],
}, },
{ {
title: "SÜREÇLER", title: "SÜREÇLER",
titleKey: "navigation.groups.processes",
items: [ items: [
{ title: "Projeleriniz", href: "/portal/projects", icon: FolderKanban }, { title: "Projeleriniz", titleKey: "navigation.items.portalProjects", href: "/portal/projects", icon: FolderKanban },
{ title: "Yapılan Görevler", href: "/portal/tasks", icon: CheckSquare2 }, { title: "Yapılan Görevler", titleKey: "navigation.items.portalTasks", href: "/portal/tasks", icon: CheckSquare2 },
{ title: "Revizyon Talepleri", href: "/portal/revisions", icon: Sparkles }, { 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,
})),
}));
}
+99 -4
View File
@@ -12,9 +12,11 @@ import {
appProfiles, appProfiles,
authAuditEvents, authAuditEvents,
clients, clients,
instanceLocales,
portalInvitations, portalInvitations,
session, session,
user, user,
userPreferences,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation"; import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
@@ -23,6 +25,7 @@ const DEFAULT_INVITATION_TTL_HOURS = 72;
const createInvitationSchema = z.object({ const createInvitationSchema = z.object({
clientId: z.string().trim().min(1).max(128), clientId: z.string().trim().min(1).max(128),
email: z.email().transform(normalizeAuthEmail), 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), expiresInHours: z.number().int().min(1).max(168).default(DEFAULT_INVITATION_TTL_HOURS),
}); });
@@ -56,12 +59,13 @@ export type PortalInvitationPreview = {
email: string; email: string;
expiresAt: Date; expiresAt: Date;
status: "pending" | "accepted" | "revoked" | "expired"; status: "pending" | "accepted" | "revoked" | "expired";
locale: string;
}; };
export async function createPortalInvitation( export async function createPortalInvitation(
actor: SessionContext, actor: SessionContext,
input: z.input<typeof createInvitationSchema>, input: z.input<typeof createInvitationSchema>,
): Promise<{ id: number; invitationUrl: string; expiresAt: Date }> { ): Promise<{ id: number; invitationUrl: string; expiresAt: Date; locale: string }> {
assertFreelancerActor(actor); assertFreelancerActor(actor);
const parsed = parseOrThrow(createInvitationSchema, input); const parsed = parseOrThrow(createInvitationSchema, input);
@@ -83,6 +87,7 @@ export async function createPortalInvitation(
if (!client) { if (!client) {
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı."); throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
} }
const locale = assertPortalReadyLocale(tx, parsed.locale);
if (client.authUserId) { if (client.authUserId) {
throw new PortalInvitationError( throw new PortalInvitationError(
@@ -143,6 +148,7 @@ export async function createPortalInvitation(
tokenHash, tokenHash,
clientId: parsed.clientId, clientId: parsed.clientId,
email: parsed.email, email: parsed.email,
locale,
status: "pending", status: "pending",
expiresAt, expiresAt,
createdByUserId: actor.user.id, createdByUserId: actor.user.id,
@@ -159,6 +165,7 @@ export async function createPortalInvitation(
metadata: { metadata: {
invitationId: inserted.id, invitationId: inserted.id,
clientId: parsed.clientId, clientId: parsed.clientId,
locale,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
}, },
}) })
@@ -171,6 +178,7 @@ export async function createPortalInvitation(
id: invitationId, id: invitationId,
invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`, invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`,
expiresAt, expiresAt,
locale: parsed.locale,
}; };
} }
@@ -186,6 +194,7 @@ export function getPortalInvitationPreview(rawToken: string): PortalInvitationPr
email: portalInvitations.email, email: portalInvitations.email,
status: portalInvitations.status, status: portalInvitations.status,
expiresAt: portalInvitations.expiresAt, expiresAt: portalInvitations.expiresAt,
locale: portalInvitations.locale,
}) })
.from(portalInvitations) .from(portalInvitations)
.where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken))) .where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken)))
@@ -214,7 +223,7 @@ export function getPortalInvitationPreview(rawToken: string): PortalInvitationPr
.values({ .values({
type: "invitation_expired", type: "invitation_expired",
email: invitation.email, email: invitation.email,
metadata: { invitationId: invitation.id }, metadata: { invitationId: invitation.id },
}) })
.run(); .run();
} }
@@ -356,7 +365,7 @@ export async function acceptPortalInvitation(input: {
const linkedClient = tx const linkedClient = tx
.update(clients) .update(clients)
.set({ authUserId, updatedAt: now }) .set({ authUserId, portalLocale: invitation.locale, updatedAt: now })
.where( .where(
and( and(
eq(clients.id, invitation.clientId), 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 const accepted = tx
.update(portalInvitations) .update(portalInvitations)
.set({ status: "accepted", acceptedAt: now }) .set({ status: "accepted", acceptedAt: now })
@@ -395,7 +418,7 @@ export async function acceptPortalInvitation(input: {
type: "invitation_accepted", type: "invitation_accepted",
authUserId, authUserId,
email: invitation.email, email: invitation.email,
metadata: { invitationId: invitation.id, clientId: invitation.clientId }, metadata: { invitationId: invitation.id, clientId: invitation.clientId, locale: invitation.locale },
}) })
.run(); .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 { export function hashInvitationToken(rawToken: string): string {
return createHash("sha256").update(rawToken, "utf8").digest("hex"); return createHash("sha256").update(rawToken, "utf8").digest("hex");
} }
@@ -534,3 +611,21 @@ async function recordInvitationFailure(email: string | null, reason: string): Pr
}) })
.run(); .run();
} }
function assertPortalReadyLocale(
tx: Pick<ReturnType<typeof getSqliteConnection>["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;
}
+2 -1
View File
@@ -22,4 +22,5 @@ export type AuthAuditEventType =
| "invitation_accepted" | "invitation_accepted"
| "invitation_accept_failed" | "invitation_accept_failed"
| "client_access_disabled" | "client_access_disabled"
| "client_access_enabled"; | "client_access_enabled"
| "client_locale_updated";