Use Cases (Diperbarui: 2/6/2026)

Membangun profil pengguna yang aman dengan Claude Code

Implementasi profil pengguna dengan DB, form, validasi, otorisasi, avatar, dan audit log memakai Claude Code.

Membangun profil pengguna yang aman dengan Claude Code

Fitur profil pengguna tampak sederhana: nama tampilan, bio singkat, dan avatar. Namun saat masuk produksi, fitur ini menyentuh kepemilikan data, batas field publik, validasi link, batas upload gambar, otorisasi, serta audit log. Jika perintah ke Claude Code hanya “buat halaman profil”, hasilnya mungkin terlihat bagus tetapi masih membuka risiko.

Panduan ini menunjukkan cara memakai Claude Code untuk membangun profil pengguna dengan Next.js App Router, Prisma, Zod, React, dan Sharp. Fokusnya adalah membuat kontrak sebelum kode dibuat: field mana yang boleh diubah pengguna, field mana yang tidak pernah boleh diterima dari request body, dan data mana yang aman dikirim kembali ke client.

Untuk dasar login, baca implementasi autentikasi dengan Claude Code. Untuk input form, lihat juga validasi form dengan Claude Code. Referensi resmi yang relevan adalah OWASP Mass Assignment Cheat Sheet, OWASP File Upload Cheat Sheet, Next.js Route Handlers, dan Prisma relations.

Ruang lingkup dan batas keamanan

Fitur ini memungkinkan pengguna yang sudah login mengedit profilnya sendiri dan memilih apakah profil tersebut publik. Field yang bisa diedit adalah username, display name, bio, lokasi, website, social links, status publik, dan avatar URL. Email, plan, role, izin tim, dan status verifikasi email tidak termasuk.

Autentikasi menjawab “siapa pengguna ini”. Otorisasi menjawab “aksi apa yang boleh dilakukan pengguna ini”. Halaman profil hanya mengatur cara pengguna tampil, bukan mengubah status admin atau paket berlangganan.

AreaAturanAlasan
PemilikHanya update session.user.idMencegah edit profil orang lain
InputHanya menerima field dari ZodMencegah mass assignment
ResponseMengirim field yang aman untuk publikTidak membocorkan email atau ID internal
AvatarValidasi MIME, ukuran, pixel, lalu re-encodeMengurangi risiko upload berbahaya
AuditSimpan nama field berubah dan metadata kecilBisa dilacak tanpa menyalin PII
flowchart TD
  A["Profile form"] --> B["Zod validation"]
  B --> C["Authorization check"]
  C --> D["Prisma transaction"]
  D --> E["Profile table"]
  D --> F["ProfileAuditLog"]
  A --> G["Avatar upload"]
  G --> H["MIME / size / pixel checks"]
  H --> I["Sharp resize to WebP"]
  I --> E

Desain database

Pisahkan User dan Profile dengan relasi satu banding satu. User menyimpan identitas dan status akun. Profile hanya menyimpan data tampilan. Riwayat perubahan masuk ke ProfileAuditLog.

// prisma/schema.prisma
model User {
  id               String            @id @default(cuid())
  email            String            @unique
  emailVerified    DateTime?
  profile          Profile?
  profileAuditLogs ProfileAuditLog[] @relation("ProfileAuditActor")
  createdAt        DateTime          @default(now())
  updatedAt        DateTime          @updatedAt
}

model Profile {
  id          String   @id @default(cuid())
  userId      String   @unique
  username    String   @unique
  displayName String
  bio         String   @default("")
  location    String   @default("")
  websiteUrl  String   @default("")
  avatarUrl   String?
  socialLinks Json     @default("{}")
  isPublic    Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([isPublic, updatedAt])
}

model ProfileAuditLog {
  id            String   @id @default(cuid())
  userId        String
  actorUserId   String
  action        String
  changedFields Json
  metadata      Json?
  createdAt     DateTime @default(now())

  actor User @relation("ProfileAuditActor", fields: [actorUserId], references: [id])

  @@index([userId, createdAt])
}

Jangan tambahkan role, plan, isAdmin, atau emailVerified ke Profile. Jika admin perlu mengedit profil anggota, buat endpoint admin terpisah dan catat admin sebagai actorUserId.

Validasi dengan Zod

Zod menjadi batas antara request yang tidak tepercaya dan database. .strict() menolak key yang tidak dikenal. Dengan begitu payload seperti role, emailVerified, atau userId tidak bisa lewat.

// src/lib/profile-schema.ts
import { z } from "zod";

const usernamePattern = /^[a-z0-9][a-z0-9_-]{2,29}$/;

