Add frontend #1
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"quay/app/models"
|
||||||
|
"quay/app/repository"
|
||||||
|
"quay/internal/security"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserHandler struct {
|
||||||
|
Repo repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserHandler(repo repository.UserRepository) *UserHandler {
|
||||||
|
return &UserHandler{Repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) GetAllUsers(c fiber.Ctx) error {
|
||||||
|
users, err := h.Repo.GetAllUsers()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Println("Error getting user: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while getting site",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if users == nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range users {
|
||||||
|
users[i].HashedPassword = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserById godoc
|
||||||
|
// @Summary Get user by ID
|
||||||
|
// @Description Get a single user by its ID
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {object} models.User
|
||||||
|
// @Failure 404 {object} models.APIError
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
func (h *UserHandler) GetUserById(c fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
user, err := h.Repo.GetUserById(id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Println("Error getting user: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while getting site",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
user.HashedPassword = ""
|
||||||
|
|
||||||
|
return c.JSON(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByName godoc
|
||||||
|
// @Summary Get user by Name
|
||||||
|
// @Description Get a single user by its Name
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param name path string true "User Name"
|
||||||
|
// @Success 200 {object} models.User
|
||||||
|
// @Failure 404 {object} models.APIError
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
func (h *UserHandler) GetUserByName(c fiber.Ctx) error {
|
||||||
|
name := c.Params("name")
|
||||||
|
|
||||||
|
user, err := h.Repo.GetUserByName(name)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Println("Error getting user: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while getting site",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
user.HashedPassword = ""
|
||||||
|
|
||||||
|
return c.JSON(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser godoc
|
||||||
|
// @Summary Create a new user
|
||||||
|
// @Description Create a new user with the provided name, role, and password
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} models.User
|
||||||
|
// @Failure 400 {object} models.APIError
|
||||||
|
// @Failure 409 {object} models.APIError
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||||
|
var createUserRequest models.CreateUserRequest
|
||||||
|
|
||||||
|
if err := c.Bind().Body(&createUserRequest); err != nil {
|
||||||
|
log.Println("Error parsing body: ", err)
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.ValidateCreateUserRequest(&createUserRequest); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||||
|
Message: "Invalid request body: " + err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := security.HashPassword(createUserRequest.Password)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error hashing password: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while hashing password",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
user := models.User{
|
||||||
|
Name: createUserRequest.Name,
|
||||||
|
Role: createUserRequest.Role,
|
||||||
|
HashedPassword: hashedPassword,
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.DateTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.Repo.CreateUser(&user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error creating user: ", err)
|
||||||
|
if errors.Is(err, repository.ErrUserAlreadyExists) {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(&models.APIError{
|
||||||
|
Message: "User name already taken",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while creating user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser godoc
|
||||||
|
// @Summary Update an existing user
|
||||||
|
// @Description Update an existing user with the provided name, role, and password
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {object} models.User
|
||||||
|
// @Failure 400 {object} models.APIError
|
||||||
|
// @Failure 404 {object} models.APIError
|
||||||
|
// @Failure 409 {object} models.APIError
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
func (h *UserHandler) UpdateUser(c fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
var updateUserRequest models.UpdateUserRequest
|
||||||
|
|
||||||
|
if err := c.Bind().Body(&updateUserRequest); err != nil {
|
||||||
|
log.Println("Error parsing body: ", err)
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.ValidateUpdateUserRequest(&updateUserRequest); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||||
|
Message: "Invalid request body: " + err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
user := models.User{
|
||||||
|
ID: id,
|
||||||
|
Name: updateUserRequest.Name,
|
||||||
|
Role: updateUserRequest.Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.Repo.UpdateUser(&user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error updating user: ", err)
|
||||||
|
if errors.Is(err, repository.ErrUserAlreadyExists) {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(&models.APIError{
|
||||||
|
Message: "User name already taken",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while updating user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser godoc
|
||||||
|
// @Summary Delete a user
|
||||||
|
// @Description Delete a user by its ID
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Failure 404 {object} models.APIError
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
if _, err := h.Repo.GetUserById(id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Println("Error checking user before delete: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while deleting user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.Repo.DeleteUser(id)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error deleting user: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while deleting user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
HashedPassword string `json:"hashed_password"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCreateUserRequest(req *CreateUserRequest) error {
|
||||||
|
if req.Name == "" {
|
||||||
|
return errors.New("name is required")
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
return errors.New("role is required")
|
||||||
|
}
|
||||||
|
if req.Role != "admin" && req.Role != "user" {
|
||||||
|
return errors.New("role must be either 'admin' or 'user'")
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
return errors.New("password is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateUpdateUserRequest(req *UpdateUserRequest) error {
|
||||||
|
if req.Name == "" {
|
||||||
|
return errors.New("name is required")
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
return errors.New("role is required")
|
||||||
|
}
|
||||||
|
if req.Role != "admin" && req.Role != "user" {
|
||||||
|
return errors.New("role must be either 'admin' or 'user'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"quay/app/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||||
|
|
||||||
|
type UserRepository interface {
|
||||||
|
GetAllUsers() ([]models.User, error)
|
||||||
|
GetUserById(id string) (*models.User, error)
|
||||||
|
GetUserByName(name string) (*models.User, error)
|
||||||
|
CreateUser(user *models.User) error
|
||||||
|
UpdateUser(user *models.User) error
|
||||||
|
DeleteUser(id string) error
|
||||||
|
}
|
||||||
@@ -18,9 +18,11 @@ import (
|
|||||||
func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) {
|
func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) {
|
||||||
siteRepository := cachedrepo.NewCachedSiteRepository(database.NewSQLiteSiteRepository(db))
|
siteRepository := cachedrepo.NewCachedSiteRepository(database.NewSQLiteSiteRepository(db))
|
||||||
deploymentRepository := database.NewSQLiteDeploymentRepository(db)
|
deploymentRepository := database.NewSQLiteDeploymentRepository(db)
|
||||||
|
userRepository := database.NewSQLiteUserRepository(db)
|
||||||
|
|
||||||
deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository)
|
|
||||||
siteHandler := handlers.NewSiteHandler(siteRepository)
|
siteHandler := handlers.NewSiteHandler(siteRepository)
|
||||||
|
deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository)
|
||||||
|
userHandler := handlers.NewUserHandler(userRepository)
|
||||||
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
|
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
|
||||||
|
|
||||||
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
|
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
|
||||||
@@ -61,6 +63,14 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
|
|||||||
api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
|
api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
|
||||||
api.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
|
api.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
|
||||||
|
|
||||||
|
// Users
|
||||||
|
api.Get("/users", userHandler.GetAllUsers)
|
||||||
|
api.Get("/users/:id", userHandler.GetUserById)
|
||||||
|
api.Get("/users/by-name/:name", userHandler.GetUserByName)
|
||||||
|
api.Post("/users", userHandler.CreateUser)
|
||||||
|
api.Put("/users/:id", userHandler.UpdateUser)
|
||||||
|
api.Delete("/users/:id", userHandler.DeleteUser)
|
||||||
|
|
||||||
api.Use(func(c fiber.Ctx) error {
|
api.Use(func(c fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
Message: "Endpoint not found",
|
Message: "Endpoint not found",
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ CREATE TABLE IF NOT EXISTS deployments (
|
|||||||
start_time TIMESTAMP NOT NULL DEFAULT 0,
|
start_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||||
finish_time TIMESTAMP NOT NULL DEFAULT 0,
|
finish_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
hashed_password TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"quay/app/models"
|
||||||
|
"quay/app/repository"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLiteUserRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLiteUserRepository(db *sql.DB) *SQLiteUserRepository {
|
||||||
|
return &SQLiteUserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ repository.UserRepository = (*SQLiteUserRepository)(nil)
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) GetAllUsers() ([]models.User, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, name, role, hashed_password, created_at
|
||||||
|
FROM users`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []models.User
|
||||||
|
for rows.Next() {
|
||||||
|
s, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("list users scan: %w", err)
|
||||||
|
}
|
||||||
|
users = append(users, *s)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) GetUserById(id string) (*models.User, error) {
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, name, role, hashed_password, created_at
|
||||||
|
FROM users WHERE id = ?`, id)
|
||||||
|
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) GetUserByName(name string) (*models.User, error) {
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, name, role, hashed_password, created_at
|
||||||
|
FROM users WHERE name = ?`, name)
|
||||||
|
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) CreateUser(user *models.User) error {
|
||||||
|
tx, err := r.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create user begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
user.ID = uuid.NewString()
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO users (id, name, role, hashed_password, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
user.ID, user.Name, user.Role, user.HashedPassword, user.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isSQLiteUniqueConstraintError(err) {
|
||||||
|
return fmt.Errorf("create user insert: %w", repository.ErrUserAlreadyExists)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("create user insert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) UpdateUser(user *models.User) error {
|
||||||
|
tx, err := r.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update user begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE users SET name=?, role=? WHERE id=?`,
|
||||||
|
user.Name, user.Role, user.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isSQLiteUniqueConstraintError(err) {
|
||||||
|
return fmt.Errorf("update user: %w", repository.ErrUserAlreadyExists)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) DeleteUser(id string) error {
|
||||||
|
_, err := r.db.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(s scanner) (*models.User, error) {
|
||||||
|
u := new(models.User)
|
||||||
|
err := s.Scan(
|
||||||
|
&u.ID,
|
||||||
|
&u.Name,
|
||||||
|
&u.Role,
|
||||||
|
&u.HashedPassword,
|
||||||
|
&u.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteUserRepository) AdminUserExists() (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin user exists: %w", err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSQLiteUniqueConstraintError(err error) bool {
|
||||||
|
var sqliteErr sqlite3.Error
|
||||||
|
if !errors.As(err, &sqliteErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqliteErr.Code == sqlite3.ErrConstraint && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPasswordHash(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
hashed_password: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
name?: string;
|
||||||
|
role?: 'admin' | 'user';
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { makeApiUrl } from '.';
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, User } from './types/user';
|
||||||
|
|
||||||
|
export const getUsers = async (): Promise<User[]> => {
|
||||||
|
const response = await fetch(makeApiUrl('/users'), {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch sites');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserById = async (id: string): Promise<User> => {
|
||||||
|
const response = await fetch(makeApiUrl(`/users/${id}`), {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch user');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (data: CreateUserRequest): Promise<User> => {
|
||||||
|
const response = await fetch(makeApiUrl('/users'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create user');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (id: string, data: Partial<UpdateUserRequest>): Promise<User> => {
|
||||||
|
const response = await fetch(makeApiUrl(`/users/${id}`), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update user');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (id: string): Promise<void> => {
|
||||||
|
const response = await fetch(makeApiUrl(`/users/${id}`), {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { ChartLine, CircleUserRound, LogOut, Settings } from 'lucide-react';
|
import { ChartLine, CircleUserRound, LogOut, Settings, Users } from 'lucide-react';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
@@ -59,6 +59,12 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => {
|
|||||||
Analytics
|
Analytics
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/users">
|
||||||
|
<Users />
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/settings">
|
<Link to="/settings">
|
||||||
<Settings />
|
<Settings />
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createUser } from '../../../api/users.api';
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ['users'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { deleteUser } from '../../../api/users.api';
|
||||||
|
|
||||||
|
export function useDeleteUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (userId: string) => deleteUser(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ['users'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getUsers } from '../../../api/users.api';
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: getUsers,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ThemeProvider } from './components/theme-provider';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import NewSite from './pages/NewSite/NewSite';
|
import NewSite from './pages/NewSite/NewSite';
|
||||||
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
||||||
|
import Users from './pages/Users/Users';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="/" element={<MainPage />} />
|
<Route path="/" element={<MainPage />} />
|
||||||
<Route path="/sites/new" element={<NewSite />} />
|
<Route path="/sites/new" element={<NewSite />} />
|
||||||
<Route path="/sites/:id" element={<SiteOverview />} />
|
<Route path="/sites/:id" element={<SiteOverview />} />
|
||||||
|
<Route path="/users" element={<Users />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { Plus, Trash2, UserX, X } from 'lucide-react';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '../../components/ui/empty';
|
||||||
|
import { useUsers } from '../../hooks/api/users/useUsers';
|
||||||
|
import { useCreateUser } from '../../hooks/api/users/useCreateUser';
|
||||||
|
import Page from '../Page';
|
||||||
|
import { Skeleton } from '../../components/ui/skeleton';
|
||||||
|
import { memo, useState } from 'react';
|
||||||
|
import type { User } from '../../api/types/user';
|
||||||
|
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '../../components/ui/dialog';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Label } from '../../components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../../components/ui/select';
|
||||||
|
import { useDeleteUser } from '../../hooks/api/users/useDeleteUser';
|
||||||
|
|
||||||
|
const DeleteUserDialog = ({ userId }: { userId: string }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteUser.mutate(userId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon-sm">
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete user</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this user? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteUser.isPending}
|
||||||
|
>
|
||||||
|
{deleteUser.isPending ? 'Deleting...' : 'Delete user'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserRow = ({ user }: { user: User }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg border">
|
||||||
|
<Avatar size="lg">
|
||||||
|
<AvatarFallback>{user.name.charAt(0).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<DeleteUserDialog userId={user.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UsersLoadingSkeleton = memo(() => {
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateUserDialog = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const { mutate: createUser, isPending, error, reset } = useCreateUser();
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName('');
|
||||||
|
setRole('user');
|
||||||
|
setPassword('');
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !password) return;
|
||||||
|
|
||||||
|
createUser(
|
||||||
|
{ name: name.trim(), role, password },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0 && password.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="default" variant="default">
|
||||||
|
<Plus />
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create user</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new user. They'll be able to sign in with the password you
|
||||||
|
set.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="user-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="user-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="user-role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={role}
|
||||||
|
onValueChange={(v) => setRole(v as 'admin' | 'user')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="user-role" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="user-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="user-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Failed to create user. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!isValid || isPending}>
|
||||||
|
{isPending ? 'Creating...' : 'Create user'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Users = () => {
|
||||||
|
const { data: users, isLoading: loadingUsers, error: usersError } = useUsers();
|
||||||
|
|
||||||
|
if (loadingUsers || usersError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Users">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Users</h1>
|
||||||
|
<CreateUserDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{loadingUsers && <UsersLoadingSkeleton />}
|
||||||
|
{usersError && (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<X />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle className="text-xl">Failed to load users</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
An error occurred while fetching your users. Please try again later.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
{users &&
|
||||||
|
users.length > 0 &&
|
||||||
|
!usersError &&
|
||||||
|
!loadingUsers &&
|
||||||
|
users.map((user) => <UserRow key={user.id} user={user} />)}
|
||||||
|
{users && !usersError && !loadingUsers && users.length === 0 && (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<UserX />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle className="text-xl font-semibold">
|
||||||
|
No users found
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
You haven't added any users yet.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Users;
|
||||||
Reference in New Issue
Block a user