From 6d95e27114f010b0ffbcba59bfcc25b9939bd7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beyhan=20O=C4=9Fur?= Date: Sun, 26 Apr 2026 22:16:43 +0300 Subject: [PATCH] first commit --- .dockerignore | 9 + .gitignore | 41 + .idea/.gitignore | 10 + .idea/copilot.data.migration.agent.xml | 6 + .idea/copilot.data.migration.ask.xml | 6 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/copilot.data.migration.edit.xml | 6 + .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/modules.xml | 8 + .idea/next-go-blog.iml | 8 + .idea/vcs.xml | 6 + .vscode/settings.json | 3 + AGENTS.md | 171 + BACKEND_ENDPOINT.mb | 223 + CORS_API_DOCUMENTATION.md | 620 +++ Dockerfile | 70 + EMAIL_VERIFICATION.md | 236 + GEMINI.md | 171 + HARD_DELETE_GUIDE.md | 191 + Login.txt | 35 + README.md | 36 + Register.txt | 35 + SOFT_DELETE_MANAGEMENT.md | 414 ++ USER_PROFILE_API.md | 586 +++ app/(admin)/admin/cors/page.tsx | 178 + app/(admin)/admin/page.tsx | 61 + app/(admin)/admin/users/page.tsx | 223 + app/(admin)/layout.tsx | 83 + app/(auth)/layout.tsx | 11 + app/(auth)/login/page.tsx | 135 + app/(auth)/register/page.tsx | 161 + app/(dashboard)/layout.tsx | 13 + app/(dashboard)/profile/page.tsx | 309 ++ app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 125 + app/layout.tsx | 37 + app/page.tsx | 65 + components.json | 23 + components/StoreProvider.tsx | 21 + components/app-sidebar.tsx | 106 + components/cors/cors-dialog.tsx | 107 + components/cors/cors-edit-dialog.tsx | 99 + components/cors/cors-table.tsx | 93 + components/header/header.tsx | 172 + components/theme-provider.tsx | 11 + components/ui/avatar.tsx | 109 + components/ui/badge.tsx | 36 + components/ui/breadcrumb.tsx | 115 + components/ui/button.tsx | 64 + components/ui/card.tsx | 92 + components/ui/dialog.tsx | 158 + components/ui/dropdown-menu.tsx | 257 ++ components/ui/input.tsx | 21 + components/ui/label.tsx | 24 + components/ui/select.tsx | 190 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 143 + components/ui/sidebar.tsx | 726 +++ components/ui/skeleton.tsx | 13 + components/ui/switch.tsx | 29 + components/ui/table.tsx | 116 + components/ui/tooltip.tsx | 61 + components/users/deleted-user-table.tsx | 90 + components/users/user-dialog.tsx | 190 + components/users/user-table.tsx | 99 + docker-compose.yml | 27 + docs/API_ENDPOINTS.md | 590 +++ docs/BACKEND_ENDPOINT.mb | 119 + docs/BACKEND_URLS.md | 237 + docs/CHANGELOG.md | 119 + docs/DEPLOYMENT.md | 406 ++ docs/QUICKSTART.txt | 166 + docs/QUICK_REFERENCE.md | 267 ++ docs/SETTINGS_API.md | 558 +++ docs/SETUP.md | 328 ++ docs/api_backend.txt | 9 + eslint.config.mjs | 18 + hooks/use-mobile.ts | 19 + lib/api/fetchClient.ts | 109 + lib/features/auth/authSlice.ts | 300 ++ lib/features/cors/corsSlice.ts | 215 + lib/features/users/usersSlice.ts | 256 ++ lib/hooks.ts | 8 + lib/schemas/login.ts | 8 + lib/schemas/register.ts | 13 + lib/store.ts | 20 + lib/utils.ts | 15 + next.config.ts | 19 + package.json | 42 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + tsconfig.json | 34 + yarn.lock | 4276 ++++++++++++++++++ 97 files changed, 15687 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/copilot.data.migration.agent.xml create mode 100644 .idea/copilot.data.migration.ask.xml create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/copilot.data.migration.edit.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/next-go-blog.iml create mode 100644 .idea/vcs.xml create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 BACKEND_ENDPOINT.mb create mode 100644 CORS_API_DOCUMENTATION.md create mode 100644 Dockerfile create mode 100644 EMAIL_VERIFICATION.md create mode 100644 GEMINI.md create mode 100644 HARD_DELETE_GUIDE.md create mode 100644 Login.txt create mode 100644 README.md create mode 100644 Register.txt create mode 100644 SOFT_DELETE_MANAGEMENT.md create mode 100644 USER_PROFILE_API.md create mode 100644 app/(admin)/admin/cors/page.tsx create mode 100644 app/(admin)/admin/page.tsx create mode 100644 app/(admin)/admin/users/page.tsx create mode 100644 app/(admin)/layout.tsx create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/register/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/profile/page.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/StoreProvider.tsx create mode 100644 components/app-sidebar.tsx create mode 100644 components/cors/cors-dialog.tsx create mode 100644 components/cors/cors-edit-dialog.tsx create mode 100644 components/cors/cors-table.tsx create mode 100644 components/header/header.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/users/deleted-user-table.tsx create mode 100644 components/users/user-dialog.tsx create mode 100644 components/users/user-table.tsx create mode 100644 docker-compose.yml create mode 100644 docs/API_ENDPOINTS.md create mode 100644 docs/BACKEND_ENDPOINT.mb create mode 100644 docs/BACKEND_URLS.md create mode 100644 docs/CHANGELOG.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/QUICKSTART.txt create mode 100644 docs/QUICK_REFERENCE.md create mode 100644 docs/SETTINGS_API.md create mode 100644 docs/SETUP.md create mode 100644 docs/api_backend.txt create mode 100644 eslint.config.mjs create mode 100644 hooks/use-mobile.ts create mode 100644 lib/api/fetchClient.ts create mode 100644 lib/features/auth/authSlice.ts create mode 100644 lib/features/cors/corsSlice.ts create mode 100644 lib/features/users/usersSlice.ts create mode 100644 lib/hooks.ts create mode 100644 lib/schemas/login.ts create mode 100644 lib/schemas/register.ts create mode 100644 lib/store.ts create mode 100644 lib/utils.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..221466c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +.env +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..06112c4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/next-go-blog.iml b/.idea/next-go-blog.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/next-go-blog.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..13ee2b0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "nuxt.isNuxtApp": false +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38a79cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,171 @@ +# Genimi (AI) Kuralları +- **Dil:** Her zaman (kod blokları hariç) Türkçe cevap vermelisin. + +# Go Backend Guidelines — JWT + OAuth + RBAC & Security +# Go Next Js Guidelines +Bu doküman Next frontend'e hizmet edecek Go (backend) uygulaması için güncel proje ve mimari rehberidir. + +## Proje Backend Durumu ve Özellikler + +- **Go Sürümü:** 1.25.6 +- **Veritabanı:** PostgreSQL +- **Web Framework:** Gin +- **Mimari:** RESTful API + +## Proje Frontend Durumu ve Özellikler + +- **Next js:** 16.1.6 +- **Next-auth":** 4.24.13 +- **Radix-ui":** 1.4.3 +- **Shadcn/ui":** Latest +- **Sweetalert2":** 11.26.18 +- **Ioredis":** 5.9.2 +- **Redux":** 1.4.3 +- **Redux Toolkit":** 1.4.3 +- **Zod":** 4.3.6 +- **Tailwindcss":** 4 +- **Tailwind-merge":** 3.4.0 +- **Lucide-react":** 0.563.0 +- **Tw-animate-css":** 1.4.0 +- **server side rendering:** true + +## API Uç Noktaları + +-- Register Yeni Kullanıcı +POST +http://localhost:8080/v1/auth/register +-- Gönderrilen JSON +{ + "email":"beyhanod@beyhan.dev", + "password":"1923btO**", + "username":"test yaptim" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMDVlZTc0Zi1hYjgzLTQxNzEtYjI3Ny1mZGM0NDZhNjA3YjciLCJlbWFpbCI6ImJleHlzc2hhbm9kQGJleWhhbi5kZXYiLCJwZXJtaXNzaW9ucyI6WyJ1c2VyOnJlYWQiXSwiaXNzIjoiZ2F1dGgtY2VudHJhbCIsImV4cCI6MTc3MDEzMDQ2OCwiaWF0IjoxNzcwMTI5NTY4fQ.Qc5EnE2r-In7hm6-NjP6WX2TKm3MyuM68SwsHYUNJbI", + "email": "bexysshanod@beyhan.dev", + "message": "User created successfully", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMDVlZTc0Zi1hYjgzLTQxNzEtYjI3Ny1mZGM0NDZhNjA3YjciLCJlbWFpbCI6ImJleHlzc2hhbm9kQGJleWhhbi5kZXYiLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwNzM0MzY4LCJpYXQiOjE3NzAxMjk1Njh9.JE2UZ6jJti2N2jbExx_TTY5VPSfXKvc2ZGB-Nw_toLQ", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "f05ee74f-ab83-4171-b277-fdc446a607b7", + "username": "test yaptim" +} + +-- Login Yeni Kullanıcı +POST +http://localhost:8080/v1/auth/login +-- Gönderrilen JSON +{ + "email":"beyhanod@beyhan.dev", + "password":"1923btO**" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCJdLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwMTMwNjU3LCJpYXQiOjE3NzAxMjk3NTd9.QbsRFn5fr7L4Wc7HCxOs0_zOWWhuceWzPmt20TV5lNI", + "email": "beyhano@beyhan.dev", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzQ1NTcsImlhdCI6MTc3MDEyOTc1N30.wBML1pT9S9i9FtAw3PKmJBMdcobZexWVBTRV5remb_s", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "91cf0868-df24-4b8f-b491-70d9eb7a4373", + "username": "user_91cf0868" +} + +-- Refresh Token +POST +http://localhost:8080/v1/auth/refresh +-- Gönderilen JSON +{ + "refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzQ2NDUsImlhdCI6MTc3MDEyOTg0NX0.ACDDM20v1u6yjyNrqBnWafjXnrRAAT1-8CvfqSkjTsE" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCJdLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwMTMxMjYwLCJpYXQiOjE3NzAxMzAzNjB9.BKmZBkL6FPo208mYLeBFMkNOqJ2tsmGXJUN_0bdZFHQ", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzUxNjAsImlhdCI6MTc3MDEzMDM2MH0.tkpcbQ6QmVVXK-r0QgP333X_FrAktVOuh1AJhwvV1BQ" +} + +-- Me (Kullanıcı Profili) +GET +http://localhost:8080/v1/auth/me +-- Header +Authorization: Bearer ACCESS_TOKEN_BURAYA +-- Body: YOK (GET isteği) +-- Cevap +{ + "id": "91cf0868-df24-4b8f-b491-70d9eb7a4373", + "username": "user_91cf0868", + "email": "beyhano@beyhan.dev", + "created_at": "2026-02-03T17:03:07.863425+03:00", + "updated_at": "2026-02-03T17:03:07.880923+03:00", + "social_accounts": null, + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ] +} + +-- Validate Token (Token Doğrulama) +GET +http://localhost:8080/v1/auth/validate +-- Header +Authorization: Bearer ACCESS_TOKEN_BURAYA +-- Body: YOK (GET isteği, body gönderilmez) +-- Cevap +{ + "email": "beyhano@beyhan.dev", + "message": "Token is valid", + "user_id": "91cf0868-df24-4b8f-b491-70d9eb7a4373" +} +## Kurulum ve Çalıştırma + +1. `.env.example` dosyasını `.env` olarak kopyalayın ve değerleri düzenleyin. +2. Docker ile veritabanını ve uygulamayı başlatmak için: + ```bash + ./scripts/run.sh + ``` + _(Bu script Docker konteynerini kontrol eder, yoksa başlatır ve ardından Go sunucusunu çalıştırır)_ + +## Geliştirme Notları + +- **Env Değişkenleri:** + - `RATE_LIMIT_BLACKLIST_IPS=...`, `RATE_LIMIT_BLACKLIST_DOMAINS=...` ile yasaklama. + - `RATE_LIMIT_WHITELIST=...`, `RATE_LIMIT_WHITELIST_DOMAINS=...` ile muafiyet. + +- **Migration Ekleme:** `internal/db/migrations` klasörüne `00000X_description.up.sql` formatında yeni dosya ekleyin. +- **Admin Kullanıcısı:** Sistem ilk açılışta `admin@example.com` / `admin123` kullanıcısını otomatik oluşturur. +- **Docker Volume:** `/uploads` klasörü `uploads_data` volume'ü ile kalıcı hale getirilmiştir. +- **Image Processing:** WebP desteği için Dockerfile `CGO_ENABLED=1` ve `build-base` içerir. diff --git a/BACKEND_ENDPOINT.mb b/BACKEND_ENDPOINT.mb new file mode 100644 index 0000000..7a20854 --- /dev/null +++ b/BACKEND_ENDPOINT.mb @@ -0,0 +1,223 @@ +-- Register Yeni Kullanıcı +POST +http://localhost:8080/v1/auth/register +-- Gönderrilen JSON +{ + "email":"beyhanod@beyhan.dev", + "password":"1923btO**", + "username":"test yaptim" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMDVlZTc0Zi1hYjgzLTQxNzEtYjI3Ny1mZGM0NDZhNjA3YjciLCJlbWFpbCI6ImJleHlzc2hhbm9kQGJleWhhbi5kZXYiLCJwZXJtaXNzaW9ucyI6WyJ1c2VyOnJlYWQiXSwiaXNzIjoiZ2F1dGgtY2VudHJhbCIsImV4cCI6MTc3MDEzMDQ2OCwiaWF0IjoxNzcwMTI5NTY4fQ.Qc5EnE2r-In7hm6-NjP6WX2TKm3MyuM68SwsHYUNJbI", + "email": "bexysshanod@beyhan.dev", + "message": "User created successfully", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMDVlZTc0Zi1hYjgzLTQxNzEtYjI3Ny1mZGM0NDZhNjA3YjciLCJlbWFpbCI6ImJleHlzc2hhbm9kQGJleWhhbi5kZXYiLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwNzM0MzY4LCJpYXQiOjE3NzAxMjk1Njh9.JE2UZ6jJti2N2jbExx_TTY5VPSfXKvc2ZGB-Nw_toLQ", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "f05ee74f-ab83-4171-b277-fdc446a607b7", + "username": "test yaptim" +} + +-- Login Yeni Kullanıcı +POST +http://localhost:8080/v1/auth/login +-- Gönderrilen JSON +{ + "email":"beyhanod@beyhan.dev", + "password":"1923btO**" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCJdLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwMTMwNjU3LCJpYXQiOjE3NzAxMjk3NTd9.QbsRFn5fr7L4Wc7HCxOs0_zOWWhuceWzPmt20TV5lNI", + "email": "beyhano@beyhan.dev", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzQ1NTcsImlhdCI6MTc3MDEyOTc1N30.wBML1pT9S9i9FtAw3PKmJBMdcobZexWVBTRV5remb_s", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "91cf0868-df24-4b8f-b491-70d9eb7a4373", + "username": "user_91cf0868" +} + +-- Refresh Token +POST +http://localhost:8080/v1/auth/refresh +-- Gönderilen JSON +{ + "refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzQ2NDUsImlhdCI6MTc3MDEyOTg0NX0.ACDDM20v1u6yjyNrqBnWafjXnrRAAT1-8CvfqSkjTsE" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCJdLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwMTMxMjYwLCJpYXQiOjE3NzAxMzAzNjB9.BKmZBkL6FPo208mYLeBFMkNOqJ2tsmGXJUN_0bdZFHQ", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzUxNjAsImlhdCI6MTc3MDEzMDM2MH0.tkpcbQ6QmVVXK-r0QgP333X_FrAktVOuh1AJhwvV1BQ" +} + +-- Me (Kullanıcı Profili) +GET +http://localhost:8080/v1/auth/me +-- Header +Authorization: Bearer ACCESS_TOKEN_BURAYA +-- Body: YOK (GET isteği) +-- Cevap +{ + "id": "91cf0868-df24-4b8f-b491-70d9eb7a4373", + "username": "user_91cf0868", + "email": "beyhano@beyhan.dev", + "created_at": "2026-02-03T17:03:07.863425+03:00", + "updated_at": "2026-02-03T17:03:07.880923+03:00", + "social_accounts": null, + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ] +} + +-- Validate Token (Token Doğrulama) +GET +http://localhost:8080/v1/auth/validate +-- Header +Authorization: Bearer ACCESS_TOKEN_BURAYA +-- Body: YOK (GET isteği, body gönderilmez) +-- Cevap +{ + "email": "beyhano@beyhan.dev", + "message": "Token is valid", + "user_id": "91cf0868-df24-4b8f-b491-70d9eb7a4373" +} + + +-- userlist admin +{ + "pagination": { + "limit": 10, + "page": 1, + "total": 5, + "totalPages": 1 + }, + "users": [ + { + "id": "d056c1fd-4f99-42ab-bad9-0f205555a872", + "username": "ares2000@gmail.com", + "email": "zxczxcc@sdfsdf.com", + "avatar": "/uploads/avatars/d056c1fd-4f99-42ab-bad9-0f205555a872_1770180464146441000.png", + "created_at": "2026-02-04T07:47:44.100874+03:00", + "updated_at": "2026-02-04T07:47:44.148572+03:00", + "email_verified": true, + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": null + } + ] + }, + { + "id": "fbfdfb16-3ffa-423a-9914-b1624aab2ddb", + "username": "adsssssmin@demo.com", + "email": "xxxxxadmin@demo.com", + "avatar": "/uploads/avatars/fbfdfb16-3ffa-423a-9914-b1624aab2ddb_1770179925659471000.png", + "created_at": "2026-02-04T07:38:45.618572+03:00", + "updated_at": "2026-02-04T07:44:54.318372+03:00", + "email_verified": true, + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": null + } + ] + }, + { + "id": "4f177189-7c96-48cd-825d-4e9a5a76421a", + "username": "admin@demo.com", + "email": "admin@demo.com", + "avatar": "/uploads/avatars/4f177189-7c96-48cd-825d-4e9a5a76421a_1770180623694066000.png", + "created_at": "2026-02-04T07:37:17.716058+03:00", + "updated_at": "2026-02-04T07:50:23.696197+03:00", + "email_verified": true, + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": null + } + ] + }, + { + "id": "4971553b-99da-432c-99a8-28ef172cb139", + "username": "Beyhan Oğur", + "email": "beyhan@beyhan.dev", + "avatar": "/uploads/avatars/4971553b-99da-432c-99a8-28ef172cb139_1770181055825103000.png", + "created_at": "2026-02-04T06:05:17.334092+03:00", + "updated_at": "2026-02-04T07:57:35.826692+03:00", + "email_verified": true, + "roles": [ + { + "id": 1, + "name": "admin", + "description": "Default admin role", + "permissions": null + } + ] + }, + { + "id": "2503e3d9-3ff5-4e4a-97af-b23ac37f37db", + "username": "admin", + "email": "admin@gauth.local", + "avatar": "/uploads/avatars/2503e3d9-3ff5-4e4a-97af-b23ac37f37db_1770181222075819000.png", + "created_at": "2026-02-04T06:04:40.83783+03:00", + "updated_at": "2026-02-04T08:00:22.077544+03:00", + "email_verified": true, + "roles": [ + { + "id": 1, + "name": "admin", + "description": "Default admin role", + "permissions": null + }, + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": null + } + ] + } + ] +} \ No newline at end of file diff --git a/CORS_API_DOCUMENTATION.md b/CORS_API_DOCUMENTATION.md new file mode 100644 index 0000000..ab53065 --- /dev/null +++ b/CORS_API_DOCUMENTATION.md @@ -0,0 +1,620 @@ +# CORS Whitelist & Blacklist API Dokümantasyonu + +## 📋 Genel Bakış + +AuthCentral'da CORS (Cross-Origin Resource Sharing) yönetimi için Whitelist ve Blacklist sistemleri mevcuttur. + +### Özellikler: +- ✅ CRUD operasyonları (Create, Read, Update, Delete) +- ✅ Redis cache desteği +- ✅ Admin only endpoints +- ✅ Active/Inactive durumları +- ✅ Audit trail (created_by, updated_by) + +--- + +## 🔐 Authentication + +Tüm endpoint'ler **Admin** yetkisi gerektirir: +``` +Authorization: Bearer {admin_access_token} +``` + +--- + +## 📡 CORS Whitelist API + +### 1. Tüm Whitelist Kayıtlarını Listele + +```http +GET /v1/settings/cors/whitelist +``` + +**Headers:** +``` +Authorization: Bearer {token} +``` + +**Yanıt:** +```json +[ + { + "id": "uuid", + "origin": "http://localhost:3000", + "description": "Development frontend", + "is_active": true, + "created_by": "admin@gauth.local", + "created_at": "2026-02-05T10:00:00Z", + "updated_at": "2026-02-05T10:00:00Z" + }, + { + "id": "uuid", + "origin": "https://myapp.com", + "description": "Production frontend", + "is_active": true, + "created_by": "admin@gauth.local", + "created_at": "2026-02-05T09:00:00Z", + "updated_at": "2026-02-05T09:00:00Z" + } +] +``` + +### 2. Whitelist Kaydı Oluştur + +```http +POST /v1/settings/cors/whitelist +``` + +**Headers:** +``` +Authorization: Bearer {token} +Content-Type: application/json +``` + +**Body:** +```json +{ + "origin": "https://example.com", + "description": "Customer frontend application" +} +``` + +**Başarılı Yanıt (201):** +```json +{ + "id": "new-uuid", + "origin": "https://example.com", + "description": "Customer frontend application", + "is_active": true, + "created_by": "admin@gauth.local", + "created_at": "2026-02-05T11:00:00Z", + "updated_at": "2026-02-05T11:00:00Z" +} +``` + +### 3. Whitelist Kaydını Güncelle + +```http +PUT /v1/settings/cors/whitelist/{id} +``` + +**Headers:** +``` +Authorization: Bearer {token} +Content-Type: application/json +``` + +**Body (tüm alanlar opsiyonel):** +```json +{ + "origin": "https://new-domain.com", + "description": "Updated description", + "is_active": false +} +``` + +**Başarılı Yanıt (200):** +```json +{ + "message": "Whitelist updated successfully" +} +``` + +### 4. Whitelist Kaydını Sil + +```http +DELETE /v1/settings/cors/whitelist/{id} +``` + +**Headers:** +``` +Authorization: Bearer {token} +``` + +**Başarılı Yanıt (200):** +```json +{ + "message": "Whitelist entry deleted successfully" +} +``` + +--- + +## 🚫 CORS Blacklist API + +### 1. Tüm Blacklist Kayıtlarını Listele + +```http +GET /v1/settings/cors/blacklist +``` + +**Headers:** +``` +Authorization: Bearer {token} +``` + +**Yanıt:** +```json +[ + { + "id": "uuid", + "origin": "https://malicious-site.com", + "reason": "Security threat detected", + "is_active": true, + "created_by": "admin@gauth.local", + "created_at": "2026-02-05T10:00:00Z", + "updated_at": "2026-02-05T10:00:00Z" + } +] +``` + +### 2. Blacklist Kaydı Oluştur + +```http +POST /v1/settings/cors/blacklist +``` + +**Headers:** +``` +Authorization: Bearer {token} +Content-Type: application/json +``` + +**Body:** +```json +{ + "origin": "https://spam-domain.com", + "reason": "Spam attempts detected" +} +``` + +**Başarılı Yanıt (201):** +```json +{ + "id": "new-uuid", + "origin": "https://spam-domain.com", + "reason": "Spam attempts detected", + "is_active": true, + "created_by": "admin@gauth.local", + "created_at": "2026-02-05T11:00:00Z", + "updated_at": "2026-02-05T11:00:00Z" +} +``` + +### 3. Blacklist Kaydını Güncelle + +```http +PUT /v1/settings/cors/blacklist/{id} +``` + +**Headers:** +``` +Authorization: Bearer {token} +Content-Type: application/json +``` + +**Body (tüm alanlar opsiyonel):** +```json +{ + "origin": "https://updated-domain.com", + "reason": "Updated reason", + "is_active": false +} +``` + +**Başarılı Yanıt (200):** +```json +{ + "message": "Blacklist updated successfully" +} +``` + +### 4. Blacklist Kaydını Sil + +```http +DELETE /v1/settings/cors/blacklist/{id} +``` + +**Headers:** +``` +Authorization: Bearer {token} +``` + +**Başarılı Yanıt (200):** +```json +{ + "message": "Blacklist entry deleted successfully" +} +``` + +--- + +## 🧪 Kullanım Örnekleri + +### Tam İş Akışı (cURL) + +```bash +#!/bin/bash + +# 1. Admin Login +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +echo "Token: ${TOKEN:0:30}..." + +# 2. Whitelist'e domain ekle +echo -e "\n=== Create Whitelist Entry ===" +curl -X POST http://localhost:8080/v1/settings/cors/whitelist \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "origin": "https://myapp.com", + "description": "Production app" + }' | jq . + +# 3. Tüm whitelist'i listele +echo -e "\n=== List All Whitelist ===" +curl -X GET http://localhost:8080/v1/settings/cors/whitelist \ + -H "Authorization: Bearer $TOKEN" | jq . + +# 4. Blacklist'e domain ekle +echo -e "\n=== Create Blacklist Entry ===" +curl -X POST http://localhost:8080/v1/settings/cors/blacklist \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "origin": "https://spam.com", + "reason": "Spam detected" + }' | jq . + +# 5. Whitelist entry'yi güncelle (ID'yi yukarıdaki response'dan alın) +WHITELIST_ID="your-whitelist-id-here" +echo -e "\n=== Update Whitelist Entry ===" +curl -X PUT "http://localhost:8080/v1/settings/cors/whitelist/$WHITELIST_ID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "description": "Updated description", + "is_active": true + }' | jq . + +# 6. Blacklist entry'yi sil +BLACKLIST_ID="your-blacklist-id-here" +echo -e "\n=== Delete Blacklist Entry ===" +curl -X DELETE "http://localhost:8080/v1/settings/cors/blacklist/$BLACKLIST_ID" \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +--- + +## 🎯 Frontend Entegrasyonu (React) + +### API Client + +```javascript +class CorsSettingsAPI { + constructor(baseURL, token) { + this.baseURL = baseURL; + this.token = token; + } + + // ====== Whitelist ====== + + async getWhitelist() { + const response = await fetch(`${this.baseURL}/v1/settings/cors/whitelist`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + return response.json(); + } + + async createWhitelist(origin, description) { + const response = await fetch(`${this.baseURL}/v1/settings/cors/whitelist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ origin, description }) + }); + return response.json(); + } + + async updateWhitelist(id, updates) { + const response = await fetch(`${this.baseURL}/v1/settings/cors/whitelist/${id}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + return response.json(); + } + + async deleteWhitelist(id) { + const response = await fetch(`${this.baseURL}/v1/settings/cors/whitelist/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + return response.json(); + } + + // ====== Blacklist ====== + + async getBlacklist() { + const response = await fetch(`${this.baseURL}/v1/settings/cors/blacklist`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + return response.json(); + } + + async createBlacklist(origin, reason) { + const response = await fetch(`${this.baseURL}/v1/settings/cors/blacklist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ origin, reason }) + }); + return response.json(); + } + + async updateBlacklist(id, updates) { + const response = await fetch(`${this.baseURL}/v1/settings/cors/blacklist/${id}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + return response.json(); + } + + async deleteBlacklist(id) { + const response = await fetch(`${this.baseURL}/v1/settings/cors/blacklist/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + return response.json(); + } +} + +// Kullanım +const api = new CorsSettingsAPI('http://localhost:8080', YOUR_ADMIN_TOKEN); + +// Whitelist listele +const whitelists = await api.getWhitelist(); +console.log(whitelists); + +// Yeni whitelist ekle +const newEntry = await api.createWhitelist('https://newapp.com', 'New application'); +console.log(newEntry); +``` + +### React Component Örneği + +```jsx +import React, { useState, useEffect } from 'react'; + +function CorsWhitelistManager() { + const [whitelists, setWhitelists] = useState([]); + const [newOrigin, setNewOrigin] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const token = localStorage.getItem('admin_token'); + + useEffect(() => { + fetchWhitelists(); + }, []); + + const fetchWhitelists = async () => { + try { + const response = await fetch('http://localhost:8080/v1/settings/cors/whitelist', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + const data = await response.json(); + setWhitelists(data); + } catch (error) { + console.error('Error fetching whitelists:', error); + } + }; + + const handleCreate = async (e) => { + e.preventDefault(); + try { + const response = await fetch('http://localhost:8080/v1/settings/cors/whitelist', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + origin: newOrigin, + description: newDescription + }) + }); + + if (response.ok) { + setNewOrigin(''); + setNewDescription(''); + fetchWhitelists(); // Refresh list + alert('Whitelist entry created!'); + } + } catch (error) { + console.error('Error creating whitelist:', error); + } + }; + + const handleDelete = async (id) => { + if (!confirm('Are you sure you want to delete this entry?')) return; + + try { + const response = await fetch(`http://localhost:8080/v1/settings/cors/whitelist/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + fetchWhitelists(); // Refresh list + alert('Whitelist entry deleted!'); + } + } catch (error) { + console.error('Error deleting whitelist:', error); + } + }; + + const toggleActive = async (id, currentStatus) => { + try { + const response = await fetch(`http://localhost:8080/v1/settings/cors/whitelist/${id}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + is_active: !currentStatus + }) + }); + + if (response.ok) { + fetchWhitelists(); // Refresh list + } + } catch (error) { + console.error('Error updating whitelist:', error); + } + }; + + return ( +
+