function isHttpUrl(value: string) {
  if (value === "") return true;

  try {
    const url = new URL(value);
    return url.protocol === "http:" || url.protocol === "https:";
  } catch {
    return false;
  }
}

const optionalHttpUrl = z
  .string()
  .trim()
  .max(200)
  .refine(isHttpUrl, "Use http or https URLs only");

export const profileInputSchema = z
  .object({
    username: z
      .string()
      .trim()
      .regex(usernamePattern, "Use 3-30 lowercase letters, numbers, _ or -"),
    displayName: z.string().trim().min(1).max(40),
    bio: z.string().trim().max(280).default(""),
    location: z.string().trim().max(80).default(""),
    websiteUrl: optionalHttpUrl.default(""),
    socialLinks: z
      .object({
        github: optionalHttpUrl.default(""),
        x: optionalHttpUrl.default(""),
        linkedin: optionalHttpUrl.default(""),
      })
      .default({}),
    isPublic: z.boolean().default(false),
  })
  .strict();

export type ProfileInput = z.infer<typeof profileInputSchema>;

Username dibatasi karena masuk ke URL publik. Display name ditampilkan sebagai teks, bukan HTML. Social link boleh kosong, tetapi jika diisi harus memakai http atau https.

API update profil

Route handler harus membaca session di server dan memakai session.user.id sebagai satu-satunya target update. Jangan memakai userId dari request body. Simpan profil dan audit log dalam satu transaksi.

// src/app/api/profile/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { profileInputSchema } from "@/lib/profile-schema";

const publicProfileSelect = {
  username: true,
  displayName: true,
  bio: true,
  location: true,
  websiteUrl: true,
  avatarUrl: true,
  socialLinks: true,
  isPublic: true,
  updatedAt: true,
} as const;

function changedKeys(before: Record<string, unknown> | null, after: Record<string, unknown>) {
  if (!before) return Object.keys(after);

  return Object.keys(after).filter((key) => {
    return JSON.stringify(before[key]) !== JSON.stringify(after[key]);
  });
}

export async function GET() {
  const session = await auth();

  if (!session?.user?.id) {
    return NextResponse.json({ error: "Authentication required" }, { status: 401 });
  }

  const profile = await prisma.profile.findUnique({
    where: { userId: session.user.id },
    select: publicProfileSelect,
  });

  return NextResponse.json({ profile });
}

export async function PUT(request: Request) {
  const session = await auth();

  if (!session?.user?.id) {
    return NextResponse.json({ error: "Authentication required" }, { status: 401 });
  }

  const json = await request.json().catch(() => null);
  const parsed = profileInputSchema.safeParse(json);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid profile input", issues: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const userId = session.user.id;
  const input = parsed.data;
  const before = await prisma.profile.findUnique({ where: { userId } });
  const fields = changedKeys(before, input);

  const profile = await prisma.$transaction(async (tx) => {
    const saved = await tx.profile.upsert({
      where: { userId },
      update: input,
      create: { userId, ...input },
      select: publicProfileSelect,
    });

    if (fields.length > 0) {
      await tx.profileAuditLog.create({
        data: {
          userId,
          actorUserId: userId,
          action: before ? "profile.update" : "profile.create",
          changedFields: fields,
          metadata: {
            source: "profile-settings",
            beforeDisplayName: before?.displayName ?? null,
          },
        },
      });
    }

    return saved;
  });

  return NextResponse.json({ profile });
}

Response hanya berisi field yang aman. Email, role, plan, ID internal, dan audit log tidak dikirim. Audit log menyimpan nama field yang berubah, bukan seluruh bio lama atau semua link lama.

Upload avatar

Upload gambar harus divalidasi di server. Contoh ini menyimpan WebP ke public/uploads/avatars untuk pengujian lokal. Di produksi, ganti dengan object storage dan kebijakan penghapusan file lama.

// src/app/api/profile/avatar/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

export const runtime = "nodejs";

const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = 4096 * 4096;
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);

