refactor: update Dockerfile and authentication flow for improved user experience
All checks were successful
Deploy / deploy (push) Successful in 15m6s
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:
102
src/app/giris/dogrula/page.tsx
Normal file
102
src/app/giris/dogrula/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 açık.
|
||||
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
||||
PDF artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||
|
||||
@@ -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 4–6 hayal")
|
||||
L --> L2("ortada 12–14 dengeli")
|
||||
L --> L3("sonda 5–7 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 açıkça görürsün.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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'ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.
|
||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
|
||||
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
|
||||
nasıl kurulduğu da dahil.
|
||||
</p>
|
||||
<HuniSemasi />
|
||||
</section>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user