CORS Whitelist Manager

+ + {/* Add New Entry Form */} +
+ setNewOrigin(e.target.value)} + required + /> + setNewDescription(e.target.value)} + /> + +
+ + {/* Whitelist Table */} + + + + + + + + + + + + {whitelists.map(entry => ( + + + + + + + + ))} + +
OriginDescriptionActiveCreated ByActions
{entry.origin}{entry.description} + + {entry.created_by} + +
+
+ ); +} + +export default CorsWhitelistManager; +``` + +--- + +## 📊 API Endpoints Özeti + +| Endpoint | Method | Açıklama | Admin Required | +|----------|--------|----------|----------------| +| `/v1/settings/cors/whitelist` | GET | Whitelist listele | ✅ | +| `/v1/settings/cors/whitelist` | POST | Whitelist ekle | ✅ | +| `/v1/settings/cors/whitelist/:id` | PUT | Whitelist güncelle | ✅ | +| `/v1/settings/cors/whitelist/:id` | DELETE | Whitelist sil | ✅ | +| `/v1/settings/cors/blacklist` | GET | Blacklist listele | ✅ | +| `/v1/settings/cors/blacklist` | POST | Blacklist ekle | ✅ | +| `/v1/settings/cors/blacklist/:id` | PUT | Blacklist güncelle | ✅ | +| `/v1/settings/cors/blacklist/:id` | DELETE | Blacklist sil | ✅ | + +--- + +## 🔧 Cache Yönetimi + +CORS ayarları Redis'te cache'lenir: +- **Cache süresi:** 1 saat +- **Otomatik invalidation:** Create/Update/Delete işlemlerinde +- **Cache key:** + - Whitelist: `cors:whitelist` + - Blacklist: `cors:blacklist` + +--- + +## ✅ Test Checklist + +- [ ] Admin token alındı +- [ ] Whitelist ekleme testi +- [ ] Whitelist listeleme testi +- [ ] Whitelist güncelleme testi +- [ ] Whitelist silme testi +- [ ] Blacklist ekleme testi +- [ ] Blacklist listeleme testi +- [ ] Blacklist güncelleme testi +- [ ] Blacklist silme testi +- [ ] Swagger dokümantasyonu kontrol edildi + +**CORS Whitelist & Blacklist API'leri tam çalışır durumda!** 🚀 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0ddd0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# Base image +FROM node:23-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +ARG NEXT_PUBLIC_API_BASE +ENV NEXT_PUBLIC_API_BASE=$NEXT_PUBLIC_API_BASE + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 + +# User and Group creation +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 + +# Set hostname to localhost by default, but allow overwriting. +# For Docker, 0.0.0.0 is needed to accept connections from outside the container +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] diff --git a/EMAIL_VERIFICATION.md b/EMAIL_VERIFICATION.md new file mode 100644 index 0000000..0fdf518 --- /dev/null +++ b/EMAIL_VERIFICATION.md @@ -0,0 +1,236 @@ +# Email Doğrulama Sistemi + +## Genel Bakış + +AuthCentral'da kullanıcılar iki şekilde kayıt olabilir: + +1. **Email/Password ile Kayıt**: Email doğrulaması gerektirir +2. **OAuth (Google/GitHub)**: Otomatik olarak doğrulanmış kabul edilir + +## Email/Password ile Kayıt Akışı + +### 1. Kullanıcı Kaydı +```bash +POST /v1/auth/register +{ + "username": "johndoe", + "email": "john@example.com", + "password": "securepass123" +} +``` + +**Yanıt:** +```json +{ + "message": "User created. Please verify your email.", + "user_id": "...", + "username": "johndoe", + "email": "john@example.com", + "email_verified": false, + "verification_token": "..." +} +``` + +**Not:** Kullanıcı oluşturulur ancak `email_verified: false` olarak ayarlanır. + +### 2. Email Doğrulama + +Kullanıcıya otomatik olarak doğrulama email'i gönderilir. Email'deki linke tıklayarak doğrulama yapılır: + +```bash +GET /v1/auth/verify-email?token=VERIFICATION_TOKEN +``` + +**Yanıt:** +```json +{ + "message": "Email verified successfully" +} +``` + +### 3. Login Denemesi (Email Doğrulanmadan) + +Email doğrulanmadan login yapılamaz: + +```bash +POST /v1/auth/login +{ + "email": "john@example.com", + "password": "securepass123" +} +``` + +**Hata Yanıtı:** +```json +{ + "error": "email not verified" +} +``` + +### 4. Login (Email Doğrulandıktan Sonra) + +Email doğrulandıktan sonra başarıyla login yapılabilir: + +```bash +POST /v1/auth/login +{ + "email": "john@example.com", + "password": "securepass123" +} +``` + +**Başarılı Yanıt:** +```json +{ + "access_token": "...", + "refresh_token": "...", + "user_id": "...", + "username": "johndoe", + "email": "john@example.com", + "avatar": "", + "roles": [...] +} +``` + +## OAuth (Google/GitHub) ile Kayıt + +OAuth sağlayıcıları email'i zaten doğruladığı için, bu kullanıcılar otomatik olarak `email_verified: true` olarak kaydedilir. + +```bash +GET /v1/auth/google +GET /v1/auth/github +``` + +OAuth callback'ten sonra kullanıcı otomatik olarak login edilir ve token'lar döndürülür. + +## Veritabanı Yapısı + +### Users Tablosu + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + user_name TEXT NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password TEXT, + avatar VARCHAR(500), + email_verified BOOLEAN DEFAULT false, + email_verify_token TEXT, + email_verified_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP -- Soft delete için +); +``` + +### Email Verification Alanları + +- `email_verified`: Boolean - Email doğrulandı mı? (Email/password için false, OAuth için true) +- `email_verify_token`: String - Doğrulama token'ı (email/password kayıt için) +- `email_verified_at`: Timestamp - Email ne zaman doğrulandı? + +## Admin Kullanıcı Yönetimi + +### Kullanıcı Silme + +#### Soft Delete (Varsayılan) +```bash +DELETE /v1/admin/users/{user_id} +``` + +Kullanıcı `deleted_at` timestamp'i ile işaretlenir, veritabanından silinmez. + +#### Hard Delete (Kalıcı Silme) +```bash +DELETE /v1/admin/users/{user_id}?hard=true +``` + +Kullanıcı ve tüm ilişkili kayıtları (user_roles, social_accounts) kalıcı olarak silinir. + +**Not:** Kendi hesabınızı silemezsiniz. + +## Email Ayarları + +Email gönderimi için `.env` dosyasındaki ayarları yapılandırın: + +```env +# Email Settings (Mailpit - Development) +EMAIL_HOST=212.64.215.243 +EMAIL_PORT=1025 +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_USE_TLS=false +EMAIL_USE_SSL=false +EMAIL_FROM=noreply@gauth.local +``` + +## Güvenlik Notları + +1. **Verification Token**: 32 byte güvenli rastgele token oluşturulur +2. **Token Süresi**: Şu anda token'ların süresi dolmuyor (ileride eklenebilir) +3. **Rate Limiting**: Register endpoint'i için rate limit aktif +4. **Password Hashing**: bcrypt kullanılarak güvenli şekilde hash'lenir + +## Geliştirme Notları + +### Migration + +Email verification özelliği sonradan eklendiği için, mevcut kullanıcılar otomatik olarak `email_verified: true` olarak işaretlenmiştir. Yeni kayıtlar `email_verified: false` ile başlar. + +Migration fonksiyonu `internal/database/db.go` dosyasında devre dışı bırakılmıştır. + +### Model Değişiklikleri + +User model'de `EmailVerified` alanı `*bool` (pointer) olarak tanımlanmıştır. Bu, GORM'un false değerlerini doğru şekilde işlemesini sağlar. + +```go +type User struct { + // ... + EmailVerified *bool `gorm:"default:false" json:"email_verified"` + EmailVerifyToken string `gorm:"index" json:"-"` + EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"` + // ... +} +``` + +## Test Senaryosu + +```bash +# 1. Yeni kullanıcı kaydı +curl -X POST http://localhost:8080/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "email": "test@example.com", + "password": "password123" + }' + +# 2. Email doğrulanmadan login dene (BAŞARISIZ) +curl -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password123" + }' +# Yanıt: {"error": "email not verified"} + +# 3. Email'i doğrula +curl -X GET "http://localhost:8080/v1/auth/verify-email?token=VERIFICATION_TOKEN" + +# 4. Login (BAŞARILI) +curl -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password123" + }' +# Yanıt: access_token, refresh_token, user bilgileri +``` + +## Özet + +✅ Email/password ile kayıt olanlar email doğrulaması yapmalı +✅ Email doğrulanmadan login yapılamaz +✅ OAuth ile giriş yapanlar otomatik doğrulanmış kabul edilir +✅ Soft delete varsayılan, hard delete `?hard=true` ile yapılır +✅ Email doğrulama sistemi tam çalışır durumda diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..38a79cf --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,171 @@ +# Genimi (AI) Kuralları +- **Dil:** Her zaman (kod blokları hariç) Türkçe cevap vermelisin. + +# Go Backend Guidelines — JWT + OAuth + RBAC & Security +# Go Next Js Guidelines +Bu doküman Next frontend'e hizmet edecek Go (backend) uygulaması için güncel proje ve mimari rehberidir. + +## Proje Backend Durumu ve Özellikler + +- **Go Sürümü:** 1.25.6 +- **Veritabanı:** PostgreSQL +- **Web Framework:** Gin +- **Mimari:** RESTful API + +## Proje Frontend Durumu ve Özellikler + +- **Next js:** 16.1.6 +- **Next-auth":** 4.24.13 +- **Radix-ui":** 1.4.3 +- **Shadcn/ui":** Latest +- **Sweetalert2":** 11.26.18 +- **Ioredis":** 5.9.2 +- **Redux":** 1.4.3 +- **Redux Toolkit":** 1.4.3 +- **Zod":** 4.3.6 +- **Tailwindcss":** 4 +- **Tailwind-merge":** 3.4.0 +- **Lucide-react":** 0.563.0 +- **Tw-animate-css":** 1.4.0 +- **server side rendering:** true + +## API Uç Noktaları + +-- Register Yeni Kullanıcı +POST +http://localhost:8080/v1/auth/register +-- Gönderrilen JSON +{ + "email":"beyhanod@beyhan.dev", + "password":"1923btO**", + "username":"test yaptim" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMDVlZTc0Zi1hYjgzLTQxNzEtYjI3Ny1mZGM0NDZhNjA3YjciLCJlbWFpbCI6ImJleHlzc2hhbm9kQGJleWhhbi5kZXYiLCJwZXJtaXNzaW9ucyI6WyJ1c2VyOnJlYWQiXSwiaXNzIjoiZ2F1dGgtY2VudHJhbCIsImV4cCI6MTc3MDEzMDQ2OCwiaWF0IjoxNzcwMTI5NTY4fQ.Qc5EnE2r-In7hm6-NjP6WX2TKm3MyuM68SwsHYUNJbI", + "email": "bexysshanod@beyhan.dev", + "message": "User created successfully", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMDVlZTc0Zi1hYjgzLTQxNzEtYjI3Ny1mZGM0NDZhNjA3YjciLCJlbWFpbCI6ImJleHlzc2hhbm9kQGJleWhhbi5kZXYiLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwNzM0MzY4LCJpYXQiOjE3NzAxMjk1Njh9.JE2UZ6jJti2N2jbExx_TTY5VPSfXKvc2ZGB-Nw_toLQ", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "f05ee74f-ab83-4171-b277-fdc446a607b7", + "username": "test yaptim" +} + +-- Login Yeni Kullanıcı +POST +http://localhost:8080/v1/auth/login +-- Gönderrilen JSON +{ + "email":"beyhanod@beyhan.dev", + "password":"1923btO**" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCJdLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwMTMwNjU3LCJpYXQiOjE3NzAxMjk3NTd9.QbsRFn5fr7L4Wc7HCxOs0_zOWWhuceWzPmt20TV5lNI", + "email": "beyhano@beyhan.dev", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzQ1NTcsImlhdCI6MTc3MDEyOTc1N30.wBML1pT9S9i9FtAw3PKmJBMdcobZexWVBTRV5remb_s", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "91cf0868-df24-4b8f-b491-70d9eb7a4373", + "username": "user_91cf0868" +} + +-- Refresh Token +POST +http://localhost:8080/v1/auth/refresh +-- Gönderilen JSON +{ + "refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzQ2NDUsImlhdCI6MTc3MDEyOTg0NX0.ACDDM20v1u6yjyNrqBnWafjXnrRAAT1-8CvfqSkjTsE" +} +-- Cevap +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCJdLCJpc3MiOiJnYXV0aC1jZW50cmFsIiwiZXhwIjoxNzcwMTMxMjYwLCJpYXQiOjE3NzAxMzAzNjB9.BKmZBkL6FPo208mYLeBFMkNOqJ2tsmGXJUN_0bdZFHQ", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MWNmMDg2OC1kZjI0LTRiOGYtYjQ5MS03MGQ5ZWI3YTQzNzMiLCJlbWFpbCI6ImJleWhhbm9AYmV5aGFuLmRldiIsImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzA3MzUxNjAsImlhdCI6MTc3MDEzMDM2MH0.tkpcbQ6QmVVXK-r0QgP333X_FrAktVOuh1AJhwvV1BQ" +} + +-- Me (Kullanıcı Profili) +GET +http://localhost:8080/v1/auth/me +-- Header +Authorization: Bearer ACCESS_TOKEN_BURAYA +-- Body: YOK (GET isteği) +-- Cevap +{ + "id": "91cf0868-df24-4b8f-b491-70d9eb7a4373", + "username": "user_91cf0868", + "email": "beyhano@beyhan.dev", + "created_at": "2026-02-03T17:03:07.863425+03:00", + "updated_at": "2026-02-03T17:03:07.880923+03:00", + "social_accounts": null, + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ] +} + +-- Validate Token (Token Doğrulama) +GET +http://localhost:8080/v1/auth/validate +-- Header +Authorization: Bearer ACCESS_TOKEN_BURAYA +-- Body: YOK (GET isteği, body gönderilmez) +-- Cevap +{ + "email": "beyhano@beyhan.dev", + "message": "Token is valid", + "user_id": "91cf0868-df24-4b8f-b491-70d9eb7a4373" +} +## Kurulum ve Çalıştırma + +1. `.env.example` dosyasını `.env` olarak kopyalayın ve değerleri düzenleyin. +2. Docker ile veritabanını ve uygulamayı başlatmak için: + ```bash + ./scripts/run.sh + ``` + _(Bu script Docker konteynerini kontrol eder, yoksa başlatır ve ardından Go sunucusunu çalıştırır)_ + +## Geliştirme Notları + +- **Env Değişkenleri:** + - `RATE_LIMIT_BLACKLIST_IPS=...`, `RATE_LIMIT_BLACKLIST_DOMAINS=...` ile yasaklama. + - `RATE_LIMIT_WHITELIST=...`, `RATE_LIMIT_WHITELIST_DOMAINS=...` ile muafiyet. + +- **Migration Ekleme:** `internal/db/migrations` klasörüne `00000X_description.up.sql` formatında yeni dosya ekleyin. +- **Admin Kullanıcısı:** Sistem ilk açılışta `admin@example.com` / `admin123` kullanıcısını otomatik oluşturur. +- **Docker Volume:** `/uploads` klasörü `uploads_data` volume'ü ile kalıcı hale getirilmiştir. +- **Image Processing:** WebP desteği için Dockerfile `CGO_ENABLED=1` ve `build-base` içerir. diff --git a/HARD_DELETE_GUIDE.md b/HARD_DELETE_GUIDE.md new file mode 100644 index 0000000..d1c5e9c --- /dev/null +++ b/HARD_DELETE_GUIDE.md @@ -0,0 +1,191 @@ +# Hard Delete Hızlı Referans + +## Tek Komutla Hard Delete + +### 1. Kullanıcı ID ile Hard Delete + +```bash +# Admin token al ve kullanıcıyı sil +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') && \ +curl -X DELETE "http://localhost:8080/v1/admin/users/USER_ID_BURAYA?hard=true" \ + -H "Authorization: Bearer $TOKEN" +``` + +**USER_ID_BURAYA** yerine gerçek UUID'yi yazın. + +### 2. Email ile Bul ve Hard Delete + +```bash +# Token al +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +# Email ile kullanıcı bul +USER_ID=$(curl -s -X GET "http://localhost:8080/v1/admin/users/search?q=test@example.com" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.users[0].id') + +# Hard delete +curl -X DELETE "http://localhost:8080/v1/admin/users/$USER_ID?hard=true" \ + -H "Authorization: Bearer $TOKEN" +``` + +### 3. One-Liner (Tek Satırda) + +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login -H "Content-Type: application/json" -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') && USER_ID=$(curl -s -X GET "http://localhost:8080/v1/admin/users/search?q=EMAIL_BURAYA" -H "Authorization: Bearer $TOKEN" | jq -r '.users[0].id') && curl -X DELETE "http://localhost:8080/v1/admin/users/$USER_ID?hard=true" -H "Authorization: Bearer $TOKEN" +``` + +**EMAIL_BURAYA** yerine silinecek email'i yazın. + +## API Endpoint'leri + +| İşlem | Method | Endpoint | Query Param | +|-------|--------|----------|-------------| +| Soft Delete | DELETE | `/v1/admin/users/{id}` | - | +| Hard Delete | DELETE | `/v1/admin/users/{id}` | `?hard=true` | +| Kullanıcı Ara | GET | `/v1/admin/users/search` | `?q=email` | +| Kullanıcı Listele | GET | `/v1/admin/users` | `?page=1&limit=10` | + +## Örnek Yanıtlar + +### Başarılı Hard Delete +```json +{ + "message": "User deleted permanently successfully" +} +``` + +### Başarılı Soft Delete +```json +{ + "message": "User deleted soft successfully" +} +``` + +### Hata (Kullanıcı Bulunamadı) +```json +{ + "error": "Failed to delete user" +} +``` + +### Hata (Kendi Hesabını Silmeye Çalışma) +```json +{ + "error": "Cannot delete your own account" +} +``` + +## cURL ile POST Örnekleri + +### Yeni Kullanıcı Oluştur (Hard Delete için) + +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +# Form data ile (avatar ile) +curl -X POST http://localhost:8080/v1/admin/users \ + -H "Authorization: Bearer $TOKEN" \ + -F "email=newuser@test.com" \ + -F "password=password123" \ + -F "user_name=New User" \ + -F "email_verified=false" \ + -F "roles=user" + +# Yanıt - User ID'yi not edin +# { +# "id": "abc-123-def-456", +# "email": "newuser@test.com", +# ... +# } + +# Hard delete +curl -X DELETE "http://localhost:8080/v1/admin/users/abc-123-def-456?hard=true" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Pratik Scriptler + +### test-hard-delete.sh +```bash +#!/bin/bash + +# Test kullanıcısı oluştur ve hemen hard delete yap +echo "Creating admin token..." +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +echo "Creating test user..." +CREATE_RESPONSE=$(curl -s -X POST http://localhost:8080/v1/admin/users \ + -H "Authorization: Bearer $TOKEN" \ + -F "email=temp@test.com" \ + -F "password=temp123" \ + -F "user_name=Temp User" \ + -F "email_verified=false" \ + -F "roles=user") + +USER_ID=$(echo $CREATE_RESPONSE | jq -r '.id') +echo "Created user: $USER_ID" + +echo "Hard deleting user..." +DELETE_RESPONSE=$(curl -s -X DELETE "http://localhost:8080/v1/admin/users/$USER_ID?hard=true" \ + -H "Authorization: Bearer $TOKEN") + +echo "Result: $DELETE_RESPONSE" +``` + +### bulk-hard-delete.sh +```bash +#!/bin/bash + +# Belirli email pattern'e uyan tüm kullanıcıları hard delete yap +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +# test içeren emailler +SEARCH_QUERY="test" + +echo "Searching users with pattern: $SEARCH_QUERY" +USER_IDS=$(curl -s -X GET "http://localhost:8080/v1/admin/users/search?q=$SEARCH_QUERY" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.users[].id') + +for USER_ID in $USER_IDS; do + echo "Hard deleting: $USER_ID" + curl -s -X DELETE "http://localhost:8080/v1/admin/users/$USER_ID?hard=true" \ + -H "Authorization: Bearer $TOKEN" | jq '.' + sleep 0.5 # Rate limiting için +done + +echo "Bulk hard delete completed!" +``` + +## Önemli Notlar + +✅ **Kullanım Öncesi:** +- Admin token'ınızın geçerli olduğundan emin olun +- Silinecek kullanıcının ID'sini doğrulayın +- Soft delete yerine hard delete kullanmak istediğinizden emin olun + +⚠️ **Dikkat:** +- Hard delete **GERİ ALINAMAZ** +- Kendi hesabınızı silemezsiniz +- Üretim ortamında dikkatli kullanın +- Yedek almadan hard delete yapmayın + +🔧 **Debug:** +```bash +# Token geçerli mi kontrol et +curl -X GET http://localhost:8080/v1/auth/validate \ + -H "Authorization: Bearer $TOKEN" + +# Kullanıcı var mı kontrol et +curl -X GET "http://localhost:8080/v1/admin/users/$USER_ID" \ + -H "Authorization: Bearer $TOKEN" +``` diff --git a/Login.txt b/Login.txt new file mode 100644 index 0000000..bfa98bc --- /dev/null +++ b/Login.txt @@ -0,0 +1,35 @@ +curl -X 'POST' \ + 'http://127.0.0.1:8080/v1/auth/login' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "beyhan@beyhan.dev", + "password": "1923btO**" +}' + + +Response body +Download + +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1M2JmNGIwNS03ZDgzLTQ4ZjUtODQ3Ny1kODc4YTFkYjY3ZDkiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwicGVybWlzc2lvbnMiOlsidXNlcjpyZWFkIl0sImlzcyI6ImdhdXRoLWNlbnRyYWwiLCJleHAiOjE3NzAyMzc5NTksImlhdCI6MTc3MDIzNzA1OX0.I2z8n8G8kv3phvdv_IyL8CaY-LnYl9wpyleGQjTXmQc", + "avatar": "", + "email": "beyhan@beyhan.dev", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1M2JmNGIwNS03ZDgzLTQ4ZjUtODQ3Ny1kODc4YTFkYjY3ZDkiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwiaXNzIjoiZ2F1dGgtY2VudHJhbCIsImV4cCI6MTc3MDg0MTg1OSwiaWF0IjoxNzcwMjM3MDU5fQ.sHEQU1LWFSD9FJ2d1gj_GHjP35uzGm2NDvaqa_ckLHk", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "53bf4b05-7d83-48f5-8477-d878a1db67d9", + "username": "Beyhan Oğur" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/Register.txt b/Register.txt new file mode 100644 index 0000000..a957c89 --- /dev/null +++ b/Register.txt @@ -0,0 +1,35 @@ +curl -X 'POST' \ + 'http://127.0.0.1:8080/v1/auth/register' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "beyhan@beyhan.dev", + "password": "1923btO**", + "username": "Beyhan Oğur" +}' +Response body +Download + +{ + "avatar": "", + "email": "beyhan@beyhan.dev", + "email_verified": false, + "message": "User created. Please verify your email.", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role", + "permissions": [ + { + "id": 1, + "name": "user:read", + "description": "Can read user data" + } + ] + } + ], + "user_id": "53bf4b05-7d83-48f5-8477-d878a1db67d9", + "username": "Beyhan Oğur", + "verification_token": "25b6ea0d5f7376881d5f57b11305122431e8967a13ce66888f2312b22bca48ef" +} \ No newline at end of file diff --git a/SOFT_DELETE_MANAGEMENT.md b/SOFT_DELETE_MANAGEMENT.md new file mode 100644 index 0000000..590af21 --- /dev/null +++ b/SOFT_DELETE_MANAGEMENT.md @@ -0,0 +1,414 @@ +# Soft Delete Kullanıcı Yönetimi + +## Genel Bakış + +AuthCentral'da silinen kullanıcılar soft delete ile yönetilir. Bu, kullanıcıların veritabanından silinmeden sadece işaretlenerek pasif hale getirilmesi anlamına gelir. + +## Yeni Endpoint'ler + +### 1. Silinen Kullanıcıları Listele + +```bash +GET /v1/admin/users/deleted +``` + +**Query Parameters:** +- `page` (int, optional) - Sayfa numarası (default: 1) +- `limit` (int, optional) - Sayfa başına kayıt (default: 10, max: 100) + +**Örnek:** +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +curl -X GET "http://localhost:8080/v1/admin/users/deleted?page=1&limit=10" \ + -H "Authorization: Bearer $TOKEN" +``` + +**Yanıt:** +```json +{ + "users": [ + { + "id": "ca567947-ef2a-49ad-b955-bf0ef6bbf136", + "username": "Delete Me", + "email": "deleteme@test.com", + "avatar": "", + "email_verified": true, + "created_at": "2026-02-05T00:03:08.360433+03:00", + "updated_at": "2026-02-05T00:03:08.38027+03:00", + "deleted_at": "2026-02-05T00:03:25.549299+03:00", + "roles": [ + { + "id": 2, + "name": "user", + "description": "Default user role" + } + ], + "social_accounts": [] + } + ], + "pagination": { + "page": 1, + "limit": 10, + "total": 12, + "totalPages": 2 + } +} +``` + +**Özellikler:** +- ✅ `deleted_at` field'ı görünür (normal endpoint'lerde gizli) +- ✅ Pagination desteği +- ✅ Sadece soft delete edilmiş kullanıcılar gösterilir +- ✅ En son silinen kullanıcılar önce gelir (deleted_at DESC) + +### 2. Kullanıcıyı Geri Yükle (Restore) + +```bash +POST /v1/admin/users/{id}/restore +``` + +**Path Parameters:** +- `id` (uuid, required) - Kullanıcı ID + +**Örnek:** +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +USER_ID="ca567947-ef2a-49ad-b955-bf0ef6bbf136" + +curl -X POST "http://localhost:8080/v1/admin/users/$USER_ID/restore" \ + -H "Authorization: Bearer $TOKEN" +``` + +**Başarılı Yanıt:** +```json +{ + "message": "User restored successfully" +} +``` + +**Hata Yanıtları:** +```json +{ + "error": "deleted user not found" +} +``` + +## Kullanım Senaryoları + +### Senaryo 1: Silinen Kullanıcıları İnceleme + +```bash +#!/bin/bash + +# Admin login +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +# Tüm silinen kullanıcıları listele +echo "=== Deleted Users ===" +curl -s -X GET "http://localhost:8080/v1/admin/users/deleted" \ + -H "Authorization: Bearer $TOKEN" | jq '.users[] | {id, email, username, deleted_at}' +``` + +### Senaryo 2: Kullanıcıyı Soft Delete ve Restore + +```bash +#!/bin/bash + +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +USER_ID="abc-123-def-456" + +# 1. Kullanıcıyı soft delete yap +echo "Step 1: Soft delete user" +curl -s -X DELETE "http://localhost:8080/v1/admin/users/$USER_ID" \ + -H "Authorization: Bearer $TOKEN" | jq '.' + +# 2. Silinen kullanıcılar listesinde kontrol et +echo -e "\nStep 2: Check deleted users" +curl -s -X GET "http://localhost:8080/v1/admin/users/deleted" \ + -H "Authorization: Bearer $TOKEN" | jq ".users[] | select(.id==\"$USER_ID\")" + +# 3. Kullanıcıyı geri yükle +echo -e "\nStep 3: Restore user" +curl -s -X POST "http://localhost:8080/v1/admin/users/$USER_ID/restore" \ + -H "Authorization: Bearer $TOKEN" | jq '.' + +# 4. Normal kullanıcı listesinde kontrol et +echo -e "\nStep 4: Verify user is restored" +curl -s -X GET "http://localhost:8080/v1/admin/users/$USER_ID" \ + -H "Authorization: Bearer $TOKEN" | jq '{id, email, username}' +``` + +### Senaryo 3: Frontend İçin Silinen Kullanıcılar Yönetimi + +**Frontend JavaScript Örneği:** + +```javascript +// API Client +class AdminAPI { + constructor(baseURL, token) { + this.baseURL = baseURL; + this.token = token; + } + + async getDeletedUsers(page = 1, limit = 10) { + const response = await fetch( + `${this.baseURL}/v1/admin/users/deleted?page=${page}&limit=${limit}`, + { + headers: { + 'Authorization': `Bearer ${this.token}` + } + } + ); + return response.json(); + } + + async restoreUser(userId) { + const response = await fetch( + `${this.baseURL}/v1/admin/users/${userId}/restore`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}` + } + } + ); + return response.json(); + } + + async softDeleteUser(userId) { + const response = await fetch( + `${this.baseURL}/v1/admin/users/${userId}`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.token}` + } + } + ); + return response.json(); + } + + async hardDeleteUser(userId) { + const response = await fetch( + `${this.baseURL}/v1/admin/users/${userId}?hard=true`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.token}` + } + } + ); + return response.json(); + } +} + +// Kullanım +const admin = new AdminAPI('http://localhost:8080', YOUR_TOKEN); + +// Silinen kullanıcıları getir +const deletedUsers = await admin.getDeletedUsers(1, 10); +console.log(deletedUsers); + +// Kullanıcıyı geri yükle +const result = await admin.restoreUser('user-uuid-here'); +console.log(result); +``` + +## API Endpoint'leri Özeti + +| Endpoint | Method | Açıklama | Query/Body | +|----------|--------|----------|------------| +| `/v1/admin/users` | GET | Aktif kullanıcılar | `?page=1&limit=10` | +| `/v1/admin/users/deleted` | GET | **Silinen kullanıcılar** | `?page=1&limit=10` | +| `/v1/admin/users/{id}` | DELETE | Soft delete | - | +| `/v1/admin/users/{id}?hard=true` | DELETE | Hard delete (kalıcı) | `?hard=true` | +| `/v1/admin/users/{id}/restore` | POST | **Kullanıcıyı geri yükle** | - | + +## Soft Delete vs Hard Delete + +| Özellik | Soft Delete | Hard Delete | +|---------|-------------|-------------| +| **Veritabanı** | `deleted_at` timestamp set edilir | Tamamen silinir | +| **Görünürlük** | `/deleted` endpoint'inde görünür | Hiçbir yerde görünmez | +| **Geri Getirme** | ✅ `/restore` ile mümkün | ❌ İmkansız | +| **İlişkiler** | Korunur | Silinir | +| **Kullanım** | Varsayılan, güvenli | Dikkatli kullanılmalı | +| **Komut** | `DELETE /users/{id}` | `DELETE /users/{id}?hard=true` | + +## Frontend Entegrasyonu + +### React Örneği + +```jsx +import React, { useState, useEffect } from 'react'; + +function DeletedUsersManager() { + const [deletedUsers, setDeletedUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + const fetchDeletedUsers = async () => { + setLoading(true); + try { + const response = await fetch( + `http://localhost:8080/v1/admin/users/deleted?page=${page}&limit=10`, + { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + } + ); + const data = await response.json(); + setDeletedUsers(data.users); + } catch (error) { + console.error('Error fetching deleted users:', error); + } finally { + setLoading(false); + } + }; + + const restoreUser = async (userId) => { + if (!confirm('Bu kullanıcıyı geri yüklemek istediğinize emin misiniz?')) { + return; + } + + try { + const response = await fetch( + `http://localhost:8080/v1/admin/users/${userId}/restore`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + } + ); + + if (response.ok) { + alert('Kullanıcı başarıyla geri yüklendi!'); + fetchDeletedUsers(); // Listeyi yenile + } + } catch (error) { + console.error('Error restoring user:', error); + } + }; + + useEffect(() => { + fetchDeletedUsers(); + }, [page]); + + return ( +
+

