148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
|
|
import { AuthTokenType, UserStatus } from "@prisma/client";
|
|
|
|
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
|
import { consumeAuthToken } from "@/lib/auth-tokens";
|
|
import { hashPassword } from "@/lib/auth";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
|
|
|
async function acceptInvite(formData: FormData) {
|
|
"use server";
|
|
|
|
const requestContext = await getRequestAuditContext();
|
|
const tokenRaw = formData.get("token");
|
|
const token = typeof tokenRaw === "string" ? tokenRaw.trim() : "";
|
|
const fullNameRaw = formData.get("fullName");
|
|
const fullName = typeof fullNameRaw === "string" ? fullNameRaw.trim() : "";
|
|
const passwordRaw = formData.get("password");
|
|
const password = typeof passwordRaw === "string" ? passwordRaw : "";
|
|
const confirmRaw = formData.get("confirmPassword");
|
|
const confirmPassword = typeof confirmRaw === "string" ? confirmRaw : "";
|
|
|
|
if (!token || !fullName || !password || !confirmPassword) {
|
|
redirect(`/invite/${encodeURIComponent(token)}?error=missing_fields`);
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
redirect(`/invite/${encodeURIComponent(token)}?error=password_mismatch`);
|
|
}
|
|
|
|
const resolvedToken = await consumeAuthToken(token, AuthTokenType.INVITE_ACCEPTANCE);
|
|
if (!resolvedToken.valid) {
|
|
redirect(
|
|
`/invite/${encodeURIComponent(token)}?error=${resolvedToken.reason === "expired" ? "expired_token" : "invalid_token"}`
|
|
);
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: resolvedToken.token.userId }
|
|
});
|
|
|
|
if (!user || user.status !== UserStatus.INVITED) {
|
|
redirect(`/invite/${encodeURIComponent(token)}?error=invalid_token`);
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
fullName,
|
|
passwordHash: await hashPassword(password),
|
|
status: UserStatus.ACTIVE
|
|
}
|
|
}),
|
|
prisma.authToken.update({
|
|
where: { id: resolvedToken.token.id },
|
|
data: { consumedAt: new Date() }
|
|
})
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
tenantId: user.tenantId,
|
|
actorUserId: user.id,
|
|
entityType: "user",
|
|
entityId: user.id,
|
|
action: "user_invite_accepted",
|
|
metadata: {
|
|
fullName,
|
|
email: user.email
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
|
|
redirect("/login?success=invite_accepted");
|
|
}
|
|
|
|
export default async function InvitationPage({
|
|
params,
|
|
searchParams
|
|
}: {
|
|
params: Promise<{ token: string }>;
|
|
searchParams?: Promise<{ error?: string }>;
|
|
}) {
|
|
const { token } = await params;
|
|
const paramsData = await (searchParams ?? Promise.resolve({ error: undefined }));
|
|
const error = paramsData?.error;
|
|
|
|
const resolvedToken = await consumeAuthToken(token, AuthTokenType.INVITE_ACCEPTANCE);
|
|
const isTokenValid = resolvedToken.valid;
|
|
|
|
const tokenInvalidMessage = error === "expired_token" ? "Token undangan sudah kedaluwarsa." : error === "invalid_token" ? "Link undangan tidak valid." : error === "missing_fields" ? "Lengkapi data nama dan password." : error === "password_mismatch" ? "Password tidak cocok." : null;
|
|
|
|
return (
|
|
<main className="min-h-screen bg-background px-6 py-16">
|
|
<div className="mx-auto w-full max-w-2xl rounded-[1.5rem] bg-surface-container-lowest p-4 shadow-card md:p-8">
|
|
<PageHeader title="Accept invitation" description="Selesaikan setup akun awal sebelum masuk ke dashboard." />
|
|
<div className="mt-8">
|
|
<SectionCard title="Invitation setup">
|
|
{!isTokenValid || tokenInvalidMessage ? (
|
|
<p className="mb-4 rounded-xl border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
|
{tokenInvalidMessage || "Link undangan tidak valid atau sudah kedaluwarsa."}
|
|
</p>
|
|
) : null}
|
|
{isTokenValid ? (
|
|
<form action={acceptInvite} className="grid gap-4">
|
|
<input type="hidden" name="token" value={token} />
|
|
<label className="text-sm font-medium text-on-surface-variant">
|
|
Full name
|
|
<input
|
|
name="fullName"
|
|
required
|
|
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
|
/>
|
|
</label>
|
|
<label className="text-sm font-medium text-on-surface-variant">
|
|
Password
|
|
<input
|
|
name="password"
|
|
type="password"
|
|
required
|
|
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
|
/>
|
|
</label>
|
|
<label className="text-sm font-medium text-on-surface-variant">
|
|
Confirm password
|
|
<input
|
|
name="confirmPassword"
|
|
type="password"
|
|
required
|
|
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
|
/>
|
|
</label>
|
|
<div className="mt-4">
|
|
<Button type="submit">Accept invitation</Button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<Button href="/login">Kembali ke login</Button>
|
|
)}
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|