feat: persist portal invitation locales
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<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 (
|
||||
<AppShell
|
||||
branding={branding}
|
||||
colorMode={colorMode}
|
||||
homeHref="/portal"
|
||||
navGroups={portalSidebarData}
|
||||
settingsHref="/portal/settings"
|
||||
user={user}
|
||||
progress={progress}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
<I18nProvider locale={i18n.locale} messages={i18n.messages}>
|
||||
<AppShell
|
||||
branding={branding}
|
||||
colorMode={colorMode}
|
||||
homeHref="/portal"
|
||||
navGroups={navGroups}
|
||||
settingsHref="/portal/settings"
|
||||
user={user}
|
||||
progress={progress}
|
||||
labels={labels}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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<typeof createInvitationSchema>,
|
||||
): 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<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;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ export type AuthAuditEventType =
|
||||
| "invitation_accepted"
|
||||
| "invitation_accept_failed"
|
||||
| "client_access_disabled"
|
||||
| "client_access_enabled";
|
||||
| "client_access_enabled"
|
||||
| "client_locale_updated";
|
||||
|
||||
Reference in New Issue
Block a user