Silinen Kullanıcılar

+ + {loading ? ( +

Yükleniyor...

+ ) : ( + + + + + + + + + + + {deletedUsers.map(user => ( + + + + + + + ))} + +
EmailKullanıcı AdıSilinme Tarihiİşlemler
{user.email}{user.username}{new Date(user.deleted_at).toLocaleString('tr-TR')} + +
+ )} + +
+ + Sayfa {page} + +
+
+ ); +} + +export default DeletedUsersManager; +``` + +## Güvenlik Notları + +✅ **İyi Pratikler:** +- Silinen kullanıcıları düzenli olarak gözden geçirin +- Restore işleminden önce kullanıcıyı doğrulayın +- Hard delete yapmadan önce soft delete kullanın +- Kritik kullanıcılar için restore geçmişi tutun + +⚠️ **Dikkat Edilmesi Gerekenler:** +- Sadece admin rolündeki kullanıcılar bu endpoint'lere erişebilir +- Restore edilen kullanıcı önceki tüm rolleri ve ayarları ile geri gelir +- Soft delete edilmiş kullanıcılar login yapamaz +- Hard delete geri alınamaz, dikkatli kullanın + +## Test Komutları + +```bash +# 1. Kullanıcı oluştur +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@gauth.local","password":"Admin@123"}' | jq -r '.access_token') + +curl -X POST http://localhost:8080/v1/admin/users \ + -H "Authorization: Bearer $TOKEN" \ + -F "email=test@example.com" \ + -F "password=test123" \ + -F "user_name=Test User" \ + -F "roles=user" + +# 2. Kullanıcıyı soft delete yap +curl -X DELETE "http://localhost:8080/v1/admin/users/USER_ID" \ + -H "Authorization: Bearer $TOKEN" + +# 3. Silinen kullanıcıları listele +curl -X GET "http://localhost:8080/v1/admin/users/deleted" \ + -H "Authorization: Bearer $TOKEN" | jq '.' + +# 4. Kullanıcıyı geri yükle +curl -X POST "http://localhost:8080/v1/admin/users/USER_ID/restore" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Özet + +🎯 **Yeni Özellikler:** +- ✅ Silinen kullanıcıları listeleme +- ✅ Kullanıcıyı geri yükleme (restore) +- ✅ `deleted_at` field'ı görünürlüğü +- ✅ Pagination desteği +- ✅ Frontend entegrasyonu için hazır + +📊 **Kullanım:** +- Soft delete varsayılan silme yöntemi +- Hard delete sadece kalıcı silme için +- Restore ile yanlışlıkla silinen kullanıcılar kurtarılabilir +- Frontend'de silinen kullanıcılar yönetilebilir diff --git a/USER_PROFILE_API.md b/USER_PROFILE_API.md new file mode 100644 index 0000000..9f8c587 --- /dev/null +++ b/USER_PROFILE_API.md @@ -0,0 +1,586 @@ +# Kullanıcı Profil Yönetimi API + +## Genel Bakış + +AuthCentral kullanıcıları kendi profillerini yönetebilir. Bu dokümantasyon kullanıcı profil yönetimi endpoint'lerini açıklar. + +## Endpoint'ler + +### 1. Profil Bilgilerini Getir + +```bash +GET /v1/profile +``` + +**Headers:** +- `Authorization: Bearer {access_token}` + +**Yanıt:** +```json +{ + "id": "7d8b023c-d5e4-4f62-8811-ddbf00d675bb", + "username": "admin", + "email": "admin@gauth.local", + "avatar": "/uploads/avatars/admin_avatar.png", + "email_verified": true, + "is_oauth_user": false, + "created_at": "2026-02-04T20:00:00Z", + "updated_at": "2026-02-05T10:00:00Z", + "roles": [ + { + "id": 1, + "name": "admin", + "description": "Administrator role" + } + ], + "social_accounts": [] +} +``` + +**Yeni Field:** +- `is_oauth_user` (boolean) - Kullanıcı OAuth ile mi giriş yapmış (Google/GitHub) + +**Örnek:** +```bash +TOKEN="your_access_token_here" + +curl -X GET "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +### 2. Profil Güncelle + +```bash +PUT /v1/profile +``` + +**Headers:** +- `Authorization: Bearer {access_token}` +- `Content-Type: multipart/form-data` (avatar yüklemek için) + +**Form Data:** +- `user_name` (string, optional) - Yeni kullanıcı adı +- `avatar` (file, optional) - Profil resmi (max 5MB) + +**Örnek (Username Güncelleme):** +```bash +TOKEN="your_access_token_here" + +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "user_name=Beyhan Oğur" +``` + +**Örnek (Avatar Yükleme):** +```bash +TOKEN="your_access_token_here" + +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "avatar=@/path/to/profile-picture.jpg" +``` + +**Örnek (Username + Avatar):** +```bash +TOKEN="your_access_token_here" + +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "user_name=Beyhan Oğur" \ + -F "avatar=@/path/to/profile-picture.jpg" +``` + +**Başarılı Yanıt:** +```json +{ + "message": "Profile updated successfully", + "user": { + "id": "7d8b023c-d5e4-4f62-8811-ddbf00d675bb", + "username": "Beyhan Oğur", + "email": "admin@gauth.local", + "avatar": "/uploads/avatars/7d8b023c_1770238858420987000.png", + "email_verified": true, + "roles": [...] + } +} +``` + +--- + +### 3. Şifre Değiştir + +```bash +PUT /v1/profile/password +``` + +**Headers:** +- `Authorization: Bearer {access_token}` +- `Content-Type: application/json` + +**Request Body:** +```json +{ + "current_password": "OldPassword123", + "new_password": "NewPassword123" +} +``` + +**Örnek:** +```bash +TOKEN="your_access_token_here" + +curl -X PUT "http://localhost:8080/v1/profile/password" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "current_password": "OldPassword123", + "new_password": "NewPassword123" + }' +``` + +**Başarılı Yanıt:** +```json +{ + "message": "Password changed successfully" +} +``` + +**Hata Yanıtları:** +```json +{ + "error": "Current password is incorrect" +} +``` + +```json +{ + "error": "Cannot change password for OAuth users (Google/GitHub login)" +} +``` + +**Notlar:** +- ✅ Mevcut şifre doğrulanır +- ✅ Yeni şifre minimum 6 karakter olmalı +- ⚠️ **OAuth kullanıcıları (Google/GitHub) şifre değiştiremez** +- ⚠️ Şifre değiştirildikten sonra yeni şifre ile login yapılmalı +- 💡 `is_oauth_user: true` ise şifre değiştirme butonu gösterilmemeli + +--- + +### 4. Email Adresi Değiştir + +```bash +PUT /v1/profile/email +``` + +**Headers:** +- `Authorization: Bearer {access_token}` +- `Content-Type: application/json` + +**Request Body:** +```json +{ + "new_email": "newemail@example.com", + "password": "YourCurrentPassword" +} +``` + +**Örnek:** +```bash +TOKEN="your_access_token_here" + +curl -X PUT "http://localhost:8080/v1/profile/email" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "new_email": "newemail@example.com", + "password": "YourPassword123" + }' +``` + +**Başarılı Yanıt:** +```json +{ + "message": "Email updated. Please verify your new email address.", + "new_email": "newemail@example.com", + "verification_token": "abc123def456..." +} +``` + +**Hata Yanıtları:** +```json +{ + "error": "Password is incorrect" +} +``` + +```json +{ + "error": "Email already in use" +} +``` + +```json +{ + "error": "Cannot change email for OAuth users (Google/GitHub login)" +} +``` + +**Önemli Notlar:** +- ⚠️ **OAuth kullanıcıları (Google/GitHub) email değiştiremez** +- ⚠️ Email değiştirildiğinde `email_verified` false olur +- ⚠️ Yeni email adresine doğrulama email'i gönderilir +- ⚠️ Email doğrulanana kadar login yapılamaz +- ⚠️ Email doğrulama için `/v1/auth/verify-email?token=...` endpoint'i kullanılır +- 💡 `is_oauth_user: true` ise email değiştirme butonu gösterilmemeli + +--- + +## Kullanım Senaryoları + +### Senaryo 1: Tam Profil Güncelleme + +```bash +#!/bin/bash + +# Login +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"password123"}' | jq -r '.access_token') + +# 1. Kullanıcı adını güncelle +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "user_name=Yeni İsim" + +# 2. Avatar yükle +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "avatar=@./my-photo.jpg" + +# 3. Şifre değiştir +curl -X PUT "http://localhost:8080/v1/profile/password" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "current_password": "password123", + "new_password": "newpassword456" + }' + +# 4. Profili kontrol et +curl -X GET "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" | jq '.' +``` + +### Senaryo 2: Email Değiştirme ve Doğrulama + +```bash +#!/bin/bash + +# Login +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"old@example.com","password":"password123"}' | jq -r '.access_token') + +# 1. Email değiştir +RESPONSE=$(curl -s -X PUT "http://localhost:8080/v1/profile/email" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "new_email": "new@example.com", + "password": "password123" + }') + +echo $RESPONSE | jq '.' + +# 2. Verification token'ı al +VERIFY_TOKEN=$(echo $RESPONSE | jq -r '.verification_token') + +# 3. Email'i doğrula +curl -X GET "http://localhost:8080/v1/auth/verify-email?token=$VERIFY_TOKEN" + +# 4. Yeni email ile login +curl -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "new@example.com", + "password": "password123" + }' +``` + +--- + +## Frontend Entegrasyonu + +### React Örneği + +```jsx +import React, { useState, useEffect } from 'react'; + +function ProfilePage() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const token = localStorage.getItem('access_token'); + + // Profil bilgilerini yükle + const fetchProfile = async () => { + try { + const response = await fetch('http://localhost:8080/v1/profile', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + const data = await response.json(); + setUser(data); + } catch (error) { + console.error('Error fetching profile:', error); + } + }; + + // Username güncelle + const updateUsername = async (newUsername) => { + const formData = new FormData(); + formData.append('user_name', newUsername); + + try { + const response = await fetch('http://localhost:8080/v1/profile', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (response.ok) { + fetchProfile(); // Profili yenile + alert('Username updated!'); + } + } catch (error) { + console.error('Error updating username:', error); + } + }; + + // Avatar yükle + const uploadAvatar = async (file) => { + const formData = new FormData(); + formData.append('avatar', file); + + try { + const response = await fetch('http://localhost:8080/v1/profile', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (response.ok) { + fetchProfile(); // Profili yenile + alert('Avatar uploaded!'); + } + } catch (error) { + console.error('Error uploading avatar:', error); + } + }; + + // Şifre değiştir + const changePassword = async (currentPassword, newPassword) => { + try { + const response = await fetch('http://localhost:8080/v1/profile/password', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }); + + const data = await response.json(); + + if (response.ok) { + alert('Password changed successfully!'); + } else { + alert(data.error); + } + } catch (error) { + console.error('Error changing password:', error); + } + }; + + // Email değiştir + const changeEmail = async (newEmail, password) => { + try { + const response = await fetch('http://localhost:8080/v1/profile/email', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + new_email: newEmail, + password: password + }) + }); + + const data = await response.json(); + + if (response.ok) { + alert('Email updated! Please check your email to verify.'); + } else { + alert(data.error); + } + } catch (error) { + console.error('Error changing email:', error); + } + }; + + useEffect(() => { + fetchProfile(); + }, []); + + return ( +
+

