diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/backend/.idea/.gitignore @@ -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 diff --git a/backend/.idea/backend.iml b/backend/.idea/backend.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/backend/.idea/backend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/go.imports.xml b/backend/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/backend/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml new file mode 100644 index 0000000..e066844 --- /dev/null +++ b/backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/backend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/app/handlers/user.go b/backend/app/handlers/user.go new file mode 100644 index 0000000..fd9d0e5 --- /dev/null +++ b/backend/app/handlers/user.go @@ -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) +} diff --git a/backend/app/models/user.go b/backend/app/models/user.go new file mode 100644 index 0000000..d09dca3 --- /dev/null +++ b/backend/app/models/user.go @@ -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 +} diff --git a/backend/app/repository/user_repository.go b/backend/app/repository/user_repository.go new file mode 100644 index 0000000..f9bdcce --- /dev/null +++ b/backend/app/repository/user_repository.go @@ -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 +} diff --git a/backend/app/routes/routes.go b/backend/app/routes/routes.go index 362525a..2b2dbfd 100644 --- a/backend/app/routes/routes.go +++ b/backend/app/routes/routes.go @@ -18,9 +18,11 @@ import ( func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) { siteRepository := cachedrepo.NewCachedSiteRepository(database.NewSQLiteSiteRepository(db)) deploymentRepository := database.NewSQLiteDeploymentRepository(db) + userRepository := database.NewSQLiteUserRepository(db) - deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository) siteHandler := handlers.NewSiteHandler(siteRepository) + deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository) + userHandler := handlers.NewUserHandler(userRepository) deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository) 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("/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 { return c.Status(fiber.StatusNotFound).JSON(&models.APIError{ Message: "Endpoint not found", diff --git a/backend/internal/database/init_sqlite.go b/backend/internal/database/init_sqlite.go index 7d6b78f..4c9e8cd 100644 --- a/backend/internal/database/init_sqlite.go +++ b/backend/internal/database/init_sqlite.go @@ -59,6 +59,14 @@ CREATE TABLE IF NOT EXISTS deployments ( start_time TIMESTAMP NOT NULL DEFAULT 0, finish_time TIMESTAMP NOT NULL DEFAULT 0, 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 ) `) diff --git a/backend/internal/database/user_sqlite.go b/backend/internal/database/user_sqlite.go new file mode 100644 index 0000000..6c9011e --- /dev/null +++ b/backend/internal/database/user_sqlite.go @@ -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 +} diff --git a/backend/internal/security/password.go b/backend/internal/security/password.go new file mode 100644 index 0000000..2aef301 --- /dev/null +++ b/backend/internal/security/password.go @@ -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 +} diff --git a/frontend/src/api/types/user.ts b/frontend/src/api/types/user.ts new file mode 100644 index 0000000..0dc32e4 --- /dev/null +++ b/frontend/src/api/types/user.ts @@ -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'; +} diff --git a/frontend/src/api/users.api.ts b/frontend/src/api/users.api.ts new file mode 100644 index 0000000..3f3de98 --- /dev/null +++ b/frontend/src/api/users.api.ts @@ -0,0 +1,62 @@ +import { makeApiUrl } from '.'; +import type { CreateUserRequest, UpdateUserRequest, User } from './types/user'; + +export const getUsers = async (): Promise => { + 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 => { + 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 => { + 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): Promise => { + 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 => { + const response = await fetch(makeApiUrl(`/users/${id}`), { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete user'); + } +}; diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index a4ac3f0..53a8487 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -10,7 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { ChartLine, CircleUserRound, LogOut, Settings } from 'lucide-react'; +import { ChartLine, CircleUserRound, LogOut, Settings, Users } from 'lucide-react'; interface NavbarProps { userName?: string; @@ -59,6 +59,12 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => { Analytics + + + + Users + + diff --git a/frontend/src/hooks/api/users/useCreateUser.ts b/frontend/src/hooks/api/users/useCreateUser.ts new file mode 100644 index 0000000..2445210 --- /dev/null +++ b/frontend/src/hooks/api/users/useCreateUser.ts @@ -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'], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/users/useDeleteUser.ts b/frontend/src/hooks/api/users/useDeleteUser.ts new file mode 100644 index 0000000..f7d3f9c --- /dev/null +++ b/frontend/src/hooks/api/users/useDeleteUser.ts @@ -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'], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/users/useUsers.ts b/frontend/src/hooks/api/users/useUsers.ts new file mode 100644 index 0000000..1ffef70 --- /dev/null +++ b/frontend/src/hooks/api/users/useUsers.ts @@ -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, + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e483f2a..eea82e3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from './components/theme-provider'; import './index.css'; import NewSite from './pages/NewSite/NewSite'; import SiteOverview from './pages/SiteOverview/SiteOverview'; +import Users from './pages/Users/Users'; const queryClient = new QueryClient(); @@ -18,6 +19,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx new file mode 100644 index 0000000..1868986 --- /dev/null +++ b/frontend/src/pages/Users/Users.tsx @@ -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 ( + + + + + + + + + Delete user + + Are you sure you want to delete this user? This action cannot be undone. + + + + setOpen(false)}> + Cancel + + + {deleteUser.isPending ? 'Deleting...' : 'Delete user'} + + + + + ); +}; + +const UserRow = ({ user }: { user: User }) => { + return ( + + + {user.name.charAt(0).toUpperCase()} + + + {user.name} + {user.role} + + + + + + ); +}; + +const UsersLoadingSkeleton = memo(() => { + return ( + + {[1, 2, 3].map((i) => ( + + ))} + + ); +}); + +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 ( + + + + + Add user + + + + + + Create user + + Add a new user. They'll be able to sign in with the password you + set. + + + + + + Name + setName(e.target.value)} + placeholder="Jane Doe" + autoFocus + required + /> + + + + Role + setRole(v as 'admin' | 'user')} + > + + + + + User + Admin + + + + + + Password + setPassword(e.target.value)} + placeholder="••••••••" + required + /> + + + {error && ( + + Failed to create user. Please try again. + + )} + + + + handleOpenChange(false)} + disabled={isPending} + > + Cancel + + + {isPending ? 'Creating...' : 'Create user'} + + + + + + ); +}; + +export const Users = () => { + const { data: users, isLoading: loadingUsers, error: usersError } = useUsers(); + + if (loadingUsers || usersError) { + return null; + } + + return ( + + + Users + + + + + {loadingUsers && } + {usersError && ( + + + + + + Failed to load users + + An error occurred while fetching your users. Please try again later. + + + + )} + {users && + users.length > 0 && + !usersError && + !loadingUsers && + users.map((user) => )} + {users && !usersError && !loadingUsers && users.length === 0 && ( + + + + + + + No users found + + + You haven't added any users yet. + + + + )} + + + ); +}; + +export default Users;
{user.name}
{user.role}
+ Failed to create user. Please try again. +