export async function POST(request: Request) {
  const session = await auth();

  if (!session?.user?.id) {
    return NextResponse.json({ error: "Authentication required" }, { status: 401 });
  }

  const formData = await request.formData();
  const file = formData.get("avatar");

  if (!(file instanceof File)) {
    return NextResponse.json({ error: "Avatar file is required" }, { status: 400 });
  }

  if (!allowedTypes.has(file.type)) {
    return NextResponse.json({ error: "Use JPEG, PNG, or WebP" }, { status: 400 });
  }

  if (file.size > MAX_BYTES) {
    return NextResponse.json({ error: "Avatar must be 2MB or smaller" }, { status: 400 });
  }

  const input = Buffer.from(await file.arrayBuffer());
  const image = sharp(input, { limitInputPixels: MAX_PIXELS });
  const metadata = await image.metadata();

  if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64) {
    return NextResponse.json({ error: "Image must be at least 64x64 pixels" }, { status: 400 });
  }

  const output = await sharp(input, { limitInputPixels: MAX_PIXELS })
    .rotate()
    .resize(256, 256, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();

  const fileName = `${session.user.id}-${randomUUID()}.webp`;
  const uploadDir = path.join(process.cwd(), "public", "uploads", "avatars");
  await mkdir(uploadDir, { recursive: true });
  await writeFile(path.join(uploadDir, fileName), output);

  const avatarUrl = `/uploads/avatars/${fileName}`;

  await prisma.$transaction(async (tx) => {
    await tx.profile.update({
      where: { userId: session.user.id },
      data: { avatarUrl },
    });

    await tx.profileAuditLog.create({
      data: {
        userId: session.user.id,
        actorUserId: session.user.id,
        action: "profile.avatar.update",
        changedFields: ["avatarUrl"],
        metadata: { contentType: "image/webp", bytes: output.byteLength },
      },
    });
  });

  return NextResponse.json({ avatarUrl });
}

Untuk produksi, tentukan signed URL, scanning malware, cache CDN, nama file tidak mudah ditebak, dan pembersihan avatar lama. Claude Code akan lebih tepat jika kebijakan ini sudah tertulis.

Form React

Form memisahkan upload avatar dan penyimpanan JSON profil. Jika upload gambar gagal, teks tetap aman. Jika validasi profil gagal, pengguna tidak perlu mengunggah gambar ulang. State lokal diperbarui dari response server.

// src/components/ProfileForm.tsx
"use client";

import { useState, useTransition } from "react";
import type { ProfileInput } from "@/lib/profile-schema";

type ProfileFormProps = {
  initialProfile: ProfileInput & { avatarUrl?: string | null };
};

export function ProfileForm({ initialProfile }: ProfileFormProps) {
  const [form, setForm] = useState<ProfileInput>(initialProfile);
  const [avatarUrl, setAvatarUrl] = useState(initialProfile.avatarUrl ?? "");
  const [message, setMessage] = useState("");
  const [isPending, startTransition] = useTransition();

  function updateField<K extends keyof ProfileInput>(key: K, value: ProfileInput[K]) {
    setForm((current) => ({ ...current, [key]: value }));
  }

  async function uploadAvatar(file: File) {
    setMessage("");

    if (file.size > 2 * 1024 * 1024) {
      setMessage("Avatar must be 2MB or smaller.");
      return;
    }

    const body = new FormData();
    body.append("avatar", file);

    const response = await fetch("/api/profile/avatar", {
      method: "POST",
      body,
    });

    const result = await response.json();

    if (!response.ok) {
      setMessage(result.error ?? "Avatar upload failed.");
      return;
    }

    setAvatarUrl(result.avatarUrl);
    setMessage("Avatar updated.");
  }

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setMessage("");

    startTransition(async () => {
      const response = await fetch("/api/profile", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });

      const result = await response.json();

      if (!response.ok) {
        setMessage(result.error ?? "Profile save failed.");
        return;
      }

      setForm(result.profile);
      setMessage("Profile saved.");
    });
  }

  return (
    <form onSubmit={submit} className="mx-auto max-w-2xl space-y-5">
      <div className="flex items-center gap-4">
        <img
          src={avatarUrl || "/images/default-avatar.png"}
          alt=""
          className="h-20 w-20 rounded-full object-cover"
        />
        <label className="block">
          <span className="text-sm font-medium">Avatar image</span>
          <input
            type="file"
            accept="image/jpeg,image/png,image/webp"
            onChange={(event) => {
              const file = event.currentTarget.files?.[0];
              if (file) void uploadAvatar(file);
            }}
            className="mt-2 block text-sm"
          />
        </label>
      </div>

      <label className="block">
        <span className="text-sm font-medium">Username</span>
        <input
          value={form.username}
          onChange={(event) => updateField("username", event.target.value as ProfileInput["username"])}
          className="mt-1 w-full rounded border px-3 py-2"
          autoComplete="off"
        />
      </label>

      <label className="block">
        <span className="text-sm font-medium">Display name</span>
        <input
          value={form.displayName}
          onChange={(event) => updateField("displayName", event.target.value)}
          className="mt-1 w-full rounded border px-3 py-2"
          maxLength={40}
        />
      </label>

      <label className="block">
        <span className="text-sm font-medium">Bio</span>
        <textarea
          value={form.bio}
          onChange={(event) => updateField("bio", event.target.value)}
          className="mt-1 h-28 w-full rounded border px-3 py-2"
          maxLength={280}
        />
        <span className="text-xs text-slate-500">{form.bio.length}/280</span>
      </label>

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.isPublic}
          onChange={(event) => updateField("isPublic", event.target.checked)}
        />
        <span>Show this profile publicly</span>
      </label>

      <button
        type="submit"
        disabled={isPending}
        className="rounded bg-slate-900 px-4 py-2 font-medium text-white disabled:bg-slate-400"
      >
        {isPending ? "Saving..." : "Save profile"}
      </button>

      {message && <p className="text-sm text-slate-700">{message}</p>}
    </form>
  );
}