Profile

+ {user && ( +
+ Avatar +

Username: {user.username}

+

Email: {user.email}

+

Verified: {user.email_verified ? 'Yes' : 'No'}

+ + {/* OAuth user indicator */} + {user.is_oauth_user && ( +

+ ℹ️ Logged in with {user.social_accounts?.[0]?.provider || 'OAuth'} +

+ )} + + {/* Password change - only for non-OAuth users */} + {!user.is_oauth_user && ( + + )} + + {/* Email change - only for non-OAuth users */} + {!user.is_oauth_user && ( + + )} +
+ )} +
+ ); +} +``` + +--- + +## API Özeti + +| Endpoint | Method | Açıklama | Auth Required | +|----------|--------|----------|---------------| +| `/v1/profile` | GET | Profil bilgilerini getir | ✅ | +| `/v1/profile` | PUT | Profil güncelle (username, avatar) | ✅ | +| `/v1/profile/password` | PUT | Şifre değiştir | ✅ | +| `/v1/profile/email` | PUT | Email değiştir | ✅ | + +--- + +## Güvenlik Notları + +✅ **İyi Pratikler:** +- Şifre değiştirirken mevcut şifre doğrulanır +- Email değiştirirken doğrulama email'i gönderilir +- Avatar dosya boyutu sınırlandırılmıştır (max 5MB) +- Tüm endpoint'ler authentication gerektirir + +⚠️ **Dikkat Edilmesi Gerekenler:** +- **OAuth kullanıcıları (Google/GitHub) şifre ve email değiştiremez** +- OAuth kullanıcıları sadece username ve avatar değiştirebilir +- Frontend'de `is_oauth_user` flag'ini kontrol edin +- Şifre değiştirme butonu OAuth kullanıcılarına gösterilmemeli +- Email değiştirme butonu OAuth kullanıcılarına gösterilmemeli +- Email değiştirildiğinde yeniden doğrulama gerekir +- Şifre minimum 6 karakter olmalı +- Avatar sadece resim formatları kabul edilir + +## OAuth Kullanıcı Kısıtlamaları + +| Özellik | Email/Password Kullanıcı | OAuth Kullanıcı (Google/GitHub) | +|---------|-------------------------|--------------------------------| +| Profil Görüntüleme | ✅ Evet | ✅ Evet | +| Username Değiştirme | ✅ Evet | ✅ Evet | +| Avatar Yükleme | ✅ Evet | ✅ Evet | +| **Şifre Değiştirme** | ✅ Evet | ❌ **Hayır** | +| **Email Değiştirme** | ✅ Evet | ❌ **Hayır** | + +**Önemli:** Frontend'de `is_oauth_user` field'ını kontrol ederek UI'ı buna göre düzenleyin. + +--- + +## Test Komutları + +```bash +# Login ve token al +TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"password123"}' | jq -r '.access_token') + +# Profil bilgilerini getir +curl -X GET "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" | jq '.' + +# Username güncelle +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "user_name=New Name" + +# Avatar yükle +curl -X PUT "http://localhost:8080/v1/profile" \ + -H "Authorization: Bearer $TOKEN" \ + -F "avatar=@./photo.jpg" + +# Şifre değiştir +curl -X PUT "http://localhost:8080/v1/profile/password" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"current_password":"old123","new_password":"new456"}' + +# Email değiştir +curl -X PUT "http://localhost:8080/v1/profile/email" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"new_email":"new@email.com","password":"password123"}' +``` + +--- + +## Özet + +🎯 **Özellikler:** +- ✅ Profil bilgilerini görüntüleme +- ✅ Kullanıcı adı güncelleme +- ✅ Avatar yükleme (max 5MB) +- ✅ Şifre değiştirme (mevcut şifre doğrulaması ile) +- ✅ Email değiştirme (yeniden doğrulama ile) + +📊 **Kullanım:** +- Her kullanıcı kendi profilini yönetebilir +- OAuth kullanıcıları şifre değiştiremez +- Email değişikliği doğrulama gerektirir +- Avatar otomatik optimize edilir diff --git a/app/(admin)/admin/cors/page.tsx b/app/(admin)/admin/cors/page.tsx new file mode 100644 index 0000000..1ead854 --- /dev/null +++ b/app/(admin)/admin/cors/page.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { useAppDispatch, useAppSelector } from "@/lib/hooks"; +import { + fetchWhitelists, + fetchBlacklists, + createWhitelist, + createBlacklist, + deleteWhitelist, + deleteBlacklist, + updateWhitelist, + updateBlacklist, + CorsEntry, +} from "@/lib/features/cors/corsSlice"; +import { CorsTable } from "@/components/cors/cors-table"; +import { CorsDialog } from "@/components/cors/cors-dialog"; +import { Button } from "@/components/ui/button"; +import { Plus } from "lucide-react"; +import Swal from "sweetalert2"; + +export default function CorsPage() { + const dispatch = useAppDispatch(); + const { whitelist, blacklist } = useAppSelector((state) => state.cors); + const [activeTab, setActiveTab] = useState<"whitelist" | "blacklist">("whitelist"); + const [mounted, setMounted] = useState(false); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingEntry, setEditingEntry] = useState(null); + + useEffect(() => { + setMounted(true); + dispatch(fetchWhitelists()); + dispatch(fetchBlacklists()); + }, [dispatch]); + + if (!mounted) return null; + + const handleDelete = (id: string) => { + Swal.fire({ + title: 'Emin misiniz?', + text: "Bu kaydı silmek istediğinize emin misiniz?", + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: 'Evet, sil!', + cancelButtonText: 'İptal' + }).then((result) => { + if (result.isConfirmed) { + if (activeTab === "whitelist") { + dispatch(deleteWhitelist(id)).then(() => { + Swal.fire('Silindi!', 'Kayıt başarıyla silindi.', 'success'); + }); + } else { + dispatch(deleteBlacklist(id)).then(() => { + Swal.fire('Silindi!', 'Kayıt başarıyla silindi.', 'success'); + }); + } + } + }); + }; + + const handleToggleActive = (id: string, currentStatus: boolean) => { + const data = { is_active: !currentStatus }; + if (activeTab === "whitelist") { + dispatch(updateWhitelist({ id, data })); + } else { + dispatch(updateBlacklist({ id, data })); + } + }; + + const handleEdit = (entry: CorsEntry) => { + setEditingEntry(entry); + setDialogOpen(true); + }; + + const handleAddClick = () => { + setEditingEntry(null); + setDialogOpen(true); + }; + + const handleDialogSubmit = async (origin: string, note: string) => { + if (editingEntry) { + // Update existing + const data: Partial = activeTab === "whitelist" + ? { origin, description: note } + : { origin, reason: note }; + + if (activeTab === "whitelist") { + await dispatch(updateWhitelist({ id: editingEntry.id, data })).unwrap(); + } else { + await dispatch(updateBlacklist({ id: editingEntry.id, data })).unwrap(); + } + Swal.fire('Güncellendi!', 'Kayıt başarıyla güncellendi.', 'success'); + } else { + // Create new + if (activeTab === "whitelist") { + await dispatch(createWhitelist({ origin, description: note })).unwrap(); + } else { + await dispatch(createBlacklist({ origin, reason: note })).unwrap(); + } + Swal.fire('Eklendi!', 'Yeni kayıt başarıyla eklendi.', 'success'); + } + }; + + return ( +
+ {/* Header / Breadcrumb Section */} +
+ + + + Admin + + + + CORS Ayarları + + + +
+ + + + {/* Actions & Dialog */} +
+
+ + +
+ +
+ + + + {/* Table */} +
+ +
+
+ ); +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..3ea20fb --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useAppSelector } from "@/lib/hooks"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { getAvatarUrl } from "@/lib/utils"; + +export default function DashboardPage() { + const { user } = useAppSelector((state) => state.auth); + + return ( +
+
+

Hoşgeldin, {user?.username}

+

+ Admin paneli kontrol merkezi. +

+
+
+ + + Toplam Kullanıcı + + +
128
+

+ +4% geçen aydan beri +

+
+
+ + + Aktif Roller + + +
+ {user?.roles?.map(r => r.name).join(", ") || "Yok"} +
+
+
+
+ + + + Profil Bilgileri + + + + + {user?.username?.slice(0, 2).toUpperCase()} + +
+
{user?.username}
+
{user?.email}
+
ID: {user?.id}
+
+
+
+
+ ); +} diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..672add0 --- /dev/null +++ b/app/(admin)/admin/users/page.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAppDispatch, useAppSelector } from "@/lib/hooks"; +import { fetchUsers, fetchDeletedUsers, createUser, updateUser, deleteUser, restoreUser, CreateUserRequest, UpdateUserRequest } from "@/lib/features/users/usersSlice"; +import { UserTable } from "@/components/users/user-table"; +import { DeletedUserTable } from "@/components/users/deleted-user-table"; +import { UserDialog } from "@/components/users/user-dialog"; +import { Button } from "@/components/ui/button"; +import { Plus, Trash, Trash2 } from "lucide-react"; +import Swal from "sweetalert2"; +import { User } from "@/lib/features/auth/authSlice"; + +export default function UsersPage() { + const dispatch = useAppDispatch(); + const { users, deletedUsers, isLoading, error } = useAppSelector((state) => state.users); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + + useEffect(() => { + dispatch(fetchUsers()); + dispatch(fetchDeletedUsers()); + }, [dispatch]); + + const handleCreate = () => { + setSelectedUser(null); + setDialogOpen(true); + }; + + const handleEdit = (user: User) => { + setSelectedUser(user); + setDialogOpen(true); + }; + + const handleDelete = (id: string) => { + Swal.fire({ + title: "Soft Delete", + text: "Bu kullanıcı 'silindi' olarak işaretlenecek (Soft Delete). Geri alınabilir.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#f97316", // Orange + cancelButtonColor: "#3085d6", + confirmButtonText: "Evet, Sil", + cancelButtonText: "İptal", + }).then((result) => { + if (result.isConfirmed) { + dispatch(deleteUser(id)) + .unwrap() + .then(() => { + Swal.fire("Silindi!", "Kullanıcı başarıyla silindi (Soft).", "success"); + // Refresh both lists because a user moved from active to deleted + dispatch(fetchUsers()); + dispatch(fetchDeletedUsers()); + }) + .catch((err) => { + Swal.fire("Hata!", err || "Silme işlemi başarısız.", "error"); + }); + } + }); + }; + + const handleHardDelete = (id: string) => { + Swal.fire({ + title: "KALICI OLARAK SİL?", + text: "Bu işlem GERİ ALINAMAZ! Kullanıcı ve tüm verileri veritabanından tamamen silinecek (Hard Delete).", + icon: "error", // Red + showCancelButton: true, + confirmButtonColor: "#d33", // Red + cancelButtonColor: "#3085d6", + confirmButtonText: "Evet, KALICI SİL!", + cancelButtonText: "İptal", + }).then((result) => { + if (result.isConfirmed) { + const isSoftDeleted = deletedUsers.some(u => u.id === id); + + if (isSoftDeleted) { + // WORKAROUND: Backend soft-deleted kullanıcıları bulamıyor olabilir. + // Önce restore et, sonra hard delete yap. + dispatch(restoreUser(id)) + .unwrap() + .then(() => { + return dispatch(deleteUser({ id, hard: true })).unwrap(); + }) + .then(() => { + Swal.fire("Silindi!", "Kullanıcı kalıcı olarak silindi.", "success"); + }) + .catch((err) => { + Swal.fire("Hata!", err || "Silme işlemi başarısız.", "error"); + }); + } else { + // Normal Hard Delete (Aktif kullanıcı) + dispatch(deleteUser({ id, hard: true })) + .unwrap() + .then(() => { + Swal.fire("Silindi!", "Kullanıcı kalıcı olarak silindi.", "success"); + }) + .catch((err) => { + Swal.fire("Hata!", err || "Silme işlemi başarısız.", "error"); + }); + } + } + }); + }; + + const handleRestore = (id: string) => { + Swal.fire({ + title: "Geri Yükle?", + text: "Kullanıcı tekrar aktif edilecek.", + icon: "question", + showCancelButton: true, + confirmButtonColor: "#16a34a", // Green + cancelButtonColor: "#d33", + confirmButtonText: "Evet, Geri Yükle", + cancelButtonText: "İptal", + }).then((result) => { + if (result.isConfirmed) { + dispatch(restoreUser(id)) + .unwrap() + .then(() => { + Swal.fire("Başarılı!", "Kullanıcı geri yüklendi.", "success"); + dispatch(fetchUsers()); + dispatch(fetchDeletedUsers()); + }) + .catch((err) => { + Swal.fire("Hata!", err || "Geri yükleme başarısız.", "error"); + }); + } + }); + }; + + const handleSubmit = (data: CreateUserRequest | UpdateUserRequest) => { + if ("id" in data) { + dispatch(updateUser(data as UpdateUserRequest)) + .unwrap() + .then(() => { + setDialogOpen(false); + Swal.fire("Başarılı", "Kullanıcı güncellendi.", "success"); + dispatch(fetchUsers()); + }) + .catch((err) => { + Swal.fire("Hata", err || "Güncelleme başarısız.", "error"); + }); + } else { + dispatch(createUser(data as CreateUserRequest)) + .unwrap() + .then(() => { + setDialogOpen(false); + Swal.fire("Başarılı", "Kullanıcı oluşturuldu.", "success"); + dispatch(fetchUsers()); + }) + .catch((err) => { + Swal.fire("Hata", err || "Oluşturma başarısız.", "error"); + }); + } + }; + + return ( +
+ {/* Active Users Section */} +
+
+
+

Kullanıcılar

+

+ Sistemdeki aktif kullanıcıları yönetin. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + { setSelectedUser(u); setDialogOpen(true); }} + onDelete={handleDelete} + onHardDelete={handleHardDelete} + /> +
+ + {/* Deleted Users Section */} +
+
+

+ Çöp Kutusu +

+

+ Silinmiş kullanıcıları geri yükleyin veya kalıcı olarak silin. +

+
+ + +
+ + { + // Re-implementing handleSubmit logic inline or keep it separate as before if preferred, + // but for brevity I'll assume the previous handleSubmit function is available in scope + // or I should include it in replacement if I am replacing the whole return block. + // Ideally, I should keep the existing handleSubmit and just pass it. + // Since I am replacing the whole return, I must ensure handleSubmit is defined above or passed correctly. + // Wait, I am replacing a range that includes imports and component setup? No, just the function body? + // Let's modify the instruction to be safer. + handleSubmit(data); + }} + isLoading={isLoading} + /> +
+ ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx new file mode 100644 index 0000000..6f7f8b8 --- /dev/null +++ b/app/(admin)/layout.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useAppSelector } from "@/lib/hooks"; +import { Loader2 } from "lucide-react"; +import Cookies from "js-cookie"; +import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/app-sidebar"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { isAuthenticated, isLoading, user } = useAppSelector((state) => state.auth); + const router = useRouter(); + const [authorized, setAuthorized] = useState(false); + + useEffect(() => { + // If we're not loading and not authenticated, redirect + // We wait for initial restoration to complete (managed by StoreProvider and authSlice) + const checkAuth = () => { + const token = Cookies.get("access_token"); + if (!token) { + router.push("/login"); + return; + } + + // Get user from state or localStorage + let currentUser = user; + if (!currentUser && typeof window !== 'undefined') { + const userStr = localStorage.getItem("user"); + if (userStr) { + try { + currentUser = JSON.parse(userStr); + } catch (e) { } + } + } + + if (currentUser) { + const isAdmin = currentUser.roles?.some((r: any) => r.name === "admin"); + if (isAdmin) { + setAuthorized(true); + } else { + // Redirect non-admins to home page + router.push("/"); + } + } else { + // Token exists but user data is missing/loading. + // If strictly protected, redirect to login. + router.push("/login"); + } + }; + checkAuth(); + }, [isAuthenticated, user, router]); + + if (!authorized) { + return ( +
+ +
+ ); + } + + return ( + + + +
+ +
+ Yönetim Paneli +
+ +
+ {children} +
+
+
+ ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..a29f9fc --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..6c25bd0 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState } from "react"; +import { useAppDispatch, useAppSelector } from "@/lib/hooks"; +import { login } from "@/lib/features/auth/authSlice"; +import { useRouter } from "next/navigation"; +import { Turnstile } from "nextjs-turnstile"; +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 { AlertCircle, Loader2 } from "lucide-react"; +import Link from "next/link"; +import Swal from 'sweetalert2' + +import { loginSchema } from "@/lib/schemas/login"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [formErrors, setFormErrors] = useState>({}); + const [turnstileToken, setTurnstileToken] = useState(null); + + const dispatch = useAppDispatch(); + const { isLoading, error } = useAppSelector((state) => state.auth); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormErrors({}); + + // Zod Validation + const result = loginSchema.safeParse({ + email, + password + }); + + if (!result.success) { + const errors: Record = {}; + result.error.issues.forEach((issue) => { + const path = issue.path[0]; + if (typeof path === 'string') { + errors[path] = issue.message; + } + }); + setFormErrors(errors); + return; + } + + if (!turnstileToken) { + Swal.fire({ + icon: 'error', + title: 'Hata', + text: 'Lütfen doğrulamayı tamamlayın (Turnstile).', + }) + return; + } + + const dispatchResult = await dispatch(login({ email, password })); + if (login.fulfilled.match(dispatchResult)) { + Swal.fire({ + position: "center", + icon: "success", + title: "Giriş Başarılı", + showConfirmButton: false, + timer: 1500 + }); + router.push("/admin"); + } + }; + + return ( + + + Giriş Yap + + Admin paneline erişmek için giriş yapın + + + +
+
+ + setEmail(e.target.value)} + // required override by Zod + /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + setPassword(e.target.value)} + // required override by Zod + /> + {formErrors.password &&

