feat(auth): enhance login flow with notifications and path normalization
Some checks failed
CI / Test (1.22) (push) Failing after -30m33s
CI / Test (1.23) (push) Failing after -30m32s
CI / Lint (push) Failing after -30m39s
CI / Build (push) Failing after -30m35s

- add success notification on successful login
- show error notification with detailed message on login failure
- normalize API paths to prevent double slashes and trailing slashes
- redirect to login page only if not on login request or page
This commit is contained in:
2026-03-05 01:03:50 +02:00
parent 1490e0b596
commit 271a0603b8
10 changed files with 230 additions and 29 deletions

11
CLI.md
View File

@@ -150,6 +150,17 @@ All commands support the following global flags:
- `--config <path>`: Path to configuration file - `--config <path>`: Path to configuration file
- `--server <url>`: Server URL (overrides config file) - `--server <url>`: Server URL (overrides config file)
### Password Hash Utility
Generate bcrypt password hashes for user creation and password updates:
```bash
./bin/whatshook-cli password-hash
./bin/whatshook-cli password-hash "my-secret-password"
./bin/whatshook-cli password-hash --password "my-secret-password" --cost 12
echo -n "my-secret-password" | ./bin/whatshook-cli password-hash --stdin
```
### Health Check ### Health Check
Check if the server is running and healthy: Check if the server is running and healthy:

View File

@@ -0,0 +1,87 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
var (
hashPassword string
hashCost int
hashFromStdin bool
)
var passwordHashCmd = &cobra.Command{
Use: "password-hash [password]",
Short: "Generate a bcrypt password hash",
Long: "Generate a bcrypt password hash for creating or updating users.",
Args: cobra.MaximumNArgs(1),
Annotations: map[string]string{
"skip-config-load": "true",
},
Run: func(cmd *cobra.Command, args []string) {
checkError(runPasswordHash(args))
},
}
func init() {
passwordHashCmd.Flags().StringVarP(&hashPassword, "password", "p", "", "Password to hash")
passwordHashCmd.Flags().IntVar(&hashCost, "cost", bcrypt.DefaultCost, "Bcrypt cost")
passwordHashCmd.Flags().BoolVar(&hashFromStdin, "stdin", false, "Read password from stdin")
}
func runPasswordHash(args []string) error {
if hashCost < bcrypt.MinCost || hashCost > bcrypt.MaxCost {
return fmt.Errorf("invalid cost %d (must be between %d and %d)", hashCost, bcrypt.MinCost, bcrypt.MaxCost)
}
password, err := resolveHashPassword(args)
if err != nil {
return err
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), hashCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
fmt.Println(string(hashed))
return nil
}
func resolveHashPassword(args []string) (string, error) {
if hashFromStdin && (hashPassword != "" || len(args) > 0) {
return "", fmt.Errorf("use --stdin by itself (do not combine with --password or positional password)")
}
if hashFromStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("failed to read password from stdin: %w", err)
}
password := strings.TrimRight(string(data), "\r\n")
if password == "" {
return "", fmt.Errorf("password cannot be empty")
}
return password, nil
}
if hashPassword != "" && len(args) > 0 {
return "", fmt.Errorf("use either --password or positional password, not both")
}
if hashPassword != "" {
return hashPassword, nil
}
if len(args) > 0 {
return args[0], nil
}
return readPassword("Password: "), nil
}

View File

