feat(auth): enhance login flow with notifications and path normalization
- 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:
11
CLI.md
11
CLI.md
@@ -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:
|
||||||
|
|||||||
87
cmd/cli/commands_password_hash.go
Normal file
87
cmd/cli/commands_password_hash.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
2
pkg/serverembed/dist/index.html
vendored
2
pkg/serverembed/dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
@@ -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 || "");
|
||||||
|
const currentPath = normalizePath(window.location.pathname);
|
||||||
|
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();
|
this.clearAuth();
|
||||||
window.location.href = "/ui/login";
|
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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user