From dffc97239e4b0c0154655bdaed2e70fbb86c9a37 Mon Sep 17 00:00:00 2001 From: Ahmet Kilinc Date: Sat, 20 Dec 2025 03:15:55 +0000 Subject: [PATCH] wip --- actions/settings.ts | 239 ++++++++++++++++++++++ app/(main)/[username]/page.tsx | 83 ++++++-- app/(main)/settings/account/page.tsx | 55 +++++ app/(main)/settings/layout.tsx | 28 +++ app/(main)/settings/page.tsx | 65 ++++++ app/api/avatar/[filename]/route.ts | 35 ++++ components/header.tsx | 8 +- components/icons.tsx | 34 +++ components/settings/avatar-upload.tsx | 81 ++++++++ components/settings/delete-account.tsx | 112 ++++++++++ components/settings/email-form.tsx | 80 ++++++++ components/settings/password-form.tsx | 106 ++++++++++ components/settings/profile-form.tsx | 150 ++++++++++++++ components/settings/settings-nav.tsx | 42 ++++ components/settings/social-links-form.tsx | 107 ++++++++++ db/schema.ts | 14 +- next.config.ts | 5 + 17 files changed, 1220 insertions(+), 24 deletions(-) create mode 100644 actions/settings.ts create mode 100644 app/(main)/settings/account/page.tsx create mode 100644 app/(main)/settings/layout.tsx create mode 100644 app/(main)/settings/page.tsx create mode 100644 app/api/avatar/[filename]/route.ts create mode 100644 components/icons.tsx create mode 100644 components/settings/avatar-upload.tsx create mode 100644 components/settings/delete-account.tsx create mode 100644 components/settings/email-form.tsx create mode 100644 components/settings/password-form.tsx create mode 100644 components/settings/profile-form.tsx create mode 100644 components/settings/settings-nav.tsx create mode 100644 components/settings/social-links-form.tsx diff --git a/actions/settings.ts b/actions/settings.ts new file mode 100644 index 0000000..61055ac --- /dev/null +++ b/actions/settings.ts @@ -0,0 +1,239 @@ +"use server"; + +import { db } from "@/db"; +import { users, accounts, repositories } from "@/db/schema"; +import { getSession } from "@/lib/session"; +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { r2Put, r2Delete } from "@/lib/r2"; + +export async function updateProfile(data: { + name: string; + username: string; + bio?: string; + location?: string; + website?: string; + pronouns?: string; +}) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const normalizedUsername = data.username.toLowerCase().replace(/\s+/g, "-"); + + if (!/^[a-zA-Z0-9_-]+$/.test(normalizedUsername)) { + throw new Error("Username can only contain letters, numbers, underscores, and hyphens"); + } + + if (normalizedUsername.length < 3) { + throw new Error("Username must be at least 3 characters"); + } + + const existingUser = await db.query.users.findFirst({ + where: and( + eq(users.username, normalizedUsername), + ), + }); + + if (existingUser && existingUser.id !== session.user.id) { + throw new Error("Username is already taken"); + } + + await db + .update(users) + .set({ + name: data.name, + username: normalizedUsername, + bio: data.bio || null, + location: data.location || null, + website: data.website || null, + pronouns: data.pronouns || null, + updatedAt: new Date(), + }) + .where(eq(users.id, session.user.id)); + + revalidatePath("/settings"); + revalidatePath(`/${normalizedUsername}`); + + return { success: true, username: normalizedUsername }; +} + +export async function updateSocialLinks(data: { + github?: string; + twitter?: string; + linkedin?: string; + custom?: string[]; +}) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const socialLinks = { + github: data.github || undefined, + twitter: data.twitter || undefined, + linkedin: data.linkedin || undefined, + custom: data.custom?.filter(Boolean) || undefined, + }; + + await db + .update(users) + .set({ + socialLinks, + updatedAt: new Date(), + }) + .where(eq(users.id, session.user.id)); + + revalidatePath("/settings"); + + return { success: true }; +} + +export async function updateAvatar(formData: FormData) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const file = formData.get("avatar") as File; + if (!file || file.size === 0) { + throw new Error("No file provided"); + } + + if (!file.type.startsWith("image/")) { + throw new Error("File must be an image"); + } + + if (file.size > 5 * 1024 * 1024) { + throw new Error("File size must be less than 5MB"); + } + + const ext = file.name.split(".").pop() || "png"; + const key = `avatars/${session.user.id}.${ext}`; + + const buffer = Buffer.from(await file.arrayBuffer()); + await r2Put(key, buffer); + + const avatarUrl = `/api/avatar/${session.user.id}.${ext}`; + + await db + .update(users) + .set({ + image: avatarUrl, + avatarUrl, + updatedAt: new Date(), + }) + .where(eq(users.id, session.user.id)); + + revalidatePath("/settings"); + revalidatePath("/"); + + return { success: true, avatarUrl }; +} + +export async function updateEmail(data: { email: string }) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const existingUser = await db.query.users.findFirst({ + where: eq(users.email, data.email), + }); + + if (existingUser && existingUser.id !== session.user.id) { + throw new Error("Email is already in use"); + } + + await db + .update(users) + .set({ + email: data.email, + updatedAt: new Date(), + }) + .where(eq(users.id, session.user.id)); + + revalidatePath("/settings/account"); + + return { success: true }; +} + +export async function updatePassword(data: { + currentPassword: string; + newPassword: string; +}) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + }); + + if (!user) { + throw new Error("User not found"); + } + + try { + await auth.api.signInEmail({ + body: { email: user.email, password: data.currentPassword }, + }); + } catch { + throw new Error("Current password is incorrect"); + } + + await auth.api.changePassword({ + body: { + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }, + headers: { + cookie: `better-auth.session_token=${session.session.token}`, + }, + }); + + return { success: true }; +} + +export async function deleteAccount() { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const userRepos = await db.query.repositories.findMany({ + where: eq(repositories.ownerId, session.user.id), + }); + + const { r2DeletePrefix } = await import("@/lib/r2"); + for (const repo of userRepos) { + try { + await r2DeletePrefix(`repos/${session.user.id}/${repo.name}.git`); + } catch {} + } + + try { + await r2Delete(`avatars/${session.user.id}`); + } catch {} + + await db.delete(users).where(eq(users.id, session.user.id)); + + return { success: true }; +} + +export async function getCurrentUser() { + const session = await getSession(); + if (!session?.user) { + return null; + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + }); + + return user; +} + diff --git a/app/(main)/[username]/page.tsx b/app/(main)/[username]/page.tsx index e89f022..2b771d3 100644 --- a/app/(main)/[username]/page.tsx +++ b/app/(main)/[username]/page.tsx @@ -5,14 +5,12 @@ import { eq } from "drizzle-orm"; import { getUserRepositories } from "@/actions/repositories"; import { RepoList } from "@/components/repo-list"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { CalendarDays, GitBranch } from "lucide-react"; +import { CalendarDays, GitBranch, MapPin, Link as LinkIcon } from "lucide-react"; import { format } from "date-fns"; +import Link from "next/link"; +import { GithubIcon, XIcon, LinkedInIcon } from "@/components/icons"; -export default async function ProfilePage({ - params, -}: { - params: Promise<{ username: string }>; -}) { +export default async function ProfilePage({ params }: { params: Promise<{ username: string }> }) { const { username } = await params; const user = await db.query.users.findFirst({ @@ -29,19 +27,65 @@ export default async function ProfilePage({
@@ -56,9 +100,7 @@ export default async function ProfilePage({

No repositories yet

-

- {user.name} hasn't created any public repositories. -

+

{user.name} hasn't created any public repositories.

) : ( @@ -68,4 +110,3 @@ export default async function ProfilePage({
); } - diff --git a/app/(main)/settings/account/page.tsx b/app/(main)/settings/account/page.tsx new file mode 100644 index 0000000..d0f4956 --- /dev/null +++ b/app/(main)/settings/account/page.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { getCurrentUser } from "@/actions/settings"; +import { EmailForm } from "@/components/settings/email-form"; +import { PasswordForm } from "@/components/settings/password-form"; +import { DeleteAccount } from "@/components/settings/delete-account"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default async function AccountSettingsPage() { + const user = await getCurrentUser(); + + if (!user) { + redirect("/login"); + } + + return ( +
+ + + Email Address + + Change the email associated with your account + + + + + + + + + + Password + + Update your password to keep your account secure + + + + + + + + + + Danger Zone + + Irreversible actions that affect your account + + + + + + +
+ ); +} + diff --git a/app/(main)/settings/layout.tsx b/app/(main)/settings/layout.tsx new file mode 100644 index 0000000..b7a2ea1 --- /dev/null +++ b/app/(main)/settings/layout.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/session"; +import { SettingsNav } from "@/components/settings/settings-nav"; + +export default async function SettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getSession(); + + if (!session?.user) { + redirect("/login"); + } + + return ( +
+

Settings

+
+ +
{children}
+
+
+ ); +} + diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx new file mode 100644 index 0000000..a7d7eef --- /dev/null +++ b/app/(main)/settings/page.tsx @@ -0,0 +1,65 @@ +import { redirect } from "next/navigation"; +import { getCurrentUser } from "@/actions/settings"; +import { ProfileForm } from "@/components/settings/profile-form"; +import { AvatarUpload } from "@/components/settings/avatar-upload"; +import { SocialLinksForm } from "@/components/settings/social-links-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +export default async function SettingsPage() { + const user = await getCurrentUser(); + + if (!user) { + redirect("/login"); + } + + return ( +
+ + + Profile Picture + + Upload a picture to personalize your profile + + + + + + + + + + Profile Information + + Update your profile details visible to other users + + + + + + + + + + Social Links + + Add links to your social profiles + + + + + + +
+ ); +} + diff --git a/app/api/avatar/[filename]/route.ts b/app/api/avatar/[filename]/route.ts new file mode 100644 index 0000000..1fc418e --- /dev/null +++ b/app/api/avatar/[filename]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { r2Get } from "@/lib/r2"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + const { filename } = await params; + + const key = `avatars/${filename}`; + const data = await r2Get(key); + + if (!data) { + return new NextResponse(null, { status: 404 }); + } + + const ext = filename.split(".").pop()?.toLowerCase(); + let contentType = "image/png"; + + if (ext === "jpg" || ext === "jpeg") { + contentType = "image/jpeg"; + } else if (ext === "gif") { + contentType = "image/gif"; + } else if (ext === "webp") { + contentType = "image/webp"; + } + + return new NextResponse(data, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} + diff --git a/components/header.tsx b/components/header.tsx index 1f06cbf..3bb1bcc 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { GitBranch, Plus, LogOut, User, ChevronDown } from "lucide-react"; +import { GitBranch, Plus, LogOut, User, ChevronDown, Settings } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; @@ -70,6 +70,12 @@ export function Header() { Your profile + + + + Settings + + diff --git a/components/icons.tsx b/components/icons.tsx new file mode 100644 index 0000000..6d288b9 --- /dev/null +++ b/components/icons.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +export function XIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function GithubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function LinkedInIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/components/settings/avatar-upload.tsx b/components/settings/avatar-upload.tsx new file mode 100644 index 0000000..12777d2 --- /dev/null +++ b/components/settings/avatar-upload.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { updateAvatar } from "@/actions/settings"; +import { Camera, Loader2 } from "lucide-react"; + +interface AvatarUploadProps { + currentAvatar?: string | null; + name: string; +} + +export function AvatarUpload({ currentAvatar, name }: AvatarUploadProps) { + const [loading, setLoading] = useState(false); + const [preview, setPreview] = useState(currentAvatar || null); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("image/")) { + setError("Please select an image file"); + return; + } + + if (file.size > 5 * 1024 * 1024) { + setError("Image must be less than 5MB"); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + setPreview(e.target?.result as string); + }; + reader.readAsDataURL(file); + + setLoading(true); + setError(null); + + try { + const formData = new FormData(); + formData.append("avatar", file); + const result = await updateAvatar(formData); + setPreview(result.avatarUrl); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to upload avatar"); + setPreview(currentAvatar || null); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + + {name.charAt(0).toUpperCase()} + + {loading && ( +
+ +
+ )} +
+ +
+ + +

JPG, PNG or GIF. Max 5MB.

+ {error &&

{error}

} +
+
+ ); +} diff --git a/components/settings/delete-account.tsx b/components/settings/delete-account.tsx new file mode 100644 index 0000000..cc30c39 --- /dev/null +++ b/components/settings/delete-account.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { deleteAccount } from "@/actions/settings"; +import { Loader2, AlertTriangle } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface DeleteAccountProps { + username: string; +} + +export function DeleteAccount({ username }: DeleteAccountProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [confirmation, setConfirmation] = useState(""); + const [showConfirm, setShowConfirm] = useState(false); + const router = useRouter(); + + async function handleDelete() { + if (confirmation !== username) { + setError("Please type your username to confirm"); + return; + } + + setLoading(true); + setError(null); + + try { + await deleteAccount(); + router.push("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete account"); + setLoading(false); + } + } + + if (!showConfirm) { + return ( +
+

+ Once you delete your account, there is no going back. All your repositories and data will be permanently deleted. +

+ +
+ ); + } + + return ( +
+
+ +
+

+ This action cannot be undone +

+

+ This will permanently delete your account, all repositories, and remove all your data from our servers. +

+
+
+ +
+ + setConfirmation(e.target.value)} + placeholder="Enter your username" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ); +} + diff --git a/components/settings/email-form.tsx b/components/settings/email-form.tsx new file mode 100644 index 0000000..c959a5e --- /dev/null +++ b/components/settings/email-form.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { updateEmail } from "@/actions/settings"; +import { Loader2 } from "lucide-react"; + +interface EmailFormProps { + currentEmail: string; +} + +export function EmailForm({ currentEmail }: EmailFormProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(false); + + const formData = new FormData(e.currentTarget); + const email = formData.get("email") as string; + + if (email === currentEmail) { + setError("New email is the same as current email"); + setLoading(false); + return; + } + + try { + await updateEmail({ email }); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update email"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + +

+ Your email is used for account notifications and git authentication +

+
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Email updated successfully! +
+ )} + + +
+ ); +} + diff --git a/components/settings/password-form.tsx b/components/settings/password-form.tsx new file mode 100644 index 0000000..7e234ab --- /dev/null +++ b/components/settings/password-form.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { updatePassword } from "@/actions/settings"; +import { Loader2 } from "lucide-react"; + +export function PasswordForm() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(false); + + const formData = new FormData(e.currentTarget); + const currentPassword = formData.get("currentPassword") as string; + const newPassword = formData.get("newPassword") as string; + const confirmPassword = formData.get("confirmPassword") as string; + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + setLoading(false); + return; + } + + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + setLoading(false); + return; + } + + try { + await updatePassword({ currentPassword, newPassword }); + setSuccess(true); + (e.target as HTMLFormElement).reset(); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update password"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + +
+ +
+ + +

+ Must be at least 8 characters +

+
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Password updated successfully! +
+ )} + + +
+ ); +} + diff --git a/components/settings/profile-form.tsx b/components/settings/profile-form.tsx new file mode 100644 index 0000000..e2346e9 --- /dev/null +++ b/components/settings/profile-form.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { updateProfile } from "@/actions/settings"; +import { Loader2 } from "lucide-react"; + +interface ProfileFormProps { + user: { + name: string; + username: string; + bio?: string | null; + location?: string | null; + website?: string | null; + pronouns?: string | null; + }; +} + +export function ProfileForm({ user }: ProfileFormProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(false); + + const formData = new FormData(e.currentTarget); + + try { + await updateProfile({ + name: formData.get("name") as string, + username: formData.get("username") as string, + bio: formData.get("bio") as string, + location: formData.get("location") as string, + website: formData.get("website") as string, + pronouns: formData.get("pronouns") as string, + }); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update profile"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + +

+ Your name as it appears on your profile +

+
+ +
+ + +

+ Your unique handle. Letters, numbers, underscores, and hyphens only. +

+
+ +
+ +