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}