diff --git a/server/index.js b/server/index.js index 2ead27d..562ccb7 100644 --- a/server/index.js +++ b/server/index.js @@ -1,16 +1,37 @@ +import 'dotenv/config'; import express from 'express'; import cors from 'cors'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; import { tmpdir } from 'os'; import { join } from 'path'; import { promises as fs } from 'fs'; import { v4 as uuidV4 } from 'uuid'; import Epub from 'epub-gen'; +const requiredEnv = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'JWT_SECRET']; +requiredEnv.forEach((key) => { + if (!process.env[key]) { + console.error(`Missing required environment variable: ${key}`); + process.exit(1); + } +}); + const app = express(); const PORT = process.env.PORT || 4000; const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173'; +const allowedOrigins = ORIGIN.split(',').map((origin) => origin.trim()); -app.use(cors({ origin: ORIGIN, credentials: true })); +const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users'; +const JWT_SECRET = process.env.JWT_SECRET; +import { supabase } from './src/services/supabaseClient.js'; + +app.use( + cors({ + origin: allowedOrigins, + credentials: true, + }), +); app.use(express.json({ limit: '10mb' })); const sanitizeHtml = (text = '') => @@ -22,6 +43,145 @@ const sanitizeHtml = (text = '') => .replace(/'/g, ''') .replace(/\n/g, '
'); +const createToken = (user) => + jwt.sign( + { + sub: user.id, + email: user.email, + username: user.username, + }, + JWT_SECRET, + { expiresIn: '7d' }, + ); + +const mapUserRecord = (record) => ({ + id: record.id, + email: record.email, + name: record.name, + username: record.username, +}); + +const authMiddleware = (req, res, next) => { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) { + return res.status(401).json({ message: 'Yetkisiz erişim' }); + } + try { + const payload = jwt.verify(token, JWT_SECRET); + req.user = payload; + return next(); + } catch (error) { + return res.status(401).json({ message: 'Oturum süresi doldu, lütfen tekrar giriş yap.' }); + } +}; + +const authRouter = express.Router(); + +authRouter.post('/register', async (req, res) => { + const { name, email, password } = req.body || {}; + if (!name || !email || !password) { + return res.status(400).json({ message: 'Ad, email ve şifre gereklidir.' }); + } + const normalizedEmail = email.trim().toLowerCase(); + const username = normalizedEmail.split('@')[0]; + + const { data: existingUser, error: existingError } = await supabase + .from(USERS_TABLE) + .select('id') + .eq('email', normalizedEmail) + .maybeSingle(); + + if (existingError && existingError.code !== 'PGRST116') { + return res.status(500).json({ message: 'Kullanıcı kontrolü başarısız.' }); + } + + if (existingUser) { + return res.status(409).json({ message: 'Bu email adresi ile zaten bir hesap mevcut.' }); + } + + const passwordHash = await bcrypt.hash(password, 10); + const { data: createdUser, error: insertError } = await supabase + .from(USERS_TABLE) + .insert({ + name: name.trim(), + email: normalizedEmail, + username, + password_hash: passwordHash, + }) + .select('id,email,name,username') + .single(); + + if (insertError || !createdUser) { + return res.status(500).json({ message: 'Kullanıcı oluşturulamadı.' }); + } + + const token = createToken(createdUser); + return res.status(201).json({ token, user: mapUserRecord(createdUser) }); +}); + +authRouter.post('/login', async (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) { + return res.status(400).json({ message: 'Email ve şifre gereklidir.' }); + } + + const normalizedEmail = email.trim().toLowerCase(); + const { data: userRecord, error: fetchError } = await supabase + .from(USERS_TABLE) + .select('id,email,name,username,password_hash') + .eq('email', normalizedEmail) + .maybeSingle(); + + if (fetchError && fetchError.code !== 'PGRST116') { + return res.status(500).json({ message: 'Giriş işlemi başarısız.' }); + } + if (!userRecord) { + return res.status(401).json({ message: 'Email veya şifre hatalı.' }); + } + + const validPassword = await bcrypt.compare(password, userRecord.password_hash); + if (!validPassword) { + return res.status(401).json({ message: 'Email veya şifre hatalı.' }); + } + + const token = createToken(userRecord); + return res.json({ token, user: mapUserRecord(userRecord) }); +}); + +authRouter.get('/me', authMiddleware, async (req, res) => { + const { data: userRecord, error } = await supabase + .from(USERS_TABLE) + .select('id,email,name,username') + .eq('id', req.user.sub) + .single(); + + if (error || !userRecord) { + return res.status(404).json({ message: 'Kullanıcı bulunamadı.' }); + } + + return res.json({ user: mapUserRecord(userRecord) }); +}); + +authRouter.post('/logout', (_req, res) => { + return res.json({ message: 'Çıkış yapıldı.' }); +}); + +authRouter.post('/forgot-password', async (req, res) => { + const { email } = req.body || {}; + if (!email) { + return res.status(400).json({ message: 'Email gereklidir.' }); + } + + // Supabase Auth kullanılmadığı için burada sadece bilgilendirici bir cevap döndürüyoruz. + return res.json({ + message: + 'Şifre sıfırlama talebin alındı. Bu demo ortamında e-posta gönderimi aktif değildir.', + }); +}); + +app.use('/auth', authRouter); + app.post('/generate-epub', async (req, res) => { const { text, meta, cover } = req.body || {}; if (!text || !text.trim()) { diff --git a/server/package.json b/server/package.json index de5e497..b56f448 100644 --- a/server/package.json +++ b/server/package.json @@ -8,9 +8,13 @@ "dev": "nodemon index.js" }, "dependencies": { + "@supabase/supabase-js": "^2.45.4", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "dotenv": "^17.2.3", "epub-gen": "^0.1.0", "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", "nodemon": "^3.1.4", "uuid": "^9.0.1" } diff --git a/server/src/services/supabaseClient.js b/server/src/services/supabaseClient.js new file mode 100644 index 0000000..4321f12 --- /dev/null +++ b/server/src/services/supabaseClient.js @@ -0,0 +1,11 @@ +import { createClient } from '@supabase/supabase-js'; + +const requiredEnv = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY']; +requiredEnv.forEach((key) => { + if (!process.env[key]) { + console.error(`Missing required Supabase env var: ${key}`); + process.exit(1); + } +}); + +export const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); diff --git a/src/App.jsx b/src/App.jsx index 0e4f68b..c07ed60 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,8 @@ import { Button, Container, Link, + Menu, + MenuItem, Paper, Snackbar, Stack, @@ -13,8 +15,9 @@ import { Stepper, Typography, } from '@mui/material'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useAppStore } from './store/useAppStore'; +import { fetchCurrentUser, logoutUser } from './utils/authApi'; export const wizardSteps = [ { label: 'Yükle', path: '/' }, @@ -30,17 +33,63 @@ const App = () => { const navigate = useNavigate(); const error = useAppStore((state) => state.error); const clearError = useAppStore((state) => state.clearError); + const initializeAuth = useAppStore((state) => state.initializeAuth); + const updateCurrentUser = useAppStore((state) => state.updateCurrentUser); + const clearAuthSession = useAppStore((state) => state.clearAuthSession); + const currentUser = useAppStore((state) => state.currentUser); + const authToken = useAppStore((state) => state.authToken); + const [menuAnchor, setMenuAnchor] = useState(null); const handleSnackbarClose = (_, reason) => { if (reason === 'clickaway') return; clearError(); }; + useEffect(() => { + initializeAuth(); + }, [initializeAuth]); + + useEffect(() => { + const syncUser = async () => { + if (!authToken) return; + try { + const response = await fetchCurrentUser(authToken); + if (response?.user) { + updateCurrentUser(response.user); + } + } catch (error) { + console.warn('Oturum doğrulanamadı:', error.message); + clearAuthSession(); + } + }; + syncUser(); + }, [authToken, clearAuthSession, updateCurrentUser]); + const activeStep = useMemo(() => { const foundIndex = wizardSteps.findIndex((step) => step.path === location.pathname); return foundIndex === -1 ? 0 : foundIndex; }, [location.pathname]); + const handleMenuOpen = (event) => { + setMenuAnchor(event.currentTarget); + }; + + const handleMenuClose = () => setMenuAnchor(null); + + const handleLogout = async () => { + try { + if (authToken) { + await logoutUser(authToken); + } + } catch (error) { + console.warn('Çıkış işlemi sırasında hata:', error.message); + } finally { + clearAuthSession(); + handleMenuClose(); + navigate('/'); + } + }; + return ( <> { }} > - + { imagepub - navigate('/login')} - sx={{ - background: 'none', - border: 'none', - color: '#2F2D28', - fontWeight: 600, - fontSize: '0.95rem', - cursor: 'pointer', - textTransform: 'none', - letterSpacing: 0.5, - transition: 'color 0.2s ease', - '&:hover': { color: '#d7b16a' }, - }} - > - Login - - + {currentUser ? ( + <> + + {currentUser.username} + + + + Hesap Ayarlarım + EPUB'larım + Çıkış + + + ) : ( + <> + navigate('/login')} + sx={{ + background: 'none', + border: 'none', + color: '#2F2D28', + fontWeight: 600, + fontSize: '0.95rem', + cursor: 'pointer', + textTransform: 'none', + letterSpacing: 0.5, + transition: 'color 0.2s ease', + '&:hover': { color: '#d7b16a' }, + }} + > + Login + + + + )} @@ -105,7 +189,7 @@ const App = () => { fontSize: { xs: '1.9rem', md: '2.6rem' }, }} > - Tek ekranda görselden EPUB'a kadar tüm sihirbaz adımları + Tek ekranda görselden EPUB'a kadar tüm sihirbaz adımları { mx: 'auto', }} > - Kapak seç, crop alanını belirle, OCR ile Türkçe metinleri koru ve sonucunu tek tıkla EPUB olarak indir. - imgpub tüm işlemleri tek bir akışta toplar, hızlı ve modern bir deneyim sunar. + Kapak seç, crop alanını belirle, OCR ile Türkçe metinleri koru ve sonucunu tek tıkla EPUB olarak indir. imgpub tüm işlemleri tek bir akışta toplar, hızlı ve modern bir deneyim sunar. diff --git a/src/main.jsx b/src/main.jsx index ad4bbdb..1663218 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -11,6 +11,7 @@ import EpubStep from './components/EpubStep'; import DownloadStep from './components/DownloadStep'; import Login from './pages/auth/Login'; import Register from './pages/auth/Register'; +import ForgotPassword from './pages/auth/ForgotPassword'; const theme = createTheme({ palette: { @@ -66,7 +67,7 @@ const theme = createTheme({ root: { backgroundColor: '#FFFFFF', border: '1px solid #D2D2D2', - borderRadius: '1.5px', + borderRadius: '16px', boxShadow: '0 10px 18px rgba(0,0,0,0.06)', }, }, @@ -109,7 +110,7 @@ const theme = createTheme({ label: { backgroundColor: '#FFFFFF', border: '1px solid #D2D2D2', - borderRadius: '1.5px', + borderRadius: '16px', padding: '4px 10px', color: '#1C1815', boxShadow: '0 6px 12px rgba(0,0,0,0.05)', @@ -139,6 +140,7 @@ const router = createBrowserRouter([ }, { path: '/login', element: }, { path: '/register', element: }, + { path: '/forgot-password', element: }, ]); ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/pages/auth/AuthPage.jsx b/src/pages/auth/AuthPage.jsx index a14e33f..462dc56 100644 --- a/src/pages/auth/AuthPage.jsx +++ b/src/pages/auth/AuthPage.jsx @@ -1,4 +1,6 @@ +import { useEffect, useMemo, useState } from 'react'; import { + Alert, Box, Button, Container, @@ -9,7 +11,9 @@ import { TextField, Typography, } from '@mui/material'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { useAppStore } from '../../store/useAppStore'; +import { loginUser, registerUser, requestPasswordReset } from '../../utils/authApi'; const copy = { login: { @@ -42,6 +46,21 @@ const copy = { 'Kapak ve önizleme görsellerini tek tuşla üret', ], }, + forgot: { + eyebrow: 'Sıfırlama bağlantısı gönder', + heading: 'Şifreni mi unuttun?', + body: + 'imgpub hesabına tekrar erişmek için email adresini gir. Demo ortamında e-posta gönderimi simüle edilir.', + submit: 'Sıfırlama bağlantısı gönder', + switchLabel: 'Parolanı hatırladın mı?', + switchLink: { to: '/login', label: 'Giriş yap' }, + google: 'Google ile devam et', + highlights: [ + 'Akışını kaybetmeden geri dön', + 'Hızlıca yeni parola belirle', + 'Güvenli studio erişimini koru', + ], + }, }; const fields = { @@ -54,11 +73,72 @@ const fields = { { name: 'email', label: 'Email adresi', type: 'email' }, { name: 'password', label: 'Şifre', type: 'password' }, ], + forgot: [{ name: 'email', label: 'Email adresi', type: 'email' }], +}; + +const highlightIconMap = { + 'Kütüphanendeki projeleri bulutta sakla': 'fa-solid fa-crop-simple', + 'OCR sonrası metinleri EPUB şablonlarına aktar': 'fa-solid fa-book', + 'Toplu crop ile sayfa düzenini koru': 'fa-solid fa-image', + 'Akıllı crop önerileri ile zamandan kazan': 'fa-solid fa-crop-simple', + 'OCR çıktısını EPUB ve PDF olarak taşı': 'fa-solid fa-book', + 'Kapak ve önizleme görsellerini tek tuşla üret': 'fa-solid fa-image', + 'Akışını kaybetmeden geri dön': 'fa-solid fa-rotate-left', + 'Hızlıca yeni parola belirle': 'fa-solid fa-key', + 'Güvenli studio erişimini koru': 'fa-solid fa-lock', }; const AuthPage = ({ mode }) => { const variant = copy[mode]; - const formFields = fields[mode]; + const formFields = fields[mode] || []; + const navigate = useNavigate(); + const setAuthSession = useAppStore((state) => state.setAuthSession); + const [formError, setFormError] = useState(null); + const [formMessage, setFormMessage] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const defaultValues = useMemo( + () => formFields.reduce((acc, field) => ({ ...acc, [field.name]: '' }), {}), + [formFields], + ); + const [formValues, setFormValues] = useState(defaultValues); + + useEffect(() => { + setFormValues(defaultValues); + setFormError(null); + setFormMessage(null); + }, [defaultValues]); + + const handleChange = (event) => { + const { name, value } = event.target; + setFormValues((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + setFormError(null); + setFormMessage(null); + setSubmitting(true); + + try { + if (mode === 'login') { + const response = await loginUser(formValues); + setAuthSession(response); + navigate('/'); + } else if (mode === 'register') { + const response = await registerUser(formValues); + setAuthSession(response); + navigate('/'); + } else if (mode === 'forgot') { + await requestPasswordReset({ email: formValues.email }); + setFormMessage('Şifre sıfırlama talebin alındı. Lütfen e-posta kutunu kontrol et.'); + } + } catch (error) { + setFormError(error.message); + } finally { + setSubmitting(false); + } + }; return ( { { fullWidth size="large" variant="outlined" + disabled startIcon={} sx={{ borderColor: '#E0DFDC', @@ -112,12 +193,19 @@ const AuthPage = ({ mode }) => { py: 1.5, fontWeight: 600, '&:hover': { borderColor: '#CFC8BD', backgroundColor: '#FAF8F6' }, + '&.Mui-disabled': { + opacity: 1, + borderColor: '#E0DFDC', + color: '#1C1815', + backgroundColor: '#FFFFFF', + cursor: 'not-allowed', + }, }} > {variant.google} veya e-posta ile - + {formFields.map((field) => ( { label={field.label} name={field.name} fullWidth + required + value={formValues[field.name] || ''} + onChange={handleChange} variant="outlined" InputProps={{ sx: { borderRadius: 3 } }} + disabled={submitting} /> ))} - {mode === 'login' && ( - Şifreni mi unuttun? Yeni hesap aç + Şifreni mi unuttun? )} - - {variant.switchLabel}{' '} - - {variant.switchLink.label} - - + {variant.switchLabel && variant.switchLink && ( + + {variant.switchLabel}{' '} + + {variant.switchLink.label} + + + )} @@ -189,12 +285,7 @@ const AuthShowcase = ({ mode }) => { {variant.highlights.map((item) => { - const iconMap = { - 'Kütüphanendeki projeleri bulutta sakla': 'fa-solid fa-crop-simple', - 'OCR sonrası metinleri EPUB şablonlarına aktar': 'fa-solid fa-book', - 'Toplu crop ile sayfa düzenini koru': 'fa-solid fa-image', - }; - const iconClass = iconMap[item]; + const iconClass = highlightIconMap[item]; return ( ; + +export default ForgotPassword; diff --git a/src/store/useAppStore.js b/src/store/useAppStore.js index 692b822..7cbfe86 100644 --- a/src/store/useAppStore.js +++ b/src/store/useAppStore.js @@ -1,5 +1,23 @@ import { create } from 'zustand'; +const TOKEN_STORAGE_KEY = 'imgpub_token'; +const USER_STORAGE_KEY = 'imgpub_user'; + +const readStoredAuth = () => { + if (typeof window === 'undefined') { + return { token: null, user: null }; + } + try { + const token = window.localStorage.getItem(TOKEN_STORAGE_KEY); + const userRaw = window.localStorage.getItem(USER_STORAGE_KEY); + const user = userRaw ? JSON.parse(userRaw) : null; + return { token, user }; + } catch (error) { + console.warn('Stored auth okunamadı', error); + return { token: null, user: null }; + } +}; + const createEmptyCropConfig = () => ({ x: 0, y: 0, @@ -27,6 +45,8 @@ export const useAppStore = create((set) => ({ croppedCoverImage: null, ocrText: '', generatedEpub: null, + authToken: null, + currentUser: null, error: null, setError: (message) => set({ error: message }), clearError: () => set({ error: null }), @@ -55,6 +75,33 @@ export const useAppStore = create((set) => ({ } return { generatedEpub: epub }; }), + initializeAuth: () => { + const { token, user } = readStoredAuth(); + if (token && user) { + set({ authToken: token, currentUser: user }); + } + }, + setAuthSession: ({ token, user }) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(TOKEN_STORAGE_KEY, token); + window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + } + set({ authToken: token, currentUser: user }); + }, + updateCurrentUser: (user) => + set((state) => { + if (typeof window !== 'undefined' && state.authToken) { + window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + } + return { currentUser: user }; + }), + clearAuthSession: () => { + if (typeof window !== 'undefined') { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + window.localStorage.removeItem(USER_STORAGE_KEY); + } + set({ authToken: null, currentUser: null }); + }, setCoverImageId: (id) => set((state) => { const draft = { diff --git a/src/utils/apiClient.js b/src/utils/apiClient.js new file mode 100644 index 0000000..3b9ff11 --- /dev/null +++ b/src/utils/apiClient.js @@ -0,0 +1,27 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000'; + +const handleResponse = async (response) => { + const contentType = response.headers.get('content-type'); + const isJson = contentType && contentType.includes('application/json'); + const data = isJson ? await response.json() : await response.text(); + if (!response.ok) { + const message = (isJson && data?.message) || 'Beklenmeyen bir hata oluştu.'; + throw new Error(message); + } + return data; +}; + +export const apiClient = async (path, { method = 'GET', data, token } = {}) => { + const headers = { 'Content-Type': 'application/json' }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}${path}`, { + method, + headers, + body: data ? JSON.stringify(data) : undefined, + }); + + return handleResponse(response); +}; diff --git a/src/utils/authApi.js b/src/utils/authApi.js new file mode 100644 index 0000000..e04fa34 --- /dev/null +++ b/src/utils/authApi.js @@ -0,0 +1,23 @@ +import { apiClient } from './apiClient'; + +export const registerUser = (payload) => apiClient('/auth/register', { method: 'POST', data: payload }); + +export const loginUser = (payload) => apiClient('/auth/login', { method: 'POST', data: payload }); + +export const fetchCurrentUser = (token) => + apiClient('/auth/me', { + method: 'GET', + token, + }); + +export const logoutUser = (token) => + apiClient('/auth/logout', { + method: 'POST', + token, + }); + +export const requestPasswordReset = (payload) => + apiClient('/auth/forgot-password', { + method: 'POST', + data: payload, + });