From dd995d9c0f06c01f86d8af87b2f91da1b377a9cb Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 29 Mar 2026 14:26:51 +0000 Subject: [PATCH] Auth with passwords + Playwright E2E tests + PostHog analytics - bcrypt password hashing in auth (register, login, change-password) - Login/register pages with password fields - Profile update + OAuth placeholder endpoints - Playwright test suite: auth, pages, API (3 test files) - PostHog Docker analytics on :8010 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + api/package-lock.json | 10 ++++ api/package.json | 1 + api/src/routes/auth.js | 94 +++++++++++++++++++++++++++----- apps/tasks/app/login/page.tsx | 32 ++++++++++- apps/tasks/app/register/page.tsx | 90 +++++++++++++++++++++++++++++- apps/tasks/lib/api.ts | 2 +- package-lock.json | 79 +++++++++++++++++++++++++++ package.json | 23 ++++++++ playwright.config.ts | 16 ++++++ tests/api.spec.ts | 40 ++++++++++++++ tests/auth.spec.ts | 32 +++++++++++ tests/tasks.spec.ts | 34 ++++++++++++ 13 files changed, 439 insertions(+), 16 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/api.spec.ts create mode 100644 tests/auth.spec.ts create mode 100644 tests/tasks.spec.ts diff --git a/.gitignore b/.gitignore index cdcfdb1..03ce518 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules/ .env *.log .claude/ +test-results/ +test-results.json diff --git a/api/package-lock.json b/api/package-lock.json index 6aaa05d..1e174a7 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -17,6 +17,7 @@ "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", "dotenv": "^17.3.1", "fastify": "^5.8.4", "ioredis": "^5.10.1", @@ -578,6 +579,15 @@ "node": ">= 18" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/api/package.json b/api/package.json index af56901..9bbf679 100644 --- a/api/package.json +++ b/api/package.json @@ -21,6 +21,7 @@ "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", "dotenv": "^17.3.1", "fastify": "^5.8.4", "ioredis": "^5.10.1", diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index 3949ff8..07cc3aa 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -1,29 +1,97 @@ -// Task Team — Auth Routes — 2026-03-29 +// Task Team — Auth Routes with password + OAuth prep — 2026-03-29 +const bcrypt = require('bcryptjs'); + async function authRoutes(app) { - // Simple JWT auth for now, Supabase integration later + // Register with password app.post('/auth/register', async (req) => { - const { email, name, phone, password } = req.body; + const { email, name, phone, password, language } = req.body; + if (!email || !name || !password) throw { statusCode: 400, message: 'Email, name and password required' }; + if (password.length < 6) throw { statusCode: 400, message: 'Password must be at least 6 characters' }; + + // Check if email exists + const { rows: existing } = await app.db.query('SELECT id FROM users WHERE email = $1', [email]); + if (existing.length) throw { statusCode: 409, message: 'Email already registered' }; + + const hash = await bcrypt.hash(password, 12); const { rows } = await app.db.query( - 'INSERT INTO users (email, name, phone) VALUES ($1, $2, $3) RETURNING id, email, name', - [email, name, phone] + 'INSERT INTO users (email, name, phone, auth_provider, language, settings) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, name, phone, language', + [email, name, phone, 'email', language || 'cs', JSON.stringify({ password_hash: hash })] ); - const token = app.jwt.sign({ id: rows[0].id, email: rows[0].email }); + const token = app.jwt.sign({ id: rows[0].id, email: rows[0].email }, { expiresIn: '7d' }); return { data: { user: rows[0], token } }; }); + // Login with password app.post('/auth/login', async (req) => { - const { email } = req.body; - const { rows } = await app.db.query('SELECT id, email, name FROM users WHERE email = $1', [email]); - if (!rows.length) throw { statusCode: 401, message: 'User not found' }; - const token = app.jwt.sign({ id: rows[0].id, email: rows[0].email }); - return { data: { user: rows[0], token } }; + const { email, password } = req.body; + if (!email) throw { statusCode: 400, message: 'Email required' }; + + const { rows } = await app.db.query('SELECT id, email, name, phone, language, settings FROM users WHERE email = $1', [email]); + if (!rows.length) throw { statusCode: 401, message: 'Invalid credentials' }; + + const user = rows[0]; + const hash = user.settings?.password_hash; + + // If user has password, verify it + if (hash && password) { + const valid = await bcrypt.compare(password, hash); + if (!valid) throw { statusCode: 401, message: 'Invalid credentials' }; + } else if (hash && !password) { + throw { statusCode: 400, message: 'Password required' }; + } + // If no hash (legacy user), allow login without password (backward compat) + + const token = app.jwt.sign({ id: user.id, email: user.email }, { expiresIn: '7d' }); + const { settings, ...safeUser } = user; + return { data: { user: safeUser, token } }; }); - app.get('/auth/me', { preHandler: [async (req) => { await req.jwtVerify() }] }, async (req) => { - const { rows } = await app.db.query('SELECT id, email, name, phone, language, settings FROM users WHERE id = $1', [req.user.id]); + // Get current user + app.get('/auth/me', { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => { + const { rows } = await app.db.query( + 'SELECT id, email, name, phone, language, avatar_url, auth_provider, created_at FROM users WHERE id = $1', + [req.user.id] + ); if (!rows.length) throw { statusCode: 404, message: 'User not found' }; return { data: rows[0] }; }); + + // Update profile + app.put('/auth/me', { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => { + const { name, phone, language, avatar_url } = req.body; + const { rows } = await app.db.query( + 'UPDATE users SET name=COALESCE($1,name), phone=COALESCE($2,phone), language=COALESCE($3,language), avatar_url=COALESCE($4,avatar_url), updated_at=NOW() WHERE id=$5 RETURNING id, email, name, phone, language, avatar_url', + [name, phone, language, avatar_url, req.user.id] + ); + return { data: rows[0] }; + }); + + // Change password + app.post('/auth/change-password', { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => { + const { current_password, new_password } = req.body; + if (!new_password || new_password.length < 6) throw { statusCode: 400, message: 'New password must be at least 6 characters' }; + + const { rows } = await app.db.query('SELECT settings FROM users WHERE id = $1', [req.user.id]); + const hash = rows[0]?.settings?.password_hash; + if (hash && current_password) { + const valid = await bcrypt.compare(current_password, hash); + if (!valid) throw { statusCode: 401, message: 'Current password is incorrect' }; + } + + const newHash = await bcrypt.hash(new_password, 12); + await app.db.query( + "UPDATE users SET settings = settings || $1::jsonb, updated_at = NOW() WHERE id = $2", + [JSON.stringify({ password_hash: newHash }), req.user.id] + ); + return { status: 'ok', message: 'Password changed' }; + }); + + // OAuth callback placeholder (for Google/Apple/Facebook) + app.get('/auth/oauth/:provider', async (req) => { + const { provider } = req.params; + // TODO: Implement OAuth flows + return { status: 'not_implemented', provider, message: 'OAuth coming soon. Use email/password.' }; + }); } module.exports = authRoutes; diff --git a/apps/tasks/app/login/page.tsx b/apps/tasks/app/login/page.tsx index d27584f..e380d79 100644 --- a/apps/tasks/app/login/page.tsx +++ b/apps/tasks/app/login/page.tsx @@ -9,6 +9,8 @@ import { useTranslation } from "@/lib/i18n"; export default function LoginPage() { const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const { setAuth } = useAuth(); @@ -24,7 +26,7 @@ export default function LoginPage() { setLoading(true); setError(""); try { - const result = await login({ email: email.trim() }); + const result = await login({ email: email.trim(), password: password || undefined }); setAuth(result.data.token, result.data.user); router.push("/tasks"); } catch (err) { @@ -60,6 +62,28 @@ export default function LoginPage() { /> +
+ +
+ setPassword(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none pr-10" + placeholder="******" + autoComplete="current-password" + /> + +
+
+ + + {password && ( +
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+

{t(`auth.strength.${strength.label}`)}

+
+ )} +

{t("auth.passwordMinLength")}

+
+ +
+ + setConfirmPassword(e.target.value)} + className={`w-full px-3 py-2.5 border rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none ${ + confirmPassword && !passwordsMatch + ? "border-red-400 dark:border-red-600" + : "border-gray-300 dark:border-gray-600" + }`} + placeholder="******" + autoComplete="new-password" + /> + {confirmPassword && !passwordsMatch && ( +

{t("auth.passwordMismatch")}

+ )} +
+