{formErrors.password}

} +
+ +
+ setTurnstileToken(token)} + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+ +

+ Hesabınız yok mu?{" "} + + Kayıt Ol + +

+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..7a29e91 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import { useAppDispatch, useAppSelector } from "@/lib/hooks"; +import { register } from "@/lib/features/auth/authSlice"; +import { useRouter } from "next/navigation"; +import { Turnstile } from "nextjs-turnstile"; +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 { AlertCircle, Loader2 } from "lucide-react"; +import Link from "next/link"; +import Swal from 'sweetalert2' + +import { registerSchema } from "@/lib/schemas/register"; + +export default function RegisterPage() { + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formErrors, setFormErrors] = useState>({}); + const [turnstileToken, setTurnstileToken] = useState(null); + + const dispatch = useAppDispatch(); + const { isLoading, error } = useAppSelector((state) => state.auth); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormErrors({}); + + // Zod Validation + const result = registerSchema.safeParse({ + username, + email, + password, + confirmPassword + }); + + if (!result.success) { + const errors: Record = {}; + result.error.issues.forEach((issue) => { + const path = issue.path[0]; + if (typeof path === 'string') { + errors[path] = issue.message; + } + }); + setFormErrors(errors); + return; + } + + if (!turnstileToken) { + Swal.fire({ + icon: 'error', + title: 'Hata', + text: 'Lütfen doğrulamayı tamamlayın (Turnstile).', + }) + return; + } + + const dispatchResult = await dispatch(register({ username, email, password })); + if (register.fulfilled.match(dispatchResult)) { + Swal.fire({ + position: "center", + icon: "success", + title: "Kayıt Başarılı!", + text: "Lütfen email adresinize gönderilen doğrulama linkine tıklayarak hesabınızı aktif edin.", + showConfirmButton: true, + confirmButtonText: "Giriş Yap" + }).then(() => { + router.push("/login"); + }); + } + }; + + return ( + + + Kayıt Ol + + Yeni bir hesap oluşturun + + + +
+
+ + setUsername(e.target.value)} + // required validation is now handled by Zod manually on submit + /> + {formErrors.username &&

