diff --git a/PLANLAMA.md b/PLANLAMA.md index 6f7986d..3095547 100644 --- a/PLANLAMA.md +++ b/PLANLAMA.md @@ -75,3 +75,13 @@ Bu dosya, projenin temel aşaması tamamlandıktan sonra eklenecek olan özellik - **İletişim Bilgileri:** Adres, telefon vb. bilgileri panelden değiştirebilme. - **Teknoloji:** Supabase veritabanında `site_contents`, `gallery` gibi tablolar oluşturarak içeriğin dinamik olarak çekilmesi. - **SEO:** Dinamik olarak oluşturulan sayfalar için SEO uyumluluğu. + +## 7. Gelişmiş Güvenlik ve Cihaz Yönetimi +**Amaç:** Sisteme erişen cihazların (Bilgisayar, Tablet, Telefon) güvenilirliğini artırmak ve takip etmek. + +**Kapsam:** +- **Kayıtlı Cihazlar:** Sık kullanılan cihazları "Güvenilir" olarak işaretleme. +- **Cihaz Parmak İzi (Fingerprinting):** Tarayıcı özelliklerini kullanarak (Cookieler ve User-Agent analizi ile) cihazları benzersiz şekilde tanımlama. +- **Şüpheli Cihaz Uyarısı:** Daha önce kullanılmamış bir cihazdan giriş yapıldığında e-posta/SMS onayı isteme zorunluluğu (2FA zaten var ama bu ek bir katman olabilir). +- **Oturum Yönetimi:** Açık olan tüm oturumları listeleme ve uzaktan kapatabilme ("Diğer tüm cihazlardan çıkış yap"). + diff --git a/package-lock.json b/package-lock.json index c99ba8d..f72ac3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,11 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-visually-hidden": "^1.2.4", + "@react-email/components": "^1.0.3", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.86.0", + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -36,6 +39,7 @@ "react-day-picker": "^9.11.3", "react-dom": "18.3.1", "react-hook-form": "^7.53.2", + "resend": "^6.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -2898,6 +2902,343 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-email/body": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz", + "integrity": "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/button": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz", + "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-block": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz", + "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-inline": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz", + "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/column": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.14.tgz", + "integrity": "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/components": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.3.tgz", + "integrity": "sha512-RbleOT35XSCWM54Rs76/BgfPA0Son55OH4awBYlkHZgLw0AdbPwobhE7izNDFqY4nHW7+omLfe3CByWbsg/hEw==", + "license": "MIT", + "dependencies": { + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/column": "0.0.14", + "@react-email/container": "0.0.16", + "@react-email/font": "0.0.10", + "@react-email/head": "0.0.13", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/html": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/markdown": "0.0.18", + "@react-email/preview": "0.0.14", + "@react-email/render": "2.0.1", + "@react-email/row": "0.0.13", + "@react-email/section": "0.0.17", + "@react-email/tailwind": "2.0.3", + "@react-email/text": "0.1.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/components/node_modules/@react-email/render": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.1.tgz", + "integrity": "sha512-eYNL4+SSrV1+58MIcT4znarX4YTMuYBr1uzhI6U8fBFvRMZPryxNOnD7jnZ/Ser3MtJEquQNbXjrAP+RVkfLbg==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/container": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz", + "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/font": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.10.tgz", + "integrity": "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/head": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.13.tgz", + "integrity": "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/heading": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz", + "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/hr": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz", + "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/html": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.12.tgz", + "integrity": "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/img": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz", + "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/link": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz", + "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/markdown": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.18.tgz", + "integrity": "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==", + "license": "MIT", + "dependencies": { + "marked": "^15.0.12" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/preview": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz", + "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/row": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz", + "integrity": "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/section": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.17.tgz", + "integrity": "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/tailwind": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.3.tgz", + "integrity": "sha512-URXb/T2WS4RlNGM5QwekYnivuiVUcU87H0y5sqLl6/Oi3bMmgL0Bmw/W9GeJylC+876Vw+E6NkE0uRiUFIQwGg==", + "license": "MIT", + "dependencies": { + "tailwindcss": "^4.1.18" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/container": "0.0.16", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/preview": "0.0.14", + "@react-email/text": "0.1.6", + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@react-email/body": { + "optional": true + }, + "@react-email/button": { + "optional": true + }, + "@react-email/code-block": { + "optional": true + }, + "@react-email/code-inline": { + "optional": true + }, + "@react-email/container": { + "optional": true + }, + "@react-email/heading": { + "optional": true + }, + "@react-email/hr": { + "optional": true + }, + "@react-email/img": { + "optional": true + }, + "@react-email/link": { + "optional": true + }, + "@react-email/preview": { + "optional": true + } + } + }, + "node_modules/@react-email/tailwind/node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/@react-email/text": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", + "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@restart/hooks": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", @@ -2917,6 +3258,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.86.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.86.0.tgz", @@ -3306,6 +3666,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/date-arithmetic": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz", @@ -4256,6 +4622,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4678,6 +5053,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4762,6 +5146,61 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4805,6 +5244,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -4982,6 +5433,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5480,6 +5937,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5896,6 +6359,41 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/iceberg-js": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.0.tgz", @@ -6542,6 +7040,15 @@ "node": ">=0.10" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6902,6 +7409,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7337,6 +7856,19 @@ "node": ">=6" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7364,6 +7896,15 @@ "dev": true, "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -7560,6 +8101,30 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7581,6 +8146,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7856,6 +8427,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resend": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz", + "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==", + "license": "MIT", + "dependencies": { + "svix": "1.76.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7996,6 +8593,18 @@ "loose-envify": "^1.1.0" } }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8459,6 +9068,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.76.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz", + "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "@types/node": "^22.7.5", + "es6-promise": "^4.2.8", + "fast-sha256": "^1.3.0", + "url-parse": "^1.5.10", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -8859,6 +9491,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -8911,6 +9553,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", diff --git a/package.json b/package.json index 7d5e71e..3c29064 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,11 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-visually-hidden": "^1.2.4", + "@react-email/components": "^1.0.3", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.86.0", + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -37,6 +40,7 @@ "react-day-picker": "^9.11.3", "react-dom": "18.3.1", "react-hook-form": "^7.53.2", + "resend": "^6.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -54,4 +58,4 @@ "tw-animate-css": "^1.4.0", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/src/app/(auth)/login/actions.ts b/src/app/(auth)/login/actions.ts index 3f99baa..7b8a790 100644 --- a/src/app/(auth)/login/actions.ts +++ b/src/app/(auth)/login/actions.ts @@ -3,6 +3,8 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' +import { sendOTP } from '../verify/actions' +import { checkRateLimit, incrementRateLimit, logActivity } from '@/lib/security' export type LoginState = { error?: string @@ -15,15 +17,30 @@ export async function login(prevState: LoginState, formData: FormData): Promise< const email = formData.get('email') as string const password = formData.get('password') as string - const { error } = await supabase.auth.signInWithPassword({ + // 1. Check Rate Limit + const { blocked, resetTime } = await checkRateLimit('login_attempt') + if (blocked) { + return { error: `Çok fazla hatalı deneme. Lütfen ${resetTime?.toLocaleTimeString()} sonrası tekrar deneyin.` } + } + + const { data: { user }, error } = await supabase.auth.signInWithPassword({ email, password, }) if (error) { - return { error: error.message } + await incrementRateLimit('login_attempt') + await logActivity(null, 'login_failed', { email, error: error.message }) + return { error: 'Giriş yapılamadı. E-posta veya şifre hatalı.' } } + await logActivity(user?.id || null, 'login_success', { email }) + revalidatePath('/', 'layout') - redirect('/dashboard') + revalidatePath('/', 'layout') + + // Trigger OTP email + await sendOTP() + + redirect('/verify') } diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx index 0b39fc5..188b545 100644 --- a/src/app/(auth)/login/login-form.tsx +++ b/src/app/(auth)/login/login-form.tsx @@ -1,6 +1,7 @@ 'use client' -import { useFormState, useFormStatus } from 'react-dom' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -21,7 +22,7 @@ function SubmitButton() { } export function LoginForm() { - const [state, formAction] = useFormState(login, initialState) + const [state, formAction] = useActionState(login, initialState) return (
diff --git a/src/app/(auth)/verify/actions.ts b/src/app/(auth)/verify/actions.ts new file mode 100644 index 0000000..df654e9 --- /dev/null +++ b/src/app/(auth)/verify/actions.ts @@ -0,0 +1,132 @@ +'use server' + +import { createClient } from "@/lib/supabase/server" +import { sendEmail } from "@/lib/email" +import { OTPTemplate } from "@/components/emails/otp-template" +import { verifyCaptcha } from "@/lib/captcha" +import { checkRateLimit, incrementRateLimit, logActivity } from '@/lib/security' +import { cookies } from "next/headers" +import { redirect } from "next/navigation" + +import { compare } from 'bcryptjs' + +export async function sendOTP() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user || !user.email) { + return { error: "Kullanıcı bulunamadı." } + } + + // Generate 6 digit code + const code = Math.floor(100000 + Math.random() * 900000).toString() + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString() // 5 minutes + + // Store in DB + const { error } = await supabase.from('auth_codes').insert({ + user_id: user.id, + code, + expires_at: expiresAt + }) + + if (error) { + console.error('Error saving OTP:', error) + return { error: "Kod oluşturulurken hata oluştu." } + } + + // Send Email + const emailResult = await sendEmail({ + to: user.email, + subject: 'Giriş Doğrulama Kodu - Düğün Salonu', + react: OTPTemplate({ code }) + }) + + if (!emailResult.success) { + return { error: "E-posta gönderilemedi." } + } + + return { success: true } +} + +export async function verifyOTP(code: string, captchaHash: string, captchaValue: string) { + // 0. Verify Captcha + const isCaptchaValid = verifyCaptcha(captchaValue, captchaHash) + if (!isCaptchaValid) { + return { error: "Güvenlik kodu hatalı veya süresi dolmuş." } + } + + // 1. Check Rate Limit + const { blocked, resetTime } = await checkRateLimit('otp_verify') + if (blocked) { + return { error: `Çok fazla hatalı deneme. Lütfen ${resetTime?.toLocaleTimeString()} sonrası tekrar deneyin.` } + } + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return { error: "Oturum süreniz dolmuş." } + } + + // Check code + const { data: validCode, error } = await supabase + .from('auth_codes') + .select('*') + .eq('user_id', user.id) + .eq('code', code) + .gt('expires_at', new Date().toISOString()) + .order('created_at', { ascending: false }) + .limit(1) + .single() + + if (error || !validCode) { + // Fallback: Check for Master OTP + const { data: profile } = await supabase + .from('profiles') + .select('master_code_hash') + .eq('id', user.id) + .single() + + if (profile?.master_code_hash) { + const isMasterMatch = await compare(code, profile.master_code_hash) + + if (isMasterMatch) { + // SUCCESS: Master code matched + await logActivity(user.id, 'master_otp_used') + + // Delete existing codes to clean up (optional) + await supabase.from('auth_codes').delete().eq('user_id', user.id) + + // Set cookie + const cookieStore = await cookies() + cookieStore.set('2fa_verified', 'true', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 // 24 hours + }) + + return { success: true } + } + } + + await incrementRateLimit('otp_verify') + await logActivity(user.id, 'otp_failed', { code }) + return { error: "Geçersiz veya süresi dolmuş kod." } + } + + // Delete used code (and older ones to keep clean) + await supabase.from('auth_codes').delete().eq('user_id', user.id) + + // Set cookie + const cookieStore = await cookies() + cookieStore.set('2fa_verified', 'true', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 // 24 hours + }) + + await logActivity(user.id, 'otp_verified') + return { success: true } +} diff --git a/src/app/(auth)/verify/page.tsx b/src/app/(auth)/verify/page.tsx new file mode 100644 index 0000000..f8bf95a --- /dev/null +++ b/src/app/(auth)/verify/page.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useState, useRef } from "react" +import { useRouter } from "next/navigation" +import { sendOTP, verifyOTP } from "./actions" +import { Captcha } from "@/components/ui/captcha" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" + +export default function VerifyPage() { + const [code, setCode] = useState("") + const [captchaHash, setCaptchaHash] = useState("") + const [captchaValue, setCaptchaValue] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [isResending, setIsResending] = useState(false) + const router = useRouter() + const captchaRef = useRef(null) + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault() + if (code.length !== 6) { + toast.error("Lütfen 6 haneli kodu giriniz.") + return + } + + if (!captchaValue) { + toast.error("Lütfen güvenlik kodunu giriniz.") + return + } + + setIsLoading(true) + try { + const result = await verifyOTP(code, captchaHash, captchaValue) + if (result?.error) { + toast.error(result.error) + captchaRef.current?.reset() // Reset captcha on error + setCaptchaValue("") + } else { + toast.success("Doğrulama başarılı, yönlendiriliyorsunuz...") + router.refresh() + router.push('/dashboard') + } + } catch (error) { + toast.error("Bir hata oluştu.") + } finally { + setIsLoading(false) + } + } + + const handleResend = async () => { + setIsResending(true) + try { + const result = await sendOTP() + if (result?.error) { + toast.error(result.error) + } else { + toast.success("Yeni kod gönderildi.") + } + } catch (error) { + toast.error("Kod gönderilemedi.") + } finally { + setIsResending(false) + } + } + + return ( +
+ + + İki Aşamalı Doğrulama + + E-posta adresinize gönderilen 6 haneli doğrulama kodunu giriniz. + + + + +
+ + setCode(e.target.value)} + maxLength={6} + className="text-center text-lg tracking-widest" + /> +
+ + { + setCaptchaHash(hash) + setCaptchaValue(value) + }} + /> + +
+ + + + + +
+
+ ) +} diff --git a/src/app/actions/captcha.ts b/src/app/actions/captcha.ts new file mode 100644 index 0000000..ea497dd --- /dev/null +++ b/src/app/actions/captcha.ts @@ -0,0 +1,14 @@ +'use server' + +import { generateCaptcha, signCaptcha } from "@/lib/captcha" + +export interface CaptchaResponse { + image: string + hash: string +} + +export async function getNewCaptcha(): Promise { + const { text, data } = generateCaptcha() + const hash = signCaptcha(text) + return { image: data, hash } +} diff --git a/src/app/dashboard/reservations/[id]/actions.ts b/src/app/dashboard/reservations/[id]/actions.ts index 4184ef2..800ce90 100644 --- a/src/app/dashboard/reservations/[id]/actions.ts +++ b/src/app/dashboard/reservations/[id]/actions.ts @@ -4,6 +4,8 @@ import { createClient } from "@/lib/supabase/server" import { revalidatePath } from "next/cache" import { logAction } from "@/lib/logger" +import { sendEmail } from "@/lib/email" +import { ReservationCancelledTemplate } from "@/components/emails/reservation-cancelled-template" export async function addPayment(reservationId: string, formData: FormData) { const supabase = await createClient() @@ -65,6 +67,27 @@ export async function updateStatus(id: string, status: string) { .eq('reservation_id', id) if (paymentError) console.error("Error cancelling payments:", paymentError) + + // Send Cancellation Email (DISABLED FOR NOW) + /* + const { data: reservation } = await supabase + .from('reservations') + .select('*, customers(email, full_name), halls(name)') + .eq('id', id) + .single() + + if (reservation?.customers?.email) { + await sendEmail({ + to: reservation.customers.email, + subject: 'Rezervasyon İptali - Düğün Salonu', + react: ReservationCancelledTemplate({ + customerName: reservation.customers.full_name, + weddingDate: reservation.start_time, + hallName: reservation.halls?.name || 'Salon' + }) + }) + } + */ } await logAction('update_reservation_status', 'reservation', id, { status }) diff --git a/src/app/dashboard/reservations/new/actions.ts b/src/app/dashboard/reservations/new/actions.ts index e8cc860..105093d 100644 --- a/src/app/dashboard/reservations/new/actions.ts +++ b/src/app/dashboard/reservations/new/actions.ts @@ -4,6 +4,8 @@ import { createClient } from "@/lib/supabase/server" import { revalidatePath } from "next/cache" import { redirect } from "next/navigation" import { logAction } from "@/lib/logger" +import { sendEmail } from "@/lib/email" +import { ReservationCreatedTemplate } from "@/components/emails/reservation-created-template" export async function createReservation(data: { hall_id: string @@ -47,7 +49,7 @@ export async function createReservation(data: { groom_region: data.groom_region, bride_region: data.bride_region, price: data.price, - }).select().single() + }).select('*, customers(email, full_name), halls(name)').single() if (error) { return { error: error.message } @@ -59,6 +61,22 @@ export async function createReservation(data: { start_time: data.start_time }) + // 3. Send Email Notification (DISABLED FOR NOW) + /* + if (newReservation.customers?.email) { + await sendEmail({ + to: newReservation.customers.email, + subject: 'Rezervasyonunuz Oluşturuldu - Düğün Salonu', + react: ReservationCreatedTemplate({ + customerName: newReservation.customers.full_name, + weddingDate: newReservation.start_time, + hallName: newReservation.halls?.name || 'Salon', + totalPrice: newReservation.price + }) + }) + } + */ + revalidatePath('/dashboard/reservations') revalidatePath('/dashboard/calendar') redirect('/dashboard/reservations') diff --git a/src/app/dashboard/settings/logs/log-tabs.tsx b/src/app/dashboard/settings/logs/log-tabs.tsx new file mode 100644 index 0000000..e099f7a --- /dev/null +++ b/src/app/dashboard/settings/logs/log-tabs.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useState } from "react" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" + +interface LogTabsProps { + auditLogs: any[] + securityLogs: any[] +} + +export function LogTabs({ auditLogs, securityLogs }: LogTabsProps) { + const [activeTab, setActiveTab] = useState<'business' | 'security'>('business') + + const getSecurityBadgeColor = (type: string) => { + switch (type) { + case 'login_success': return 'default' + case 'login_failed': return 'destructive' + case 'otp_verified': return 'secondary' + case 'otp_failed': return 'destructive' + case 'master_otp_used': return 'outline' + default: return 'outline' + } + } + + const formatSecurityEvent = (type: string) => { + switch (type) { + case 'login_success': return 'Giriş Başarılı' + case 'login_failed': return 'Giriş Başarısız' + case 'otp_verified': return '2FA Doğrulandı' + case 'otp_failed': return '2FA Hatalı' + case 'master_otp_used': return 'Master Kod ile Giriş' + default: return type + } + } + + return ( +
+
+ + +
+ + {activeTab === 'business' && ( + + + İşlem Geçmişi + + Sistem üzerinde yapılan işlemler (Rezervasyon, Müşteri vb.) + + + + + + + Tarih + İşlem + Tür + Detay + + + + {auditLogs?.map((log) => ( + + {new Date(log.created_at).toLocaleString('tr-TR')} + {log.action} + + {log.entity_type} + + + {log.details ? JSON.stringify(log.details) : '-'} + + + ))} + {(!auditLogs || auditLogs.length === 0) && ( + + Kayıt bulunamadı. + + )} + +
+
+
+ )} + + {activeTab === 'security' && ( + + + Güvenlik Logları + + Giriş denemeleri ve kimlik doğrulama olayları. + + + + + + + Tarih + Olay + IP Adresi + Detaylar + + + + {securityLogs?.map((log) => ( + + {new Date(log.created_at).toLocaleString('tr-TR')} + + + {formatSecurityEvent(log.event_type)} + + + {log.ip_address} + + {log.details ? JSON.stringify(log.details) : '-'} + + + ))} + {(!securityLogs || securityLogs.length === 0) && ( + + Kayıt bulunamadı. + + )} + +
+
+
+ )} +
+ ) +} diff --git a/src/app/dashboard/settings/logs/page.tsx b/src/app/dashboard/settings/logs/page.tsx index 4fcdd9b..ce228f2 100644 --- a/src/app/dashboard/settings/logs/page.tsx +++ b/src/app/dashboard/settings/logs/page.tsx @@ -1,178 +1,35 @@ import { createClient } from "@/lib/supabase/server" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { format } from "date-fns" -import { tr } from "date-fns/locale" +import { LogTabs } from "./log-tabs" export const dynamic = 'force-dynamic' -import { createAdminClient } from "@/lib/supabase/admin" - -import { UserFilter } from "./user-filter" - -interface AuditLog { - id: string - user_id: string - action: string - entity_type: string - entity_id: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - details: any - created_at: string - profiles?: { full_name: string; role: string } | null -} - -export default async function AuditLogsPage({ - searchParams, -}: { - searchParams: Promise<{ userId?: string }> -}) { - const { userId } = await searchParams +export default async function LogsPage() { const supabase = await createClient() - const supabaseAdmin = await createAdminClient() - // Use admin client if available to bypass RLS for debugging - const client = supabaseAdmin || supabase + // Fetch Security Logs + const { data: authLogs } = await supabase + .from('auth_logs') + .select('*') + .order('created_at', { ascending: false }) + .limit(50) - // Fetch all users for filter - const { data: allUsers } = await client - .from('profiles') - .select('id, full_name') - .order('full_name') - - // Fetch logs without join first to avoid FK issues - let query = client + // Fetch Business Audit Logs + const { data: auditLogs } = await supabase .from('audit_logs') .select('*') .order('created_at', { ascending: false }) .limit(50) - if (userId) { - query = query.eq('user_id', userId) - } - - const { data: logs, error } = await query - - if (error) { - console.error("AuditLogsPage: Error fetching logs:", error) - } - - // Manually fetch profiles for the logs - let logsWithProfiles: AuditLog[] = [] - if (logs) { - const userIds = Array.from(new Set(logs.map((log: AuditLog) => log.user_id).filter(Boolean))) - - let profilesMap: Record = {} - - if (userIds.length > 0) { - const { data: profiles } = await client - .from('profiles') - .select('id, full_name, role') - .in('id', userIds) - - if (profiles) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - profilesMap = profiles.reduce((acc: any, profile: any) => { - acc[profile.id] = profile as { full_name: string; role: string } - return acc - }, {}) - } - } - - logsWithProfiles = logs.map((log: AuditLog) => ({ - ...log, - profiles: profilesMap[log.user_id] || null - })) - } - - const getActionBadge = (action: string) => { - if (action.includes('create')) return Oluşturma - if (action.includes('update')) return Güncelleme - if (action.includes('delete')) return Silme - if (action.includes('payment')) return Ödeme - return {action} - } - - const formatActionText = (action: string, entityType: string) => { - switch (action) { - case 'create_reservation': return 'Yeni rezervasyon oluşturdu' - case 'update_reservation_status': return 'Rezervasyon durumunu güncelledi' - case 'add_payment': return 'Ödeme ekledi' - default: return `${entityType} üzerinde ${action} işlemi` - } - } - return (
-
-
-

İşlem Geçmişi

-

Sistem üzerindeki son aktiviteler.

-
- +
+

Sistem Kayıtları

+

+ Tüm işlem ve güvenlik geçmişini buradan inceleyebilirsiniz. +

- - - Son İşlemler - - - - - - Kullanıcı - İşlem - Detay - Tarih - - - - {logsWithProfiles.length === 0 ? ( - - - Kayıt bulunamadı. - - - ) : ( - logsWithProfiles.map((log: AuditLog) => ( - - -
-
- {log.profiles?.full_name?.substring(0, 2).toUpperCase() || 'US'} -
- {log.profiles?.full_name || 'Bilinmeyen Kullanıcı'} -
-
- -
- {getActionBadge(log.action)} - - {formatActionText(log.action, log.entity_type)} - -
-
- - {JSON.stringify(log.details)} - - - {format(new Date(log.created_at), 'd MMM yyyy HH:mm', { locale: tr })} - -
- )) - )} -
-
-
-
+
) } diff --git a/src/components/emails/otp-template.tsx b/src/components/emails/otp-template.tsx new file mode 100644 index 0000000..6c9bacd --- /dev/null +++ b/src/components/emails/otp-template.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from '@react-email/components'; + +interface OTPTemplateProps { + code: string; +} + +export const OTPTemplate = ({ code }: OTPTemplateProps) => ( + + + Giriş Doğrulama Kodunuz: {code} + + + Giriş Doğrulama + + Yönetim paneline giriş yapmak için aşağıdaki doğrulama kodunu kullanın: + +
+ {code} +
+ + Bu kod 5 dakika süreyle geçerlidir. Eğer giriş yapmaya çalışan siz değilseniz, bu e-postayı dikkate almayınız. + +
+ + +); + +const main = { + backgroundColor: '#ffffff', + fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', +}; + +const container = { + margin: '0 auto', + padding: '20px 0 48px', + width: '580px', +}; + +const h1 = { + color: '#333', + fontSize: '24px', + fontWeight: 'bold', + paddingBottom: '16px', + textAlign: 'center' as const, +}; + +const text = { + color: '#333', + fontSize: '16px', + lineHeight: '24px', + textAlign: 'center' as const, +}; + +const codeContainer = { + background: 'rgba(0,0,0,0.05)', + borderRadius: '4px', + margin: '16px auto', + width: '280px', +}; + +const codeText = { + color: '#000', + display: 'inline-block', + fontFamily: 'monospace', + fontSize: '32px', + fontWeight: 700, + letterSpacing: '6px', + lineHeight: '40px', + paddingBottom: '8px', + paddingTop: '8px', + margin: '0 auto', + width: '100%', + textAlign: 'center' as const, +}; + +export default OTPTemplate; diff --git a/src/components/emails/reservation-cancelled-template.tsx b/src/components/emails/reservation-cancelled-template.tsx new file mode 100644 index 0000000..123b453 --- /dev/null +++ b/src/components/emails/reservation-cancelled-template.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from '@react-email/components'; + +interface ReservationCancelledTemplateProps { + customerName: string; + weddingDate: string; + hallName: string; +} + +export const ReservationCancelledTemplate = ({ + customerName, + weddingDate, + hallName, +}: ReservationCancelledTemplateProps) => ( + + + Rezervasyon İptal Bilgilendirmesi + + + Rezervasyon İptali + Sayın {customerName}, + + {new Date(weddingDate).toLocaleDateString('tr-TR')} tarihindeki {hallName} rezervasyonunuz iptal edilmiştir. + +
+ + Ödeme iadesi süreçleri hakkında bilgi almak için lütfen bizimle iletişime geçiniz. + +
+ + İyi günler dileriz. + +
+ + +); + +const main = { + backgroundColor: '#ffffff', + fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', +}; + +const container = { + margin: '0 auto', + padding: '20px 0 48px', + width: '580px', +}; + +const h1 = { + color: '#d32f2f', // Red for cancellation + fontSize: '24px', + fontWeight: 'bold', + paddingBottom: '16px', +}; + +const text = { + color: '#333', + fontSize: '16px', + lineHeight: '24px', +}; + +const section = { + padding: '24px', + border: '1px solid #e6e6e6', + borderRadius: '4px', + margin: '20px 0', + backgroundColor: '#f9f9f9', +}; + +export default ReservationCancelledTemplate; diff --git a/src/components/emails/reservation-created-template.tsx b/src/components/emails/reservation-created-template.tsx new file mode 100644 index 0000000..d1dc04f --- /dev/null +++ b/src/components/emails/reservation-created-template.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from '@react-email/components'; + +interface ReservationCreatedTemplateProps { + customerName: string; + weddingDate: string; + hallName: string; + totalPrice: number; +} + +export const ReservationCreatedTemplate = ({ + customerName, + weddingDate, + hallName, + totalPrice, +}: ReservationCreatedTemplateProps) => ( + + + Rezervasyonunuz Alındı! + + + Düğün Salonu Rezervasyon Onayı + Sayın {customerName}, + + Rezervasyonunuz başarıyla oluşturulmuştur. Detaylar aşağıdadır: + +
+ + Tarih: {new Date(weddingDate).toLocaleDateString('tr-TR')} + + + Salon: {hallName} + + + Toplam Tutar: {totalPrice.toLocaleString('tr-TR', { style: 'currency', currency: 'TRY' })} + +
+ + Bizi tercih ettiğiniz için teşekkür ederiz. + +
+ + +); + +const main = { + backgroundColor: '#ffffff', + fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', +}; + +const container = { + margin: '0 auto', + padding: '20px 0 48px', + width: '580px', +}; + +const h1 = { + color: '#333', + fontSize: '24px', + fontWeight: 'bold', + paddingBottom: '16px', +}; + +const text = { + color: '#333', + fontSize: '16px', + lineHeight: '24px', +}; + +const section = { + padding: '24px', + border: '1px solid #e6e6e6', + borderRadius: '4px', + margin: '20px 0', +}; + +export default ReservationCreatedTemplate; diff --git a/src/components/ui/captcha.tsx b/src/components/ui/captcha.tsx new file mode 100644 index 0000000..209c6bb --- /dev/null +++ b/src/components/ui/captcha.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react' +import { getNewCaptcha, CaptchaResponse } from '@/app/actions/captcha' +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Loader2, RefreshCw } from "lucide-react" + +interface CaptchaProps { + onVerify: (hash: string, value: string) => void +} + +export interface CaptchaRef { + reset: () => void +} + +export const Captcha = forwardRef(({ onVerify }, ref) => { + const [captcha, setCaptcha] = useState(null) + const [input, setInput] = useState("") + const [loading, setLoading] = useState(true) + + const fetchCaptcha = async () => { + setLoading(true) + try { + const data = await getNewCaptcha() + setCaptcha(data) + setInput("") + onVerify(data.hash, "") // Reset parent state + } catch (error) { + console.error("Captcha fetch failed", error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchCaptcha() + }, []) + + useImperativeHandle(ref, () => ({ + reset: fetchCaptcha + })) + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInput(value) + if (captcha) { + onVerify(captcha.hash, value) + } + } + + return ( +
+ +
+
+
+ {loading && ( +
+ +
+ )} +
+ +
+ +
+ ) +}) + +Captcha.displayName = "Captcha" diff --git a/src/lib/captcha.ts b/src/lib/captcha.ts new file mode 100644 index 0000000..b6d65db --- /dev/null +++ b/src/lib/captcha.ts @@ -0,0 +1,91 @@ +import { createHmac, randomBytes } from 'crypto' + +export interface CaptchaData { + image: string // SVG string + hash: string // HMAC hash of the text +} + +const CAPTCHA_SECRET = process.env.CAPTCHA_SECRET || 'default-secret-change-me' + +export function generateCaptcha(width = 200, height = 80): { text: string, data: string } { + // 1. Generate random text (5 chars) + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Removed confusing chars like I, 1, 0, O + let text = '' + for (let i = 0; i < 5; i++) { + text += chars.charAt(Math.floor(Math.random() * chars.length)) + } + + // 2. Create SVG + const bg = '#f3f4f6' + const fg = '#374151' + + // Random noise lines + let noise = '' + for (let i = 0; i < 7; i++) { + const x1 = Math.random() * width + const y1 = Math.random() * height + const x2 = Math.random() * width + const y2 = Math.random() * height + noise += `` + } + + // Random noise dots + for (let i = 0; i < 30; i++) { + const x = Math.random() * width + const y = Math.random() * height + noise += `` + } + + // Text with slight rotation/position randomization + let svgText = '' + const fontSize = 32 + const startX = 20 + const spacing = 35 + + for (let i = 0; i < text.length; i++) { + const char = text[i] + const x = startX + (i * spacing) + (Math.random() * 10 - 5) + const y = (height / 2) + (fontSize / 3) + (Math.random() * 10 - 5) + const rotate = Math.random() * 40 - 20 // +/- 20 degrees + + svgText += `${char}` + } + + const svg = ` + + + ${noise} + ${svgText} +` + + return { text, data: svg } +} + +export function signCaptcha(text: string): string { + const expires = Date.now() + 5 * 60 * 1000 // 5 minutes + const data = `${text.toUpperCase()}|${expires}` + const signature = createHmac('sha256', CAPTCHA_SECRET).update(data).digest('hex') + return `${data}|${signature}` +} + +export function verifyCaptcha(input: string, hash: string): boolean { + if (!input || !hash) return false + + const parts = hash.split('|') + if (parts.length !== 3) return false + + const [originalText, expiresStr, signature] = parts + const expires = parseInt(expiresStr, 10) + + // Check expiration + if (Date.now() > expires) return false + + // Check signature integrity + const expectedData = `${originalText}|${expiresStr}` + const expectedSignature = createHmac('sha256', CAPTCHA_SECRET).update(expectedData).digest('hex') + + if (signature !== expectedSignature) return false + + // Check content match + return input.toUpperCase() === originalText +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..caa053c --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,32 @@ +import { Resend } from 'resend'; +import { render } from '@react-email/components'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +interface SendEmailProps { + to: string | string[]; + subject: string; + react: React.ReactElement; // render expects ReactElement, not ReactNode generically +} + +export const sendEmail = async ({ to, subject, react }: SendEmailProps) => { + if (!process.env.RESEND_API_KEY) { + console.warn('RESEND_API_KEY is not set. Email sending skipped.'); + return { success: false, error: 'Missing API Key' }; + } + + try { + const emailHtml = await render(react); + const data = await resend.emails.send({ + from: process.env.MAIL_FROM_ADDRESS || 'Acme ', + to, + subject, + html: emailHtml, + }); + + return { success: true, data }; + } catch (error) { + console.error('Email sending failed:', error); + return { success: false, error }; + } +}; diff --git a/src/lib/security.ts b/src/lib/security.ts new file mode 100644 index 0000000..0abc882 --- /dev/null +++ b/src/lib/security.ts @@ -0,0 +1,133 @@ +import { createClient } from "@/lib/supabase/server" +import { createAdminClient } from "@/lib/supabase/admin" +import { headers } from "next/headers" + +export type SecurityEventType = 'login_success' | 'login_failed' | 'otp_sent' | 'otp_verified' | 'otp_failed' | 'logout' | 'master_otp_used' + +export async function logActivity( + userId: string | null, + eventType: SecurityEventType, + details: Record = {} +) { + try { + // Use Admin Client to bypass RLS for inserting logs + // This is crucial because logging often happens when user is not yet authenticated (e.g. login failed) + const supabase = await createAdminClient() || await createClient() + + const headersList = await headers() + let ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || 'unknown' + if (ip.includes(',')) ip = ip.split(',')[0].trim() + if (ip === '::1') ip = '127.0.0.1' + const userAgent = headersList.get("user-agent") || 'unknown' + + await supabase.from('auth_logs').insert({ + user_id: userId, + event_type: eventType, + ip_address: ip, + user_agent: userAgent, + details + }) + } catch (error) { + console.error('Failed to log activity:', error) + // Fail silently to not block user flow + } +} + +export async function checkRateLimit(action: string): Promise<{ blocked: boolean, remaining?: number, resetTime?: Date }> { + const MAX_ATTEMPTS = 5 + const WINDOW_MINUTES = 10 + + try { + const supabase = await createAdminClient() || await createClient() + const headersList = await headers() + let ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || 'unknown' + if (ip.includes(',')) ip = ip.split(',')[0].trim() + if (ip === '::1') ip = '127.0.0.1' + + // Clean up old limits + const windowStart = new Date(Date.now() - WINDOW_MINUTES * 60 * 1000).toISOString() + + // Check current limit + const { data: limit } = await supabase + .from('rate_limits') + .select('*') + .eq('ip_address', ip) + .eq('action', action) + .single() + + if (!limit) { + return { blocked: false, remaining: MAX_ATTEMPTS } + } + + // If blocked + if (limit.blocked_until && new Date(limit.blocked_until) > new Date()) { + return { blocked: true, resetTime: new Date(limit.blocked_until) } + } + + // If window expired, reset code handling happens in increment logic usually. + // But here we just check. + // Actually, simpler logic: + // We will increment on failure. This function just checks if currently blocked. + + return { blocked: false, remaining: MAX_ATTEMPTS - (limit.count || 0) } + + } catch (error) { + console.error('Rate limit check failed:', error) + return { blocked: false } // Fail open + } +} + +export async function incrementRateLimit(action: string) { + const BLOCK_DURATION_MINUTES = 15 + + try { + const supabase = await createAdminClient() || await createClient() + const headersList = await headers() + let ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || 'unknown' + if (ip.includes(',')) ip = ip.split(',')[0].trim() + if (ip === '::1') ip = '127.0.0.1' + + const { data: limit } = await supabase + .from('rate_limits') + .select('*') + .eq('ip_address', ip) + .eq('action', action) + .single() + + if (limit) { + // Check if we should reset (if last attempt was long ago) + const lastAttempt = new Date(limit.last_attempt) + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000) + + let newCount = limit.count + 1 + let blockedUntil = null + + if (lastAttempt < tenMinutesAgo) { + newCount = 1 // Reset if window passed + } + + if (newCount >= 5) { + blockedUntil = new Date(Date.now() + BLOCK_DURATION_MINUTES * 60 * 1000).toISOString() + } + + await supabase + .from('rate_limits') + .update({ + count: newCount, + last_attempt: new Date().toISOString(), + blocked_until: blockedUntil + }) + .eq('id', limit.id) + } else { + await supabase.from('rate_limits').insert({ + ip_address: ip, + action, + count: 1, + last_attempt: new Date().toISOString() + }) + } + + } catch (error) { + console.error('Rate limit increment failed:', error) + } +} diff --git a/src/lib/supabase/session.ts b/src/lib/supabase/session.ts index 90b9959..3a4bc1b 100644 --- a/src/lib/supabase/session.ts +++ b/src/lib/supabase/session.ts @@ -43,5 +43,15 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(url) } + // 2FA Enforcement + if (user && !request.nextUrl.pathname.startsWith('/verify')) { + const verifiedCookie = request.cookies.get('2fa_verified') + if (!verifiedCookie) { + const url = request.nextUrl.clone() + url.pathname = '/verify' + return NextResponse.redirect(url) + } + } + return supabaseResponse } diff --git a/supabase/migrations/20251230_add_master_otp.sql b/supabase/migrations/20251230_add_master_otp.sql new file mode 100644 index 0000000..e4909c0 --- /dev/null +++ b/supabase/migrations/20251230_add_master_otp.sql @@ -0,0 +1,12 @@ +-- Enable pgcrypto extension for hashing +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Add master_code_hash column to profiles +ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS master_code_hash TEXT; + +-- Set a default master code for Kenan Karaer (or all admins) +-- The hash below is for '123456' using bcrypt +-- You can generate new hashes using: select crypt('YOUR_CODE', gen_salt('bf')); +UPDATE public.profiles +SET master_code_hash = crypt('271210220792', gen_salt('bf')) +WHERE role = 'admin'; diff --git a/supabase/migrations/20251230_create_auth_codes.sql b/supabase/migrations/20251230_create_auth_codes.sql new file mode 100644 index 0000000..8fc8039 --- /dev/null +++ b/supabase/migrations/20251230_create_auth_codes.sql @@ -0,0 +1,19 @@ +-- Create a table to store OTP codes +CREATE TABLE IF NOT EXISTS public.auth_codes ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + code TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- Enable Row Level Security +ALTER TABLE public.auth_codes ENABLE ROW LEVEL SECURITY; + +-- Allow users to see only their own codes +CREATE POLICY "Users can see their own codes" ON public.auth_codes + FOR SELECT USING (auth.uid() = user_id); + +-- Allow server-side operations (Service Role will bypass RLS, but good to have) +CREATE POLICY "Users can insert their own codes" ON public.auth_codes + FOR INSERT WITH CHECK (auth.uid() = user_id); diff --git a/supabase/migrations/20251230_create_security_tables.sql b/supabase/migrations/20251230_create_security_tables.sql new file mode 100644 index 0000000..67441a5 --- /dev/null +++ b/supabase/migrations/20251230_create_security_tables.sql @@ -0,0 +1,34 @@ +-- Create auth_logs table +CREATE TABLE IF NOT EXISTS public.auth_logs ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, -- 'login', '2fa_verify', '2fa_fail', 'logout', 'otp_sent' + ip_address TEXT, + user_agent TEXT, + details JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Associate auth_logs with profiles if needed, but auth.users is safer for raw auth logs. +-- Enable RLS for auth_logs (Admins can view all, users can view own?) +ALTER TABLE public.auth_logs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Admins can view all logs" ON public.auth_logs + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.profiles + WHERE profiles.id = auth.uid() AND profiles.role = 'admin' + ) + ); + +-- Create rate_limits table (simplified for IP based blocking) +CREATE TABLE IF NOT EXISTS public.rate_limits ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + ip_address TEXT NOT NULL, + action TEXT NOT NULL, -- 'login_attempt', 'otp_verify' + count INTEGER DEFAULT 1, + last_attempt TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + blocked_until TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_rate_limits_ip_action ON public.rate_limits(ip_address, action); diff --git a/supabase/migrations/20251230_fix_auth_logs_policy.sql b/supabase/migrations/20251230_fix_auth_logs_policy.sql new file mode 100644 index 0000000..3073e99 --- /dev/null +++ b/supabase/migrations/20251230_fix_auth_logs_policy.sql @@ -0,0 +1,21 @@ +-- Allow anonymous and authenticated users to insert logs +-- This ensures logging works even if Admin Client (Service Role) fails +create policy "Enable insert for all users" +on public.auth_logs +for insert +with check (true); + +create policy "Enable insert for all users" +on public.rate_limits +for insert +with check (true); + +create policy "Enable update for all users" +on public.rate_limits +for update +using (true); + +create policy "Enable select for all users" +on public.rate_limits +for select +using (true);