Setelah itu tambahkan error per field, pesan username duplikat, preview gambar, focus handling, dan pengujian disabled state. Semua ini melengkapi validasi server, bukan menggantikannya.

Prompt dan review Claude Code

Prompt harus menyebut scope, teknologi, dan larangan dengan jelas.

Implement a user profile feature in this Next.js App Router project.

Scope:
- Edit only the files needed for profile schema, API routes, avatar upload, and ProfileForm.
- Use Prisma for Profile and ProfileAuditLog.
- Use Zod for request validation.
- Never accept userId, email, role, plan, or emailVerified from the request body.
- Use session.user.id as the only profile owner.
- Return only public-safe profile fields from API responses.
- Limit avatars to JPEG, PNG, or WebP, 2MB max, then resize to 256x256 WebP.
- Create an audit log row for profile and avatar changes.

After implementation, review your own diff for:
- mass assignment
- broken authorization
- unsafe file upload
- PII in logs
- optimistic UI inconsistency
- missing tests or manual verification steps

Jalankan review kedua dengan fokus keamanan. Cari risiko edit profil orang lain, field role atau email yang dipercaya dari client, upload tidak aman, PII di log, dan response publik yang terlalu luas.

Use case

Use case pertama adalah profil akun SaaS. Pengguna mengedit nama, departemen, avatar, dan bio. Plan serta role tetap berada di alur lain.

Use case kedua adalah manajemen tim. Admin boleh melihat profil, tetapi perubahan role harus memakai endpoint admin. Jika admin mengedit profil anggota, audit log harus membedakan aktor dan target.

Use case ketiga adalah onboarding. Username dan display name pada login pertama memudahkan undangan tim dan halaman publik. Verifikasi email dan persetujuan syarat tetap menjadi state terpisah.

Use case keempat adalah profil publik. Hanya profil isPublic yang tampil di /users/[username]. Halaman publik tidak boleh mengirim email, ID internal, data tim, atau audit log.

Kesalahan umum

Kesalahan pertama adalah menerima target userId dari request. Untuk profil self-service, target harus berasal dari session.

Kesalahan kedua adalah mengirim ...body langsung ke Prisma. Ini membuka risiko mass assignment saat model bertambah.

Kesalahan ketiga adalah validasi gambar hanya di browser. Server harus membatasi dan re-encode.

Kesalahan keempat adalah menulis terlalu banyak PII ke log. Audit log harus menjawab siapa mengubah apa dan kapan, bukan menyalin seluruh profil.

Kesalahan kelima adalah optimistic UI yang tidak konsisten. Field publik harus diperbarui dari response server.

Monetisasi dan operasi

Profil memengaruhi revenue. Di SaaS, profil tim yang lengkap meningkatkan kepercayaan undangan. Di komunitas, profil publik memengaruhi reputasi. Di bisnis pelatihan, profil instruktur dan peserta memengaruhi keputusan konsultasi.

ClaudeCodeLab membantu tim membangun surface kecil tetapi sensitif dengan Claude Code: autentikasi, profil, admin screen, audit log, dan pengaturan dekat billing. Jika ingin memasukkan profil aman ke produk yang sudah ada atau melatih tim melakukan review output Claude Code, lihat training dan konsultasi Claude Code.

Setelah rilis, pantau kelengkapan profil, kegagalan upload avatar, username duplikat, view profil publik, dan klik CTA. Audit log dipakai untuk support dan incident review, bukan sebagai database kedua berisi data pribadi.

Hasil uji nyata

Masa menguji alur ini pada aplikasi Next.js kecil. Versi yang dimulai dengan batas database dan field terlarang jauh lebih mudah direview. Versi yang hanya diminta “buat halaman profil yang bagus” membawa userId di state form, melakukan validasi gambar hanya di client, dan menyimpan bio lama secara penuh di audit log.

Sebelum publish, uji JSON dengan userId lain, field role dan emailVerified, gambar lebih dari 2MB, link javascript:, username duplikat, dan save yang gagal. Claude Code memberi kecepatan, tetapi kualitas profil tetap datang dari pengecekan ownership, validasi, log, dan data publik.

#Claude Code #user profile #authentication #security #React
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.