Güvenlik Doğrulaması,Login Logları
This commit is contained in:
10
PLANLAMA.md
10
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").
|
||||
|
||||
|
||||
655
package-lock.json
generated
655
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<form action={formAction} className="space-y-4">
|
||||
|
||||
132
src/app/(auth)/verify/actions.ts
Normal file
132
src/app/(auth)/verify/actions.ts
Normal file
@@ -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 }
|
||||
}
|
||||
121
src/app/(auth)/verify/page.tsx
Normal file
121
src/app/(auth)/verify/page.tsx
Normal file
@@ -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<any>(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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">İki Aşamalı Doğrulama</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
E-posta adresinize gönderilen 6 haneli doğrulama kodunu giriniz.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleVerify}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Doğrulama Kodu</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
className="text-center text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Captcha
|
||||
ref={captchaRef}
|
||||
onVerify={(hash, value) => {
|
||||
setCaptchaHash(hash)
|
||||
setCaptchaValue(value)
|
||||
}}
|
||||
/>
|
||||
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Doğrula
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-muted-foreground"
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isResending}
|
||||
>
|
||||
{isResending ? "Gönderiliyor..." : "Kodu Tekrar Gönder"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/app/actions/captcha.ts
Normal file
14
src/app/actions/captcha.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
'use server'
|
||||
|
||||
import { generateCaptcha, signCaptcha } from "@/lib/captcha"
|
||||
|
||||
export interface CaptchaResponse {
|
||||
image: string
|
||||
hash: string
|
||||
}
|
||||
|
||||
export async function getNewCaptcha(): Promise<CaptchaResponse> {
|
||||
const { text, data } = generateCaptcha()
|
||||
const hash = signCaptcha(text)
|
||||
return { image: data, hash }
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
150
src/app/dashboard/settings/logs/log-tabs.tsx
Normal file
150
src/app/dashboard/settings/logs/log-tabs.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'business' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('business')}
|
||||
>
|
||||
İşlem Geçmişi
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'security' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('security')}
|
||||
>
|
||||
Güvenlik Logları
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'business' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>İşlem Geçmişi</CardTitle>
|
||||
<CardDescription>
|
||||
Sistem üzerinde yapılan işlemler (Rezervasyon, Müşteri vb.)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>İşlem</TableHead>
|
||||
<TableHead>Tür</TableHead>
|
||||
<TableHead>Detay</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{auditLogs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{new Date(log.created_at).toLocaleString('tr-TR')}</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.entity_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{log.details ? JSON.stringify(log.details) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(!auditLogs || auditLogs.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-4">Kayıt bulunamadı.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Güvenlik Logları</CardTitle>
|
||||
<CardDescription>
|
||||
Giriş denemeleri ve kimlik doğrulama olayları.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>Olay</TableHead>
|
||||
<TableHead>IP Adresi</TableHead>
|
||||
<TableHead>Detaylar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{securityLogs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{new Date(log.created_at).toLocaleString('tr-TR')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getSecurityBadgeColor(log.event_type) as any}>
|
||||
{formatSecurityEvent(log.event_type)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{log.ip_address}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{log.details ? JSON.stringify(log.details) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(!securityLogs || securityLogs.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-4">Kayıt bulunamadı.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, { full_name: string; role: string }> = {}
|
||||
|
||||
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 <Badge variant="default">Oluşturma</Badge>
|
||||
if (action.includes('update')) return <Badge variant="secondary">Güncelleme</Badge>
|
||||
if (action.includes('delete')) return <Badge variant="destructive">Silme</Badge>
|
||||
if (action.includes('payment')) return <Badge className="bg-green-600">Ödeme</Badge>
|
||||
return <Badge variant="outline">{action}</Badge>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">İşlem Geçmişi</h2>
|
||||
<p className="text-muted-foreground">Sistem üzerindeki son aktiviteler.</p>
|
||||
</div>
|
||||
<UserFilter users={allUsers || []} />
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Sistem Kayıtları</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Tüm işlem ve güvenlik geçmişini buradan inceleyebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Son İşlemler</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kullanıcı</TableHead>
|
||||
<TableHead>İşlem</TableHead>
|
||||
<TableHead>Detay</TableHead>
|
||||
<TableHead>Tarih</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logsWithProfiles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center h-24 text-muted-foreground">
|
||||
Kayıt bulunamadı.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logsWithProfiles.map((log: AuditLog) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{log.profiles?.full_name?.substring(0, 2).toUpperCase() || 'US'}
|
||||
</div>
|
||||
{log.profiles?.full_name || 'Bilinmeyen Kullanıcı'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getActionBadge(log.action)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatActionText(log.action, log.entity_type)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-muted-foreground text-xs font-mono">
|
||||
{JSON.stringify(log.details)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(log.created_at), 'd MMM yyyy HH:mm', { locale: tr })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<LogTabs auditLogs={auditLogs || []} securityLogs={authLogs || []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
86
src/components/emails/otp-template.tsx
Normal file
86
src/components/emails/otp-template.tsx
Normal file
@@ -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) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Giriş Doğrulama Kodunuz: {code}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Giriş Doğrulama</Heading>
|
||||
<Text style={text}>
|
||||
Yönetim paneline giriş yapmak için aşağıdaki doğrulama kodunu kullanın:
|
||||
</Text>
|
||||
<Section style={codeContainer}>
|
||||
<Text style={codeText}>{code}</Text>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
Bu kod 5 dakika süreyle geçerlidir. Eğer giriş yapmaya çalışan siz değilseniz, bu e-postayı dikkate almayınız.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
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;
|
||||
79
src/components/emails/reservation-cancelled-template.tsx
Normal file
79
src/components/emails/reservation-cancelled-template.tsx
Normal file
@@ -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) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Rezervasyon İptal Bilgilendirmesi</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Rezervasyon İptali</Heading>
|
||||
<Text style={text}>Sayın {customerName},</Text>
|
||||
<Text style={text}>
|
||||
{new Date(weddingDate).toLocaleDateString('tr-TR')} tarihindeki {hallName} rezervasyonunuz iptal edilmiştir.
|
||||
</Text>
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
Ödeme iadesi süreçleri hakkında bilgi almak için lütfen bizimle iletişime geçiniz.
|
||||
</Text>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
İyi günler dileriz.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
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;
|
||||
86
src/components/emails/reservation-created-template.tsx
Normal file
86
src/components/emails/reservation-created-template.tsx
Normal file
@@ -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) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Rezervasyonunuz Alındı!</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Düğün Salonu Rezervasyon Onayı</Heading>
|
||||
<Text style={text}>Sayın {customerName},</Text>
|
||||
<Text style={text}>
|
||||
Rezervasyonunuz başarıyla oluşturulmuştur. Detaylar aşağıdadır:
|
||||
</Text>
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>Tarih:</strong> {new Date(weddingDate).toLocaleDateString('tr-TR')}
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
<strong>Salon:</strong> {hallName}
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
<strong>Toplam Tutar:</strong> {totalPrice.toLocaleString('tr-TR', { style: 'currency', currency: 'TRY' })}
|
||||
</Text>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
Bizi tercih ettiğiniz için teşekkür ederiz.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
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;
|
||||
90
src/components/ui/captcha.tsx
Normal file
90
src/components/ui/captcha.tsx
Normal file
@@ -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<CaptchaRef, CaptchaProps>(({ onVerify }, ref) => {
|
||||
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(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<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInput(value)
|
||||
if (captcha) {
|
||||
onVerify(captcha.hash, value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Güvenlik Kodu</Label>
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="relative w-[200px] h-[80px]">
|
||||
<div
|
||||
className="border rounded-md overflow-hidden bg-gray-100 w-full h-full flex items-center justify-center select-none"
|
||||
dangerouslySetInnerHTML={{ __html: captcha?.image || '' }}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100/50">
|
||||
<Loader2 className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={fetchCaptcha}
|
||||
title="Kodu Yenile"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Resimdeki kodu giriniz"
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
maxLength={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Captcha.displayName = "Captcha"
|
||||
91
src/lib/captcha.ts
Normal file
91
src/lib/captcha.ts
Normal file
@@ -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 += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${fg}" stroke-width="1" opacity="0.3" />`
|
||||
}
|
||||
|
||||
// Random noise dots
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const x = Math.random() * width
|
||||
const y = Math.random() * height
|
||||
noise += `<circle cx="${x}" cy="${y}" r="1" fill="${fg}" opacity="0.5" />`
|
||||
}
|
||||
|
||||
// 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 += `<text x="${x}" y="${y}" font-family="monospace" font-weight="bold" font-size="${fontSize}" fill="${fg}" transform="rotate(${rotate}, ${x}, ${y})">${char}</text>`
|
||||
}
|
||||
|
||||
const svg = `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="${bg}"/>
|
||||
${noise}
|
||||
${svgText}
|
||||
</svg>`
|
||||
|
||||
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
|
||||
}
|
||||
32
src/lib/email.ts
Normal file
32
src/lib/email.ts
Normal file
@@ -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 <onboarding@resend.dev>',
|
||||
to,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
});
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
console.error('Email sending failed:', error);
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
133
src/lib/security.ts
Normal file
133
src/lib/security.ts
Normal file
@@ -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<string, any> = {}
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
supabase/migrations/20251230_add_master_otp.sql
Normal file
12
supabase/migrations/20251230_add_master_otp.sql
Normal file
@@ -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';
|
||||
19
supabase/migrations/20251230_create_auth_codes.sql
Normal file
19
supabase/migrations/20251230_create_auth_codes.sql
Normal file
@@ -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);
|
||||
34
supabase/migrations/20251230_create_security_tables.sql
Normal file
34
supabase/migrations/20251230_create_security_tables.sql
Normal file
@@ -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);
|
||||
21
supabase/migrations/20251230_fix_auth_logs_policy.sql
Normal file
21
supabase/migrations/20251230_fix_auth_logs_policy.sql
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user