{formErrors.username}

} +
+
+ + setEmail(e.target.value)} + /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + setPassword(e.target.value)} + /> + {formErrors.password &&

{formErrors.password}

} +
+
+ + setConfirmPassword(e.target.value)} + /> + {formErrors.confirmPassword &&

{formErrors.confirmPassword}

} +
+ +
+ setTurnstileToken(token)} + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+ +

+ Zaten hesabınız var mı?{" "} + + Giriş Yap + +

+
+
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..ae6f9b9 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,13 @@ +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/app/(dashboard)/profile/page.tsx b/app/(dashboard)/profile/page.tsx new file mode 100644 index 0000000..a0805ef --- /dev/null +++ b/app/(dashboard)/profile/page.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { useAppDispatch, useAppSelector } from "@/lib/hooks"; +import { fetchProfile, updateProfile, changePassword, changeEmail } from "@/lib/features/auth/authSlice"; +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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { getAvatarUrl } from "@/lib/utils"; +import Swal from "sweetalert2"; +import { Loader2, Camera, Shield, Mail, User as UserIcon } from "lucide-react"; + +export default function ProfilePage() { + const dispatch = useAppDispatch(); + const { user, isLoading } = useAppSelector((state) => state.auth); + const [file, setFile] = useState(null); + const fileInputRef = useRef(null); + + // Form states + const [username, setUsername] = useState(""); + + // Password change states + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + + // Email change states + const [newEmail, setNewEmail] = useState(""); + const [emailPassword, setEmailPassword] = useState(""); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + dispatch(fetchProfile()); + }, [dispatch]); + + useEffect(() => { + if (user) { + setUsername(user.username); + } + }, [user]); + + const handleAvatarChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFile(e.target.files[0]); + } + }; + + const handleProfileUpdate = () => { + const formData = new FormData(); + formData.append("user_name", username); + if (file) { + formData.append("avatar", file); + } + + dispatch(updateProfile(formData)) + .unwrap() + .then(() => { + Swal.fire("Başarılı", "Profil bilgileriniz güncellendi.", "success"); + setFile(null); + }) + .catch((err) => { + Swal.fire("Hata", err || "Profil güncellenemedi.", "error"); + }); + }; + + const handleChangePassword = () => { + if (!currentPassword || !newPassword) { + Swal.fire("Uyarı", "Lütfen tüm alanları doldurun.", "warning"); + return; + } + + if (newPassword.length < 6) { + Swal.fire("Uyarı", "Yeni şifre en az 6 karakter olmalıdır.", "warning"); + return; + } + + dispatch(changePassword({ current_password: currentPassword, new_password: newPassword })) + .unwrap() + .then(() => { + Swal.fire("Başarılı", "Şifreniz değiştirildi. Lütfen yeni şifrenizle tekrar giriş yapın.", "success"); + setCurrentPassword(""); + setNewPassword(""); + }) + .catch((err) => { + Swal.fire("Hata", err || "Şifre değiştirilemedi.", "error"); + }); + }; + + const handleChangeEmail = () => { + if (!newEmail || !emailPassword) { + Swal.fire("Uyarı", "Lütfen tüm alanları doldurun.", "warning"); + return; + } + + dispatch(changeEmail({ new_email: newEmail, password: emailPassword })) + .unwrap() + .then((res: any) => { + Swal.fire({ + title: "Başarılı", + text: `Email adresiniz güncellendi. Lütfen ${newEmail} adresine gönderilen doğrulama linkine tıklayın.`, + icon: "success" + }); + setNewEmail(""); + setEmailPassword(""); + }) + .catch((err) => { + Swal.fire("Hata", err || "Email değiştirilemedi.", "error"); + }); + }; + + if (!isMounted) { + return
; + } + + if (!user && isLoading) { + return
; + } + + if (!user) { + return
Kullanıcı bilgileri yüklenemedi.
; + } + + return ( +
+

Profilim

+ + {/* Profile Info & Edit */} +
+ + + Profil Bilgileri + Kişisel bilgilerinizi buradan güncelleyebilirsiniz. + + +
+
+ + + + {user.username.slice(0, 2).toUpperCase()} + + + + +
+
+

{user.username}

+

{user.email}

+
+ {user.roles.map(role => ( + + {role.name} + + ))} + {user.is_oauth_user ? ( + + OAuth + + ) : ( + user.email_verified && + Email Onaylı + + )} +
+
+
+ +
+ +
+ + setUsername(e.target.value)} + className="pl-9" + /> +
+
+
+ + + +
+ + {/* Security Settings - Only for non-OAuth users */} + {!user.is_oauth_user && ( +
+ {/* Change Password */} + + + + Şifre Değiştir + + Güvenliğiniz için güçlü bir şifre kullanın. + + +
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + + +
+ + {/* Change Email */} + + + + Email Değiştir + + Email adresinizi değiştirirseniz yeniden doğrulama yapmanız gerekir. + + +
+ + setNewEmail(e.target.value)} + /> +
+
+ + setEmailPassword(e.target.value)} + /> +
+
+ + + +
+
+ )} + + {/* OAuth Info - Only for OAuth users */} + {user.is_oauth_user && ( + + + Bağlı Hesaplar + Hesabınız aşağıdaki sosyal medya platformlarına bağlıdır. + + +
+ {user.social_accounts?.map((account) => ( +
+
+ {account.provider === 'google' ? 'G' : account.provider.charAt(0).toUpperCase()} +
+
+

{account.provider}

+

{account.email}

+
+
+ ))} + {(!user.social_accounts || user.social_accounts.length === 0) && ( +

Bağlı hesap bilgisi bulunamadı (OAuth User flag true olmasına rağmen).

+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..6cf72ed --- /dev/null +++ b/app/globals.css @@ -0,0 +1,125 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9790eb7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import StoreProvider from "@/components/StoreProvider"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Header } from "@/components/header/header"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Next Go Blog", + description: "Advanced blog application", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + +
+
{children}
+ + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..295f8fd --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
+

+ To get started, edit the page.tsx file. +

+

+ Looking for a starting point or more instructions? Head over to{" "} + + Templates + {" "} + or the{" "} + + Learning + {" "} + center. +

+
+ +
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..f87021e --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/StoreProvider.tsx b/components/StoreProvider.tsx new file mode 100644 index 0000000..cb96361 --- /dev/null +++ b/components/StoreProvider.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useRef } from "react"; +import { Provider } from "react-redux"; +import { makeStore, AppStore } from "@/lib/store"; +import { restoreSession } from "@/lib/features/auth/authSlice"; + +export default function StoreProvider({ + children, +}: { + children: React.ReactNode; +}) { + const storeRef = useRef(null); + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore(); + // Restore session from localStorage + storeRef.current.dispatch(restoreSession()); + } + + return {children}; +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx new file mode 100644 index 0000000..f302ef9 --- /dev/null +++ b/components/app-sidebar.tsx @@ -0,0 +1,106 @@ +"use client" + +import { Home, Users, Settings, LogOut, Shield } from "lucide-react" +import Link from "next/link" +import { usePathname } from "next/navigation" + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarHeader, +} from "@/components/ui/sidebar" +import { useAppDispatch, useAppSelector } from "@/lib/hooks" +import { logout } from "@/lib/features/auth/authSlice" + +// Menu items. +const items = [ + { + title: "Genel Bakış", + url: "/admin", + icon: Home, + }, + { + title: "Kullanıcılar", + url: "/admin/users", + icon: Users, + }, + { + title: "Ayarlar", + url: "/admin/settings", + icon: Settings, + }, + { + title: "CORS Ayarları", + url: "/admin/cors", + icon: Shield, + }, +] + +export function AppSidebar() { + const pathname = usePathname() + const dispatch = useAppDispatch() + const user = useAppSelector((state) => state.auth.user) + const isAdmin = user?.roles?.some((r: any) => r.name === "admin") + + return ( + + + NextGoBlog + + + {isAdmin && ( + + Admin Panel + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + )} + + + Hesabım + + + + + + + Profilim + + + + + + + + + + + dispatch(logout())}> + + Çıkış Yap + + + + + + ) +} diff --git a/components/cors/cors-dialog.tsx b/components/cors/cors-dialog.tsx new file mode 100644 index 0000000..bacae64 --- /dev/null +++ b/components/cors/cors-dialog.tsx @@ -0,0 +1,107 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useState, useEffect } from "react"; +import { CorsEntry } from "@/lib/features/cors/corsSlice"; + +interface CorsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: "whitelist" | "blacklist"; + entry: CorsEntry | null; + onSubmit: (origin: string, note: string) => Promise; +} + +export function CorsDialog({ open, onOpenChange, type, entry, onSubmit }: CorsDialogProps) { + const [origin, setOrigin] = useState(""); + const [note, setNote] = useState(""); // Description or Reason + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + if (entry) { + setOrigin(entry.origin); + setNote(type === "whitelist" ? (entry.description || "") : (entry.reason || "")); + } else { + setOrigin(""); + setNote(""); + } + } + }, [open, entry, type]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await onSubmit(origin, note); + onOpenChange(false); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + const isEdit = !!entry; + const typeLabel = type === "whitelist" ? "Whitelist" : "Blacklist"; + const noteLabel = type === "whitelist" ? "Açıklama" : "Sebep"; + const title = `${typeLabel} ${isEdit ? "Düzenle" : "Ekle"}`; + const description = isEdit + ? "Mevcut kaydı düzenleyin." + : "Listeye yeni bir domain ekleyin."; + + return ( + + + + {title} + + {description} + + +
+
+
+ + setOrigin(e.target.value)} + required + /> +
+
+ + setNote(e.target.value)} + /> +
+
+ + + +
+
+
+ ); +} diff --git a/components/cors/cors-edit-dialog.tsx b/components/cors/cors-edit-dialog.tsx new file mode 100644 index 0000000..ac85bf0 --- /dev/null +++ b/components/cors/cors-edit-dialog.tsx @@ -0,0 +1,99 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useState, useEffect } from "react"; +import { CorsEntry } from "@/lib/features/cors/corsSlice"; + +interface CorsEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + entry: CorsEntry | null; + type: "whitelist" | "blacklist"; + onUpdate: (id: string, origin: string, note: string) => Promise; +} + +export function CorsEditDialog({ open, onOpenChange, entry, type, onUpdate }: CorsEditDialogProps) { + const [origin, setOrigin] = useState(""); + const [note, setNote] = useState(""); // Description or Reason + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (entry) { + setOrigin(entry.origin); + setNote(entry.description || entry.reason || ""); + } + }, [entry]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!entry) return; + + setLoading(true); + try { + await onUpdate(entry.id, origin, note); + onOpenChange(false); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + const typeLabel = type === "whitelist" ? "Whitelist" : "Blacklist"; + const noteLabel = type === "whitelist" ? "Açıklama" : "Sebep"; + + return ( + + + + {typeLabel} Güncelle + + Mevcut kaydı düzenleyin. + + +
+
+
+ + setOrigin(e.target.value)} + required + /> +
+
+ + setNote(e.target.value)} + /> +
+
+ + + +
+
+
+ ); +} diff --git a/components/cors/cors-table.tsx b/components/cors/cors-table.tsx new file mode 100644 index 0000000..bb086a0 --- /dev/null +++ b/components/cors/cors-table.tsx @@ -0,0 +1,93 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Trash2, Edit } from "lucide-react"; +import { CorsEntry } from "@/lib/features/cors/corsSlice"; + +interface CorsTableProps { + data: CorsEntry[]; + type: "whitelist" | "blacklist"; + onDelete: (id: string) => void; + onEdit: (entry: CorsEntry) => void; + onToggleActive: (id: string, currentStatus: boolean) => void; +} + +export function CorsTable({ data, type, onDelete, onEdit, onToggleActive }: CorsTableProps) { + return ( +
+ + + + Origin + {type === "whitelist" ? "Açıklama" : "Sebep"} + Durum + Oluşturan + İşlemler + + + + {data.length === 0 ? ( + + + Kayıt bulunamadı. + + + ) : ( + data.map((entry) => ( + + {entry.origin} + + {type === "whitelist" ? entry.description : entry.reason} + + +
+ onToggleActive(entry.id, entry.is_active)} + /> + + {entry.is_active ? "Aktif" : "Pasif"} + +
+
+ + {entry.created_by} + + + + + +
+ )) + )} +
+
+
+ ); +} diff --git a/components/header/header.tsx b/components/header/header.tsx new file mode 100644 index 0000000..b45d7f9 --- /dev/null +++ b/components/header/header.tsx @@ -0,0 +1,172 @@ +"use client"; + +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { useAppSelector, useAppDispatch } from "@/lib/hooks"; +import { logout } from "@/lib/features/auth/authSlice"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Sun, Moon, LogOut, User, LayoutDashboard, Menu } from "lucide-react"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { useState, useEffect } from "react"; +import { getAvatarUrl } from "@/lib/utils"; + +export function Header() { + const { setTheme, theme } = useTheme(); + const dispatch = useAppDispatch(); + const { isAuthenticated, user } = useAppSelector((state) => state.auth); + const [mounted, setMounted] = useState(false); + + // Prevent hydration mismatch + useEffect(() => { + setMounted(true); + }, []); + + const handleLogout = () => { + dispatch(logout()); + }; + + if (!mounted) { + return ( +
+
+
+ + + NextGoBlog + + + +
+
+
+ + +
+
+
+
+ ) + } + + return ( +
+
+ {/* Desktop Interface */} +
+ + + NextGoBlog + + + +
+ + {/* Mobile Interface */} + + + + + + + NextGoBlog + + + + + + {/* Right Side */} +
+ + + {isAuthenticated ? ( + + + + + + +
+

{user?.username}

+

+ {user?.email} +

+
+
+ + {user?.roles?.some((r: any) => r.name === "admin") && ( + + + + Admin Panel + + + )} + + + + Profil + + + + + + Çıkış Yap + +
+
+ ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..a32b909 --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children} +} diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..1ac1570 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..5a5b892 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { } + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..6ef5b16 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "@radix-ui/react-slot" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>