mirror of
https://github.com/arkorty/Reduce.git
synced 2026-03-18 00:47:10 +00:00
feat: version 2.1
This commit is contained in:
@@ -41,9 +41,9 @@ A minimal URL shortener with user accounts, custom short codes, and password-pro
|
|||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
| ------ | ---------------------- | -------- | ------------------------------------- |
|
| ------ | ---------------------- | -------- | ------------------------------------- |
|
||||||
| POST | `/reduce/shorten` | Optional | Create short link |
|
| POST | `/shorten` | Optional | Create short link |
|
||||||
| GET | `/reduce/:code` | — | Resolve short code |
|
| GET | `/:code` | — | Resolve short code |
|
||||||
| POST | `/reduce/:code/verify` | — | Verify credentials for protected link |
|
| POST | `/:code/verify` | — | Verify credentials for protected link |
|
||||||
|
|
||||||
### Links (dashboard)
|
### Links (dashboard)
|
||||||
|
|
||||||
|
|||||||
182
backend/auth.go
182
backend/auth.go
@@ -56,11 +56,21 @@ func parseToken(tokenStr string) (*JWTClaims, error) {
|
|||||||
// JWTMiddleware requires a valid JWT token
|
// JWTMiddleware requires a valid JWT token
|
||||||
func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
|
// Try to get token from cookie first
|
||||||
|
cookie, err := c.Cookie("token")
|
||||||
|
var tokenStr string
|
||||||
|
if err == nil && cookie != nil {
|
||||||
|
tokenStr = cookie.Value
|
||||||
|
} else {
|
||||||
|
// Fallback to Authorization header for backward compatibility
|
||||||
auth := c.Request().Header.Get("Authorization")
|
auth := c.Request().Header.Get("Authorization")
|
||||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing or invalid token")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing or invalid token")
|
||||||
}
|
}
|
||||||
claims, err := parseToken(strings.TrimPrefix(auth, "Bearer "))
|
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := parseToken(tokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token")
|
||||||
}
|
}
|
||||||
@@ -73,9 +83,21 @@ func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
|||||||
// OptionalJWTMiddleware extracts user if token present, doesn't require it
|
// OptionalJWTMiddleware extracts user if token present, doesn't require it
|
||||||
func OptionalJWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
func OptionalJWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
|
// Try to get token from cookie first
|
||||||
|
cookie, err := c.Cookie("token")
|
||||||
|
var tokenStr string
|
||||||
|
if err == nil && cookie != nil {
|
||||||
|
tokenStr = cookie.Value
|
||||||
|
} else {
|
||||||
|
// Fallback to Authorization header
|
||||||
auth := c.Request().Header.Get("Authorization")
|
auth := c.Request().Header.Get("Authorization")
|
||||||
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||||
if claims, err := parseToken(strings.TrimPrefix(auth, "Bearer ")); err == nil {
|
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenStr != "" {
|
||||||
|
if claims, err := parseToken(tokenStr); err == nil {
|
||||||
c.Set("user_id", claims.UserID)
|
c.Set("user_id", claims.UserID)
|
||||||
c.Set("username", claims.Username)
|
c.Set("username", claims.Username)
|
||||||
}
|
}
|
||||||
@@ -122,8 +144,19 @@ func register(c echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set httpOnly cookie
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: os.Getenv("ENV") == "production", // Only secure in production
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 72 * 3600, // 72 hours
|
||||||
|
}
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||||
"token": token,
|
|
||||||
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -152,12 +185,39 @@ func login(c echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set httpOnly cookie
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: os.Getenv("ENV") == "production", // Only secure in production
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 72 * 3600, // 72 hours
|
||||||
|
}
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"token": token,
|
|
||||||
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logout(c echo.Context) error {
|
||||||
|
// Clear the cookie
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: -1, // Delete cookie
|
||||||
|
}
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Logged out successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func getMe(c echo.Context) error {
|
func getMe(c echo.Context) error {
|
||||||
uid := c.Get("user_id").(uint)
|
uid := c.Get("user_id").(uint)
|
||||||
var user User
|
var user User
|
||||||
@@ -169,3 +229,117 @@ func getMe(c echo.Context) error {
|
|||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateUsername(c echo.Context) error {
|
||||||
|
uid := c.Get("user_id").(uint)
|
||||||
|
type Req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
r := new(Req)
|
||||||
|
if err := c.Bind(r); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Username = strings.TrimSpace(r.Username)
|
||||||
|
if len(r.Username) < 3 || len(r.Username) > 32 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Username must be 3–32 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is already taken by another user
|
||||||
|
var existing User
|
||||||
|
if db.Where("username = ? AND id != ?", r.Username, uid).First(&existing).Error == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusConflict, "Username already taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if err := db.First(&user, uid).Error; err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Username = r.Username
|
||||||
|
if err := db.Save(&user).Error; err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update username")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePassword(c echo.Context) error {
|
||||||
|
uid := c.Get("user_id").(uint)
|
||||||
|
type Req struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
r := new(Req)
|
||||||
|
if err := c.Bind(r); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.NewPassword) < 6 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Password must be at least 6 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if err := db.First(&user, uid).Error; err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.CurrentPassword)); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Current password is incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(r.NewPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to process password")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = string(hash)
|
||||||
|
if err := db.Save(&user).Error; err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Password updated successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAccount(c echo.Context) error {
|
||||||
|
uid := c.Get("user_id").(uint)
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if err := db.First(&user, uid).Error; err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all links owned by this user
|
||||||
|
db.Where("user_id = ?", uid).Delete(&Link{})
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
if err := db.Delete(&user).Error; err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete account")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Account deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserStats(c echo.Context) error {
|
||||||
|
uid := c.Get("user_id").(uint)
|
||||||
|
|
||||||
|
var linkCount int
|
||||||
|
var totalClicks int
|
||||||
|
|
||||||
|
db.Model(&Link{}).Where("user_id = ?", uid).Count(&linkCount)
|
||||||
|
db.Model(&Link{}).Where("user_id = ?", uid).Select("COALESCE(SUM(click_count), 0)").Row().Scan(&totalClicks)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"link_count": linkCount,
|
||||||
|
"total_clicks": totalClicks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,6 +148,21 @@ func fetchLURL(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if link.RequiresAuth {
|
if link.RequiresAuth {
|
||||||
|
// Check if user is authenticated and authorized
|
||||||
|
if username, ok := c.Get("username").(string); ok {
|
||||||
|
// User is logged in, check if they match the access credentials
|
||||||
|
if username == link.AccessUsername {
|
||||||
|
// Auto-authorize logged-in user
|
||||||
|
db.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1"))
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"lurl": link.LongURL,
|
||||||
|
"requires_auth": false,
|
||||||
|
"auto_authorized": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User not logged in or doesn't match - require manual auth
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"requires_auth": true,
|
"requires_auth": true,
|
||||||
"code": link.Code,
|
"code": link.Code,
|
||||||
|
|||||||
@@ -15,13 +15,20 @@ func main() {
|
|||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
|
// Frontend URL for CORS
|
||||||
|
frontendURL := os.Getenv("BASE_URL")
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "http://localhost:5173"
|
||||||
|
}
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
e.Use(middleware.Logger())
|
e.Use(middleware.Logger())
|
||||||
e.Use(middleware.Recover())
|
e.Use(middleware.Recover())
|
||||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: []string{frontendURL},
|
||||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
||||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
|
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
|
||||||
|
AllowCredentials: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
@@ -32,12 +39,20 @@ func main() {
|
|||||||
// Auth
|
// Auth
|
||||||
e.POST("/auth/register", register)
|
e.POST("/auth/register", register)
|
||||||
e.POST("/auth/login", login)
|
e.POST("/auth/login", login)
|
||||||
|
e.POST("/auth/logout", logout)
|
||||||
e.GET("/auth/me", getMe, JWTMiddleware)
|
e.GET("/auth/me", getMe, JWTMiddleware)
|
||||||
|
|
||||||
|
// User account management
|
||||||
|
user := e.Group("/user", JWTMiddleware)
|
||||||
|
user.GET("/stats", getUserStats)
|
||||||
|
user.PUT("/username", updateUsername)
|
||||||
|
user.PUT("/password", updatePassword)
|
||||||
|
user.DELETE("/account", deleteAccount)
|
||||||
|
|
||||||
// Public link routes (optional auth for shorten)
|
// Public link routes (optional auth for shorten)
|
||||||
e.POST("/reduce/shorten", shortenURL, OptionalJWTMiddleware)
|
e.POST("/shorten", shortenURL, OptionalJWTMiddleware)
|
||||||
e.GET("/reduce/:code", fetchLURL)
|
e.GET("/:code", fetchLURL, OptionalJWTMiddleware)
|
||||||
e.POST("/reduce/:code/verify", verifyAndRedirect)
|
e.POST("/:code/verify", verifyAndRedirect)
|
||||||
|
|
||||||
// Authenticated link management
|
// Authenticated link management
|
||||||
links := e.Group("/links", JWTMiddleware)
|
links := e.Group("/links", JWTMiddleware)
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Reduce - URL Shortener</title>
|
<title>Reduce - Those Pesky Long URLs</title>
|
||||||
<meta name="description" content="Reduce is a simple and fast URL shortener. Shorten your long links instantly." />
|
<meta name="description" content="Reduce is a URL shortener that lets you manage your links. Links are first come first serve!" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Reduce - URL Shortener" />
|
<meta property="og:title" content="Reduce - Those Pesky Long URLs" />
|
||||||
<meta property="og:description" content="Reduce is a simple and fast URL shortener. Shorten your long links instantly." />
|
<meta property="og:description" content="Reduce is a URL shortener that lets you manage your links. Links are first come first serve!" />
|
||||||
<meta property="og:image" content="/og-image.png" />
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:title" content="Reduce - URL Shortener" />
|
<meta property="twitter:title" content="Reduce - Those Pesky Long URLs" />
|
||||||
<meta property="twitter:description" content="Reduce is a simple and fast URL shortener. Shorten your long links instantly." />
|
<meta property="twitter:description" content="Reduce is a URL shortener that lets you manage your links. Links are first come first serve!" />
|
||||||
<meta property="twitter:image" content="/og-image.png" />
|
<meta property="twitter:image" content="/og-image.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Redirect from './pages/Redirect';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Links from './pages/Links';
|
import Links from './pages/Links';
|
||||||
|
import User from './pages/User';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +18,7 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/links" element={<Links />} />
|
<Route path="/links" element={<Links />} />
|
||||||
|
<Route path="/user" element={<User />} />
|
||||||
<Route path="/:code" element={<Redirect />} />
|
<Route path="/:code" element={<Redirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { MdPerson, MdLogout, MdLink } from 'react-icons/md';
|
import { MdPerson, MdLogout, MdLink, MdSettings } from 'react-icons/md';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -21,6 +21,14 @@ export default function Navbar() {
|
|||||||
<MdLink size={16} />
|
<MdLink size={16} />
|
||||||
Links
|
Links
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/user"
|
||||||
|
className="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-100 transition-colors text-sm"
|
||||||
|
title="Account Settings"
|
||||||
|
>
|
||||||
|
<MdSettings size={16} />
|
||||||
|
Account
|
||||||
|
</Link>
|
||||||
<span className="text-zinc-600 text-xs font-mono flex items-center gap-1">
|
<span className="text-zinc-600 text-xs font-mono flex items-center gap-1">
|
||||||
<MdPerson size={14} />
|
<MdPerson size={14} />
|
||||||
{user.username}
|
{user.username}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ interface User {
|
|||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
register: (username: string, password: string) => Promise<void>;
|
register: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -19,47 +18,38 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
// Try to authenticate with existing cookie
|
||||||
api
|
api
|
||||||
.get('/auth/me')
|
.get('/auth/me')
|
||||||
.then((res) => setUser(res.data))
|
.then((res) => setUser(res.data))
|
||||||
.catch(() => {
|
.catch(() => setUser(null))
|
||||||
localStorage.removeItem('token');
|
|
||||||
setToken(null);
|
|
||||||
setUser(null);
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
} else {
|
}, []);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
const login = async (username: string, password: string) => {
|
||||||
const res = await api.post('/auth/login', { username, password });
|
const res = await api.post('/auth/login', { username, password });
|
||||||
localStorage.setItem('token', res.data.token);
|
|
||||||
setToken(res.data.token);
|
|
||||||
setUser(res.data.user);
|
setUser(res.data.user);
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (username: string, password: string) => {
|
const register = async (username: string, password: string) => {
|
||||||
const res = await api.post('/auth/register', { username, password });
|
const res = await api.post('/auth/register', { username, password });
|
||||||
localStorage.setItem('token', res.data.token);
|
|
||||||
setToken(res.data.token);
|
|
||||||
setUser(res.data.user);
|
setUser(res.data.user);
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
localStorage.removeItem('token');
|
try {
|
||||||
setToken(null);
|
await api.post('/auth/logout');
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during logout
|
||||||
|
}
|
||||||
setUser(null);
|
setUser(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, login, register, logout, isLoading }}>
|
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080',
|
baseURL: import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080',
|
||||||
});
|
withCredentials: true, // Send cookies with requests
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function Home() {
|
|||||||
payload.requires_auth = true;
|
payload.requires_auth = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/reduce/shorten', payload);
|
const response = await api.post('/shorten', payload);
|
||||||
|
|
||||||
setShortUrl(response.data.surl);
|
setShortUrl(response.data.surl);
|
||||||
setPrevLongUrl(longUrl);
|
setPrevLongUrl(longUrl);
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ function LinkModal({ mode, link, onClose, onSaved }: LinkModalProps) {
|
|||||||
const baseURL = window.location.hostname === 'r.webark.in'
|
const baseURL = window.location.hostname === 'r.webark.in'
|
||||||
? 'https://r.webark.in'
|
? 'https://r.webark.in'
|
||||||
: window.location.origin;
|
: window.location.origin;
|
||||||
await api.post('/reduce/shorten', {
|
await api.post('/shorten', {
|
||||||
lurl: longUrl,
|
lurl: longUrl,
|
||||||
base_url: baseURL,
|
base_url: baseURL,
|
||||||
code: code || undefined,
|
code: code || undefined,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function Redirect() {
|
|||||||
if (!code) return;
|
if (!code) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/reduce/${code}`);
|
const response = await api.get(`/${code}`);
|
||||||
if (response.data.requires_auth) {
|
if (response.data.requires_auth) {
|
||||||
setNeedsAuth(true);
|
setNeedsAuth(true);
|
||||||
} else if (response.data.lurl) {
|
} else if (response.data.lurl) {
|
||||||
@@ -39,7 +39,7 @@ export default function Redirect() {
|
|||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/reduce/${code}/verify`, { username, password });
|
const res = await api.post(`/${code}/verify`, { username, password });
|
||||||
if (res.data.lurl) {
|
if (res.data.lurl) {
|
||||||
window.location.replace(res.data.lurl);
|
window.location.replace(res.data.lurl);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
288
frontend/src/pages/User.tsx
Normal file
288
frontend/src/pages/User.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import api from '../lib/api';
|
||||||
|
import { MdPerson, MdLock, MdDelete, MdSave, MdBarChart } from 'react-icons/md';
|
||||||
|
|
||||||
|
interface UserStats {
|
||||||
|
link_count: number;
|
||||||
|
total_clicks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function User() {
|
||||||
|
const { user, isLoading, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<UserStats>({ link_count: 0, total_clicks: 0 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
// Username update
|
||||||
|
const [newUsername, setNewUsername] = useState('');
|
||||||
|
const [updatingUsername, setUpdatingUsername] = useState(false);
|
||||||
|
|
||||||
|
// Password update
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [updatingPassword, setUpdatingPassword] = useState(false);
|
||||||
|
|
||||||
|
// Delete account
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setNewUsername(user.username);
|
||||||
|
fetchStats();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/user/stats');
|
||||||
|
setStats(res.data);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load stats');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUsername = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
setUpdatingUsername(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.put('/user/username', { username: newUsername });
|
||||||
|
setSuccess('Username updated successfully');
|
||||||
|
// Update the user in context would require refetching /auth/me
|
||||||
|
window.location.reload(); // Simple way to update context
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to update username');
|
||||||
|
} finally {
|
||||||
|
setUpdatingUsername(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingPassword(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put('/user/password', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
setSuccess('Password updated successfully');
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to update password');
|
||||||
|
} finally {
|
||||||
|
setUpdatingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete('/user/account');
|
||||||
|
logout();
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to delete account');
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="font-mono text-zinc-500 text-sm tracking-widest animate-pulse">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pt-20 pb-12 px-4">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase mb-8">
|
||||||
|
Account Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-emerald-500 text-emerald-400">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MdBarChart size={20} className="text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-bold tracking-widest text-zinc-100 uppercase">Statistics</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-emerald-400">{stats.link_count}</div>
|
||||||
|
<div className="text-xs text-zinc-500 uppercase tracking-wide">Total Links</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-emerald-400">{stats.total_clicks}</div>
|
||||||
|
<div className="text-xs text-zinc-500 uppercase tracking-wide">Total Clicks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Username */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MdPerson size={20} className="text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-bold tracking-widest text-zinc-100 uppercase">Username</h2>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdateUsername} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wide text-zinc-400 mb-2">
|
||||||
|
New Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updatingUsername || newUsername === user?.username}
|
||||||
|
className="flex items-center gap-2 bg-zinc-200 text-zinc-900 px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<MdSave size={16} /> Update Username
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Password */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MdLock size={20} className="text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-bold tracking-widest text-zinc-100 uppercase">Password</h2>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdatePassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wide text-zinc-400 mb-2">
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wide text-zinc-400 mb-2">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wide text-zinc-400 mb-2">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updatingPassword}
|
||||||
|
className="flex items-center gap-2 bg-zinc-200 text-zinc-900 px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<MdSave size={16} /> Update Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Account */}
|
||||||
|
<div className="bg-zinc-900 border border-red-900/30 p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MdDelete size={20} className="text-red-400" />
|
||||||
|
<h2 className="text-sm font-bold tracking-widest text-red-400 uppercase">Danger Zone</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4">
|
||||||
|
Deleting your account will permanently remove all your data, including all your shortened links. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
{!showDeleteConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="flex items-center gap-2 bg-red-900/20 text-red-400 border border-red-900/50 px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-red-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
<MdDelete size={16} /> Delete Account
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-red-400 font-bold uppercase tracking-wide">
|
||||||
|
Are you absolutely sure?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
className="flex items-center gap-2 bg-red-600 text-white px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
<MdDelete size={16} /> Yes, Delete My Account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="flex items-center gap-2 bg-zinc-800 text-zinc-300 px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user