@@ -126,7 +126,7 @@ var usersRemoveCmd = &cobra.Command{
} }
func init() { func init() {
usersCmd.PersistentFlags().StringVar(&serverConfigPath, "server-config", "", "server config file (default: config.json or ~/.whatshooked/config.json)") usersCmd.PersistentFlags().StringVar(&serverConfigPath, "server-config", "", "server config file (default: config.json, ../config.json, or ~/.whatshooked/config.json)")
usersAddCmd.Flags().StringVarP(&addUsername, "username", "u", "", "Username") usersAddCmd.Flags().StringVarP(&addUsername, "username", "u", "", "Username")
usersAddCmd.Flags().StringVarP(&addEmail, "email", "e", "", "Email address") usersAddCmd.Flags().StringVarP(&addEmail, "email", "e", "", "Email address")
@@ -156,6 +156,9 @@ func resolveServerConfigPath() string {
if _, err := os.Stat("config.json"); err == nil { if _, err := os.Stat("config.json"); err == nil {
return "config.json" return "config.json"
} }
if _, err := os.Stat("../config.json"); err == nil {
return "../config.json"
}
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err == nil { if err == nil {
p := filepath.Join(home, ".whatshooked", "config.json") p := filepath.Join(home, ".whatshooked", "config.json")

View File

@@ -25,6 +25,10 @@ var rootCmd = &cobra.Command{
Short: "WhatsHooked CLI - Manage WhatsApp webhooks", Short: "WhatsHooked CLI - Manage WhatsApp webhooks",
Long: `A command-line interface for managing WhatsHooked server, hooks, and WhatsApp accounts.`, Long: `A command-line interface for managing WhatsHooked server, hooks, and WhatsApp accounts.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) { PersistentPreRun: func(cmd *cobra.Command, args []string) {
if shouldSkipConfigLoad(cmd) {
return
}
var err error var err error
cliConfig, err = LoadCLIConfig(cfgFile, serverURL) cliConfig, err = LoadCLIConfig(cfgFile, serverURL)
if err != nil { if err != nil {
@@ -44,4 +48,14 @@ func init() {
rootCmd.AddCommand(accountsCmd) rootCmd.AddCommand(accountsCmd)
rootCmd.AddCommand(sendCmd) rootCmd.AddCommand(sendCmd)
rootCmd.AddCommand(usersCmd) rootCmd.AddCommand(usersCmd)
rootCmd.AddCommand(passwordHashCmd)
}
func shouldSkipConfigLoad(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations != nil && c.Annotations["skip-config-load"] == "true" {
return true
}
}
return false
} }

View File

@@ -30,6 +30,7 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/server" "github.com/bitechdev/ResolveSpec/pkg/server"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/uptrace/bun" "github.com/uptrace/bun"
"golang.org/x/crypto/bcrypt"
) )
// WhatsHookedInterface defines the interface for accessing WhatsHooked components // WhatsHookedInterface defines the interface for accessing WhatsHooked components
@@ -681,6 +682,27 @@ func handleQueryCreate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
} }
} }
if req.Table == "users" {
rawPassword, exists := req.Data["password"]
if !exists {
http.Error(w, "Password is required", http.StatusBadRequest)
return
}
password, ok := rawPassword.(string)
if !ok || password == "" {
http.Error(w, "Password is required", http.StatusBadRequest)
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to process password", http.StatusInternalServerError)
return
}
req.Data["password"] = string(hashedPassword)
}
// Convert data map to model using JSON marshaling // Convert data map to model using JSON marshaling
dataJSON, err := json.Marshal(req.Data) dataJSON, err := json.Marshal(req.Data)
if err != nil { if err != nil {
@@ -732,6 +754,27 @@ func handleQueryUpdate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
return return
} }
if req.Table == "users" {
if rawPassword, exists := req.Data["password"]; exists {
password, ok := rawPassword.(string)
if !ok {
http.Error(w, "Invalid password format", http.StatusBadRequest)
return
}
if password == "" {
delete(req.Data, "password")
} else {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to process password", http.StatusInternalServerError)
return
}
req.Data["password"] = string(hashedPassword)
}
}
}
updateQuery := db.NewUpdate().Model(model).Where("id = ?", req.ID) updateQuery := db.NewUpdate().Model(model).Where("id = ?", req.ID)
updatedColumns := 0 updatedColumns := 0

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-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>web</title> <title>web</title>
<script type="module" crossorigin src="/ui/assets/index-BKXFy3Jy.js"></script> <script type="module" crossorigin src="/ui/assets/index-CAlNxuwF.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
</head> </head>
<body> <body>

View File

@@ -22,13 +22,33 @@ import type {
} from "../types"; } from "../types";
function getApiBaseUrl(): string { function getApiBaseUrl(): string {
if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL; if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL.replace(/\/+$/, "");
const { hostname, protocol, port } = window.location; const { hostname, protocol, port } = window.location;
if (hostname === "localhost" || hostname === "127.0.0.1") return "http://localhost:8080"; if (hostname === "localhost" || hostname === "127.0.0.1") return "http://localhost:8080";
return `${protocol}//${hostname}${port ? `:${port}` : ""}`; return `${protocol}//${hostname}${port ? `:${port}` : ""}`.replace(/\/+$/, "");
} }
const API_BASE_URL = getApiBaseUrl(); const API_BASE_URL = getApiBaseUrl();
const LOGIN_API_PATH = "/api/v1/auth/login";
const LOGIN_UI_PATH = "/ui/login";
function normalizePath(path: string): string {
const collapsed = path.replace(/\/{2,}/g, "/");
if (collapsed.length > 1 && collapsed.endsWith("/")) {
return collapsed.slice(0, -1);
}
return collapsed || "/";
}
function getNormalizedPathFromURL(input: string): string {
if (!input) return "/";
try {
return normalizePath(new URL(input, window.location.origin).pathname);
} catch {
const withoutQuery = input.split("?")[0]?.split("#")[0] || "/";
return normalizePath(withoutQuery);
}
}
function normalizeUser(raw: unknown): User | null { function normalizeUser(raw: unknown): User | null {
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;
@@ -96,9 +116,17 @@ class ApiClient {
(response) => response, (response) => response,
(error: AxiosError) => { (error: AxiosError) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Token expired or invalid, clear auth and redirect const requestPath = getNormalizedPathFromURL(error.config?.url || "");
this.clearAuth(); const currentPath = normalizePath(window.location.pathname);
window.location.href = "/ui/login"; const isLoginRequest = requestPath === LOGIN_API_PATH;
const isLoginPage = currentPath === LOGIN_UI_PATH;
// Keep failed login attempts on the same page.
if (!isLoginRequest && !isLoginPage) {
// Token expired or invalid, clear auth and redirect.
this.clearAuth();
window.location.href = LOGIN_UI_PATH;
}
} }
return Promise.reject(error); return Promise.reject(error);
}, },
@@ -122,7 +150,7 @@ class ApiClient {
// Auth endpoints // Auth endpoints
async login(credentials: LoginRequest): Promise<LoginResponse> { async login(credentials: LoginRequest): Promise<LoginResponse> {
const { data } = await this.client.post<LoginResponse>( const { data } = await this.client.post<LoginResponse>(
"/api/v1/auth/login", LOGIN_API_PATH,
credentials, credentials,
); );
if (data.token) { if (data.token) {

View File

@@ -13,6 +13,7 @@ import {
Center, Center,
Box Box
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react'; import { IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
@@ -28,10 +29,24 @@ export default function LoginPage() {
try { try {
await login(username, password); await login(username, password);
notifications.show({
title: 'Login successful',
message: 'Welcome back!',
color: 'green',
});
navigate('/'); navigate('/');
} catch (err) { } catch (err: any) {
// Error is handled in the store const responseData = err?.response?.data;
console.error('Login failed:', err); const notificationMessage =
responseData?.message ||
(typeof responseData === 'string' ? responseData.trim() : '') ||
'Invalid username or password';
notifications.show({
title: 'Login failed',
message: notificationMessage,
color: 'red',
});
} }
}; };