refactor: update Dockerfile and authentication flow for improved user experience
All checks were successful
Deploy / deploy (push) Successful in 15m6s

- Updated Dockerfile to create a cache directory for Next.js runtime, ensuring proper permissions and ownership.
- Enhanced the authentication flow by updating the magic link expiration time from 5 to 15 minutes, improving user experience and reducing login issues.
- Added user-friendly error handling for invalid or expired magic links, providing clear feedback on the login page.
- Adjusted email templates to reflect the new 15-minute validity period for magic links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-07 02:05:14 +03:00
parent 37baeb9172
commit be4b2c6000
19 changed files with 444 additions and 134 deletions

View File

@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { PixelCorner } from "@/components/pixel-decor";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = {
title: "Girişini onayla",
robots: { index: false, follow: false },
};
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
function guvenliYol(deger: string | undefined, varsayilan: string): string {
return deger && deger.startsWith("/") && !deger.startsWith("//")
? deger
: varsayilan;
}
/**
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
*/
export default async function DogrulaPage({
searchParams,
}: {
searchParams: Promise<{
token?: string;
callbackURL?: string;
errorCallbackURL?: string;
newUserCallbackURL?: string;
}>;
}) {
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
await searchParams;
if (!token) redirect("/giris");
const callback = guvenliYol(callbackURL, "/");
const hataCallback = guvenliYol(
errorCallbackURL,
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
);
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
return (
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
<p className="mt-2 text-sm text-slate-600">
Bu bağlantı senin için üretildi ve{" "}
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
</p>
<form
method="GET"
action="/api/auth/magic-link/verify"
className="mt-6"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="callbackURL" value={callback} />
<input
type="hidden"
name="errorCallbackURL"
value={hataCallback}
/>
<input
type="hidden"
name="newUserCallbackURL"
value={yeniKullaniciCallback}
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Giriş yap
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
<p className="mt-4 text-xs leading-relaxed text-slate-500">
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin butona
tıklanmadığı sürece hesabında hiçbir şey değişmez.
</p>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
</main>
</div>
);
}

View File

@@ -74,6 +74,10 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
const { error } = await authClient.signIn.magicLink({
email,
callbackURL,
// Geçersiz/süresi dolmuş token'da kullanıcı ham ?error=INVALID_TOKEN
// parametresiyle baş başa kalmasın: giriş sayfası bu değeri dostça bir
// mesaja çevirir, callback korunur (bkz. src/app/giris/page.tsx).
errorCallbackURL: `/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callbackURL)}`,
});
setBusy(null);
if (error) {
@@ -93,7 +97,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
<p className="mt-1">
<span className="font-medium">{email}</span> adresine bir giriş
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et
bağlantı 5 dakika geçerli.
bağlantı 15 dakika geçerli.
</p>
</div>
<div className="flex items-center justify-center gap-4 text-sm">

View File

@@ -14,11 +14,15 @@ export const metadata: Metadata = {
export default async function GirisPage({
searchParams,
}: {
searchParams: Promise<{ callback?: string }>;
searchParams: Promise<{ callback?: string; hata?: string }>;
}) {
const { callback } = await searchParams;
const { callback, hata } = await searchParams;
const callbackURL =
callback && callback.startsWith("/") ? callback : "/";
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
const baglantiHatasi = hata === "gecersiz-baglanti";
const session = await getSession();
if (session) redirect(callbackURL);
@@ -33,6 +37,20 @@ export default async function GirisPage({
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{baglantiHatasi && (
<div
role="alert"
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
>
<p className="font-semibold">
Giriş bağlantısı geçersiz ya da süresi dolmuş
</p>
<p className="mt-1">
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
yeni bir bağlantı isteyebilirsin.
</p>
</div>
)}
<GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600">
{funnel ? (

View File

@@ -118,8 +118,8 @@ export default async function ListemPage({
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF
artık ık.
Paketin aktif listendeki tercihlerin tamamı, gerekçeler, riskler ve
PDF artık ık.
</div>
) : null}
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında

View File

@@ -18,12 +18,14 @@ flowchart TD
H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
G --> H
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
H --> U("Havuz yine de dar kalırsa<br/>liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
U --> Y
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
R --> L("24 tercihlik liste")
L --> L1("üstte 46 hayal")
L --> L2("ortada 1214 dengeli")
L --> L3("sonda 57 garanti")
R --> L("En fazla 24 tercihlik liste")
L --> L1("üstte hayal — hedef 5, havuz elverdiğince")
L --> L2("ortada dengeli — hedef 13, havuz elverdiğince")
L --> L3("sonda garanti — hedef 6, havuz elverdiğince")
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
@@ -31,7 +33,7 @@ flowchart TD
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
class H,Y havuz
class G uyari
class G,U uyari
class L1 hayal
class L2 dengeli
class L3 garanti`;
@@ -40,14 +42,15 @@ export function HuniSemasi() {
return (
<ElCizimiSema
tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
figcaption={
<>
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
<span className="font-medium text-slate-700">
dışına asla çıkmadan
</span>{" "}
kurar.
kurar havuz dar kalırsa liste eldeki programlarla kurulur ve bunu
listende ıkça görürsün.
</>
}
/>

View File

@@ -150,7 +150,9 @@ export default function MeraklisinaPage() {
<strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Aşağıdaki şema akışın tamamını gösterir havuz
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
yetmezse listenin eldeki programlarla, sana ıkça belirtilerek
nasıl kurulduğu da dahil.
</p>
<HuniSemasi />
</section>

View File

@@ -47,13 +47,21 @@ export default async function YazdirPage() {
<header className="border-b-2 border-slate-900 pb-4">
<h1 className="font-heading text-2xl font-bold">
24 Tercihlik Kişisel Liste ve Risk Raporu
{rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
</h1>
<p className="mt-1 text-sm text-slate-600">
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
türü: {PUAN_TURLERI[params.tur]}
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
</p>
{rapor.kapsam?.uyarlandi ? (
<p className="mt-1 text-sm text-slate-600">
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
</p>
) : null}
</header>
<section className="mt-5">