diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts index 7165601..f03cd84 100644 --- a/server/src/routes/auth.routes.ts +++ b/server/src/routes/auth.routes.ts @@ -14,7 +14,12 @@ const roleSchema = z.preprocess( const registerSchema = z.object({ email: z.string().trim().email(), - password: z.string().min(6), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain at least 1 uppercase letter') + .regex(/[0-9]/, 'Password must contain at least 1 number') + .regex(/[!@#$%^&*]/, 'Password must contain at least 1 special character (!@#$%^&*)'), displayName: z.string().trim().min(1).max(100).optional(), name: z.string().trim().min(1).max(100).optional(), role: roleSchema, diff --git a/web/package-lock.json b/web/package-lock.json index 33c6ae4..8e42f07 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -72,7 +72,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1589,7 +1588,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1656,7 +1654,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -2182,7 +2179,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2536,7 +2532,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3166,7 +3161,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3352,7 +3346,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5654,7 +5647,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5664,7 +5656,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6413,7 +6404,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6576,7 +6566,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6862,7 +6851,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/app/agora/[id]/page.tsx b/web/src/app/agora/[id]/page.tsx index f2b4c27..d557138 100644 --- a/web/src/app/agora/[id]/page.tsx +++ b/web/src/app/agora/[id]/page.tsx @@ -1,5 +1,5 @@ "use client"; - +import ReadingProgressBar from "@/components/ReadingProgressBar"; import { useEffect, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; @@ -111,6 +111,8 @@ export default function AgoraDraftDetailPage() { } return ( + <> +
{/* Back link */} @@ -198,6 +200,7 @@ export default function AgoraDraftDetailPage() { )}
+ ); } diff --git a/web/src/app/agora/page.tsx b/web/src/app/agora/page.tsx index b40c9be..108afc2 100644 --- a/web/src/app/agora/page.tsx +++ b/web/src/app/agora/page.tsx @@ -1,5 +1,5 @@ "use client"; - +import ReadingProgressBar from "@/components/ReadingProgressBar"; import { useEffect, useState } from "react"; import { motion } from "framer-motion"; import { MainLayout } from "@/components/layout/MainLayout"; @@ -99,6 +99,7 @@ export default function AgoraPage() { }; return ( + {/* Page Header */} @@ -252,5 +253,6 @@ export default function AgoraPage() { + ); } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 95b516b..1eaf910 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { AmbientBackground } from "@/components/layout/AmbientBackground"; + export const metadata: Metadata = { title: "Writers' Pub — A Premium Ecosystem for Creative Minds", description: "Connect writers, editors, and publishers in a structured ecosystem for writing, feedback, and publishing.", diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 3218252..9d835b4 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -1,6 +1,7 @@ "use client"; import { FormEvent, useEffect, useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; @@ -17,6 +18,7 @@ export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState("eleanor@writerspub.com"); const [password, setPassword] = useState("password123"); + const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -61,14 +63,25 @@ export default function LoginPage() { className="w-full px-4 py-3 rounded-xl bg-[#4a5033]/5 border border-[#4a5033]/10" placeholder="Email" /> - setPassword(e.target.value)} - required - className="w-full px-4 py-3 rounded-xl bg-[#4a5033]/5 border border-[#4a5033]/10" - placeholder="Password" - /> +
+ setPassword(e.target.value)} + required + className="w-full px-4 py-3 pr-12 rounded-xl bg-[#4a5033]/5 border border-[#4a5033]/10" + placeholder="Password" + /> + + +
{error ?

{error}

: null} {loading ? "Signing in..." : "Sign In"} diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index d8ad846..38c33ca 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -15,6 +15,49 @@ interface RegisterResponse { tokens: { accessToken: string; refreshToken: string }; } +interface PasswordRule { + label: string; + message: string; + test: (value: string) => boolean; +} + +const passwordRules: PasswordRule[] = [ + { + label: "8+ characters", + message: "Password must be at least 8 characters", + test: (value) => value.length >= 8, + }, + { + label: "Uppercase letter", + message: "Password must contain at least 1 uppercase letter", + test: (value) => /[A-Z]/.test(value), + }, + { + label: "Number", + message: "Password must contain at least 1 number", + test: (value) => /[0-9]/.test(value), + }, + { + label: "Special character", + message: "Password must contain at least 1 special character (!@#$%^&*)", + test: (value) => /[!@#$%^&*]/.test(value), + }, +]; + +function getPasswordStrength(password: string) { + const passedRules = passwordRules.filter((rule) => rule.test(password)).length; + + if (passedRules <= 1) { + return { label: "Weak", barClass: "bg-rose-500", textClass: "text-rose-600" }; + } + + if (passedRules <= 3) { + return { label: "Medium", barClass: "bg-amber-500", textClass: "text-amber-600" }; + } + + return { label: "Strong", barClass: "bg-emerald-500", textClass: "text-emerald-600" }; +} + export default function SignupPage() { const router = useRouter(); const [displayName, setDisplayName] = useState(""); @@ -23,9 +66,22 @@ export default function SignupPage() { const [role, setRole] = useState("writer"); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [passwordTouched, setPasswordTouched] = useState(false); + + const passwordStrength = getPasswordStrength(password); + const passwordErrors = passwordRules + .filter((rule) => !rule.test(password)) + .map((rule) => rule.message); + + const showPasswordFeedback = passwordTouched || password.length > 0; const onSubmit = async (e: FormEvent) => { e.preventDefault(); + setPasswordTouched(true); + if (passwordErrors.length > 0) { + setError(passwordErrors[0]); + return; + } setLoading(true); setError(""); try { @@ -76,12 +132,48 @@ export default function SignupPage() { setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + setPasswordTouched(true); + setError(""); + }} + onBlur={() => setPasswordTouched(true)} required - minLength={6} + minLength={8} className="w-full px-4 py-3 rounded-xl bg-[#4a5033]/5 border border-[#4a5033]/10" placeholder="Password" /> + {showPasswordFeedback ? ( +
+
+ Password strength + {passwordStrength.label} +
+
+
+
+
    + {passwordRules.map((rule) => { + const isValid = rule.test(password); + return ( +
  • + {isValid ? "✓" : "•"} {rule.message} +
  • + ); + })} +
+
+ ) : null}