diff --git a/src/app/(auth)/forgot-password/components/forgot-password-form-1.tsx b/src/app/(auth)/forgot-password/components/forgot-password-form-1.tsx index 075d8ee..b720012 100644 --- a/src/app/(auth)/forgot-password/components/forgot-password-form-1.tsx +++ b/src/app/(auth)/forgot-password/components/forgot-password-form-1.tsx @@ -9,11 +9,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; -import { forgotPasswordAction } from "@/lib/appwrite/auth-actions"; +import { requestPasswordResetAction } from "@/lib/appwrite/password-reset-actions"; import { initialAuthState } from "@/lib/appwrite/auth-types"; export function ForgotPasswordForm1({ className, ...props }: React.ComponentProps<"div">) { - const [state, formAction, isPending] = useActionState(forgotPasswordAction, initialAuthState); + const [state, formAction, isPending] = useActionState(requestPasswordResetAction, initialAuthState); return (
diff --git a/src/app/(auth)/reset-password/components/reset-password-form.tsx b/src/app/(auth)/reset-password/components/reset-password-form.tsx new file mode 100644 index 0000000..514feeb --- /dev/null +++ b/src/app/(auth)/reset-password/components/reset-password-form.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Link from "next/link"; +import { useActionState } from "react"; +import { ArrowLeft, CircleNotch, ShieldCheck } from "@/lib/icons"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { resetPasswordAction } from "@/lib/appwrite/password-reset-actions"; +import { initialAuthState } from "@/lib/appwrite/auth-types"; + +interface Props extends React.ComponentProps<"div"> { + token: string; +} + +export function ResetPasswordForm({ token, className, ...props }: Props) { + const [state, formAction, isPending] = useActionState(resetPasswordAction, initialAuthState); + + return ( +
+ + +
+ +
+ Yeni şifre belirle + + Kod doğrulandı. Yeni şifrenizi girin. + +
+ +
+ + +
+ + +
+ +
+ + +
+ + {state.error && ( +

+ {state.error} +

+ )} + + + + + + Giriş sayfasına dön + + +
+
+
+ ); +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..bfed52a --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { XCircle } from "@/lib/icons"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { verifyResetToken } from "@/lib/appwrite/password-reset-actions"; +import { ResetPasswordForm } from "./components/reset-password-form"; + +interface Props { + searchParams: Promise<{ token?: string }>; +} + +export default async function ResetPasswordPage({ searchParams }: Props) { + const { token } = await searchParams; + + if (!token) { + return ; + } + + const { valid } = await verifyResetToken(token); + + if (!valid) { + return ; + } + + return ( +
+
+ +
+
+ ); +} + +function InvalidToken({ message }: { message: string }) { + return ( +
+
+ + +
+ +
+ Geçersiz kod +
+ +

{message}

+ + Yeni kod talep et + +
+
+
+
+ ); +} diff --git a/src/app/(auth)/sign-in/components/login-form-1.tsx b/src/app/(auth)/sign-in/components/login-form-1.tsx index 885fbc5..aa97ba2 100644 --- a/src/app/(auth)/sign-in/components/login-form-1.tsx +++ b/src/app/(auth)/sign-in/components/login-form-1.tsx @@ -14,8 +14,9 @@ import { initialAuthState } from "@/lib/appwrite/auth-types"; export function LoginForm1({ className, inviteCode, + passwordReset, ...props -}: React.ComponentProps<"div"> & { inviteCode?: string }) { +}: React.ComponentProps<"div"> & { inviteCode?: string; passwordReset?: boolean }) { const [state, formAction, isPending] = useActionState(signInAction, initialAuthState); return ( @@ -123,6 +124,12 @@ export function LoginForm1({

+ {passwordReset && ( +

+ Şifreniz güncellendi. Yeni şifrenizle giriş yapabilirsiniz. +

+ )} + {inviteCode && (

Davete katılmak için giriş yapın. diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 5876925..fce6d55 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -6,11 +6,11 @@ import { getCurrentUser } from "@/lib/appwrite/server"; export default async function Page({ searchParams, }: { - searchParams: Promise<{ invite?: string }>; + searchParams: Promise<{ invite?: string; reset?: string }>; }) { - const { invite } = await searchParams; + const { invite, reset } = await searchParams; const user = await getCurrentUser(); if (user) redirect(invite ? `/d/${invite}` : "/dashboard"); - return ; + return ; } diff --git a/src/lib/appwrite/password-reset-actions.ts b/src/lib/appwrite/password-reset-actions.ts new file mode 100644 index 0000000..a950da5 --- /dev/null +++ b/src/lib/appwrite/password-reset-actions.ts @@ -0,0 +1,131 @@ +"use server"; + +import { createHash, randomBytes } from "crypto"; +import { ID, Query } from "node-appwrite"; +import { redirect } from "next/navigation"; + +import { createAdminClient } from "./server"; +import { DATABASE_ID, TABLES } from "./schema"; +import type { AuthState } from "./auth-types"; + +const TOKEN_EXPIRY_MS = 15 * 60 * 1000; + +function generateToken(): { plain: string; hash: string } { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const bytes = randomBytes(8); + const plain = Array.from(bytes) + .map((b) => chars[b % chars.length]) + .join(""); + const hash = createHash("sha256").update(plain).digest("hex"); + return { plain, hash }; +} + +export async function requestPasswordResetAction( + _prev: AuthState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + if (!email) return { ok: false, error: "Email zorunlu." }; + + try { + const { tablesDB, users, messaging } = createAdminClient(); + + const found = await users.list([Query.equal("email", email), Query.limit(1)]); + // Kullanıcı yoksa hata vermiyoruz — timing attack önlemi + if (found.total === 0) return { ok: true }; + + const user = found.users[0]; + const { plain, hash } = generateToken(); + const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString(); + + await tablesDB.createRow(DATABASE_ID, TABLES.passwordResets, ID.unique(), { + email, + userId: user.$id, + tokenHash: hash, + expiresAt, + }); + + const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000"; + const resetLink = `${appUrl}/reset-password?token=${plain}`; + + await messaging.createEmail( + ID.unique(), + "Şifre Sıfırlama Kodunuz", + `

Merhaba,

+

Şifre sıfırlama talebiniz alındı. Aşağıdaki kodu kullanın:

+

${plain}

+

Veya doğrudan linke tıklayın:

+

${resetLink}

+

Bu kod 15 dakika geçerlidir. Bu talebi siz yapmadıysanız bu e-postayı dikkate almayın.

`, + [], + [user.$id], + [], + ); + + return { ok: true }; + } catch { + return { ok: false, error: "Bir hata oluştu. Tekrar deneyin." }; + } +} + +export async function verifyResetToken( + token: string, +): Promise<{ valid: boolean; tokenId?: string; userId?: string }> { + if (!token) return { valid: false }; + + try { + const hash = createHash("sha256").update(token.toUpperCase().trim()).digest("hex"); + const { tablesDB } = createAdminClient(); + + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.passwordResets, + queries: [ + Query.equal("tokenHash", hash), + Query.greaterThan("expiresAt", new Date().toISOString()), + Query.limit(1), + ], + }); + + if (result.total === 0) return { valid: false }; + + const row = result.rows[0] as unknown as { $id: string; userId: string; usedAt?: string }; + if (row.usedAt) return { valid: false }; + + return { valid: true, tokenId: row.$id, userId: row.userId }; + } catch { + return { valid: false }; + } +} + +export async function resetPasswordAction( + _prev: AuthState, + formData: FormData, +): Promise { + const token = String(formData.get("token") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + const confirmPassword = String(formData.get("confirmPassword") ?? ""); + + if (!token || !password) return { ok: false, error: "Tüm alanlar zorunlu." }; + if (password.length < 8) return { ok: false, error: "Şifre en az 8 karakter olmalı." }; + if (password !== confirmPassword) return { ok: false, error: "Şifreler eşleşmiyor." }; + + const { valid, tokenId, userId } = await verifyResetToken(token); + if (!valid || !tokenId || !userId) { + return { ok: false, error: "Kod geçersiz veya süresi dolmuş." }; + } + + try { + const { tablesDB, users } = createAdminClient(); + + await users.updatePassword(userId, password); + + await tablesDB.updateRow(DATABASE_ID, TABLES.passwordResets, tokenId, { + usedAt: new Date().toISOString(), + }); + } catch { + return { ok: false, error: "Şifre güncellenemedi. Tekrar deneyin." }; + } + + redirect("/sign-in?reset=success"); +} diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index fa785d9..f740fd1 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -16,6 +16,7 @@ export const TABLES = { tenantSettings: "tenant_settings", inviteLinks: "invite_links", deals: "deals", + passwordResets: "password_resets", } as const; export type TableId = (typeof TABLES)[keyof typeof TABLES]; diff --git a/src/lib/appwrite/server.ts b/src/lib/appwrite/server.ts index d25cfdd..7453c18 100644 --- a/src/lib/appwrite/server.ts +++ b/src/lib/appwrite/server.ts @@ -5,6 +5,7 @@ import { Account, Client, Databases, + Messaging, Storage, TablesDB, Teams, @@ -38,6 +39,7 @@ export function createAdminClient() { databases: new Databases(client), tablesDB: new TablesDB(client), storage: new Storage(client), + messaging: new Messaging(client), }; }