feat: persist portal invitation locales
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<I18nProvider locale={i18n.locale} messages={i18n.messages}>
|
||||||
<AppShell
|
<AppShell
|
||||||
branding={branding}
|
branding={branding}
|
||||||
colorMode={colorMode}
|
colorMode={colorMode}
|
||||||
homeHref="/portal"
|
homeHref="/portal"
|
||||||
navGroups={portalSidebarData}
|
navGroups={navGroups}
|
||||||
settingsHref="/portal/settings"
|
settingsHref="/portal/settings"
|
||||||
user={user}
|
user={user}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
labels={labels}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user