Compare commits

...

7 Commits

Author SHA1 Message Date
34c152a459 fix: correct uri for user creation page 2025-06-05 20:50:44 +02:00
ad09e98bba feat: create user page 2025-06-05 20:50:27 +02:00
d3fd5cba16 feat: create user state 2025-06-05 20:49:52 +02:00
64dbb4368c feat: create user API 2025-06-05 20:49:43 +02:00
cb3a6ddc58 feat: add support for new fields in user table 2025-06-05 20:49:35 +02:00
e774f415d8 feat: craete user route 2025-06-05 20:49:20 +02:00
d5a22895e7 feat: user creator and verified columns 2025-06-05 20:49:05 +02:00
12 changed files with 359 additions and 14 deletions

View File

@ -33,6 +33,7 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
r.Patch("/api-services/toggle/{id}", h.ToggleApiService)
r.Get("/users", h.GetUsers)
r.Post("/users", h.CreateUser)
r.Get("/users/{id}", h.GetUser)
})
}

View File

@ -7,6 +7,7 @@ import (
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@ -79,3 +80,75 @@ func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
}
}
type CreateUserRequest struct {
Email string `json:"email"`
FullName string `json:"full_name"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
}
func (h *AdminHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
web.Error(w, "email is required", http.StatusBadRequest)
return
}
if req.FullName == "" {
web.Error(w, "full name is required", http.StatusBadRequest)
return
}
if req.Password == "" {
web.Error(w, "password is required", http.StatusBadRequest)
return
}
_, err := h.repo.FindUserEmail(r.Context(), req.Email)
if err == nil {
web.Error(w, "user with provided email already exists", http.StatusBadRequest)
return
}
hash, err := util.HashPassword(req.Password)
if err != nil {
log.Println("ERR: Failed to hash password for new user:", err)
web.Error(w, "failed to create user account", http.StatusInternalServerError)
return
}
params := repository.InsertUserParams{
Email: req.Email,
FullName: req.FullName,
PasswordHash: hash,
IsAdmin: false,
}
log.Println("INFO: params for user creation:", params)
userId, err := h.repo.InsertUser(r.Context(), params)
if err != nil {
log.Println("ERR: Failed to insert user into database:", err)
web.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
type Response struct {
ID string `json:"id"`
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{
ID: userId.String(),
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

View File

@ -35,4 +35,6 @@ type User struct {
LastLogin *time.Time `json:"last_login"`
PhoneNumber *string `json:"phone_number"`
ProfilePicture *string `json:"profile_picture"`
CreatedBy *uuid.UUID `json:"created_by"`
EmailVerified bool `json:"email_verified"`
}

View File

@ -12,7 +12,7 @@ import (
)
const findAllUsers = `-- name: FindAllUsers :many
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users
`
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
@ -35,6 +35,8 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
); err != nil {
return nil, err
}
@ -47,7 +49,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
}
const findUserEmail = `-- name: FindUserEmail :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE email = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE email = $1 LIMIT 1
`
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
@ -64,12 +66,14 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
)
return i, err
}
const findUserId = `-- name: FindUserId :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE id = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE id = $1 LIMIT 1
`
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
@ -86,24 +90,27 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
)
return i, err
}
const insertUser = `-- name: InsertUser :one
INSERT INTO users (
email, full_name, password_hash, is_admin
email, full_name, password_hash, is_admin, created_by
) VALUES (
$1, $2, $3, $4
$1, $2, $3, $4, $5
)
RETURNING id
`
type InsertUserParams struct {
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedBy *uuid.UUID `json:"created_by"`
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UUID, error) {
@ -112,6 +119,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
arg.FullName,
arg.PasswordHash,
arg.IsAdmin,
arg.CreatedBy,
)
var id uuid.UUID
err := row.Scan(&id)

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN created_by UUID REFERENCES users (id) ON DELETE SET NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN created_by;
-- +goose StatementEnd

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN email_verified;
-- +goose StatementEnd

View File

@ -3,9 +3,9 @@ SELECT * FROM users;
-- name: InsertUser :one
INSERT INTO users (
email, full_name, password_hash, is_admin
email, full_name, password_hash, is_admin, created_by
) VALUES (
$1, $2, $3, $4
$1, $2, $3, $4, $5
)
RETURNING id;

View File

@ -18,6 +18,7 @@ import NotFoundPage from "./pages/NotFound";
import ApiServiceEditPage from "./pages/Admin/ApiServices/Update";
import AdminUsersPage from "./pages/Admin/Users";
import AdminViewUserPage from "./pages/Admin/Users/View";
import AdminCreateUserPage from "./pages/Admin/Users/Create";
const router = createBrowserRouter([
{
@ -59,7 +60,7 @@ const router = createBrowserRouter([
path: "users",
children: [
{ index: true, element: <AdminUsersPage /> },
// { path: "create", element: <ApiServiceCreatePage /> },
{ path: "create", element: <AdminCreateUserPage /> },
{
path: "view/:userId",
element: <AdminViewUserPage />,

View File

@ -29,3 +29,28 @@ export const adminGetUserApi = async (
return response.data;
};
export interface CreateUserRequest {
email: string;
full_name: string;
password: string;
is_admin: boolean;
}
export interface CreateUserResponse {
id: string;
}
export const postUser = async (
req: CreateUserRequest,
): Promise<CreateUserResponse> => {
const response = await axios.post<CreateUserResponse>(
"/api/v1/admin/users",
req,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -0,0 +1,188 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useUsers } from "@/store/admin/users";
import { useCallback, type FC } from "react";
import { useForm } from "react-hook-form";
import { Link } from "react-router";
interface FormData {
fullName: string;
email: string;
phoneNumber: string;
password: string;
repeatPassword: string;
isAdmin: boolean;
}
const AdminCreateUserPage: FC = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
const createUser = useUsers((state) => state.createUser);
const onSubmit = useCallback(
(data: FormData) => {
console.log("Form submitted:", data);
createUser({
email: data.email,
full_name: data.fullName,
password: data.password,
is_admin: data.isAdmin,
});
},
[createUser],
);
return (
<div className="p-4">
<Breadcrumbs
items={[
{ href: "/admin", label: "Admin" },
{ href: "/admin/users", label: "Users" },
{ label: "Create new User" },
]}
/>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Personal Information */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">
User Personal Info
</h2>
</div>
<div className="p-4">
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">Name</p>
<Input
placeholder="Full Name"
{...register("fullName", { required: "Full Name is required" })}
/>
{errors.fullName && (
<span className="text-red-500 text-sm">
{errors.fullName.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Email Address
</p>
<Input
placeholder="user@company.org"
{...register("email", {
required: true,
pattern: {
value: /\S+@\S+\.\S+/,
message: "Invalid email",
},
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<span className="text-red-500 text-sm">
{errors.email.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Password
</p>
<Input
placeholder="secret"
{...register("password", {
required: true,
validate: (password) => {
if (password.length < 8) {
return "Password must be at least 8 characters long";
}
if (!password.match(/[a-zA-Z]+/gi)) {
return "Password must contain characters";
}
if (password.split("").every((c) => c.toLowerCase() == c)) {
return "Password should contain at least 1 uppercase character";
}
if (!password.match(/\d+/gi)) {
return "Password should contain at least 1 digit";
}
},
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.password && (
<span className="text-red-500 text-sm">
{errors.password.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Repeat Password
</p>
<Input
placeholder="secret-again"
{...register("repeatPassword", {
required: true,
validate: (repeatPassword, { password }) => {
if (repeatPassword != password) {
return "Password does not match";
}
},
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.repeatPassword && (
<span className="text-red-500 text-sm">
{errors.repeatPassword.message}
</span>
)}
</div>
</div>
</div>
{/* Final Section */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">
Final Customization & Submit
</h2>
</div>
<div className="p-4">
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
{...register("isAdmin")}
/>
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span className="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">
Is Admin
</span>
</label>
<div className="flex flex-row items-center justify-between gap-2 mt-4">
<Button type="submit">Create</Button>
<Link to="/admin/users">
<Button
variant="text"
className="text-red-400 hover:text-red-500"
>
Cancel
</Button>
</Link>
</div>
</div>
</div>
</form>
</div>
);
};
export default AdminCreateUserPage;

View File

@ -33,7 +33,7 @@ const AdminUsersPage: FC = () => {
</div>
<div className="p-4 flex flex-row items-center justify-between">
<p className="text-gray-800 dark:text-gray-300">Search...</p>
<Link to="/admin/api-services/create">
<Link to="/admin/users/create">
<Button className="flex flex-row items-center gap-2">
<UserPlus /> Add User
</Button>

View File

@ -1,4 +1,9 @@
import { adminGetUserApi, adminGetUsersApi } from "@/api/admin/users";
import {
adminGetUserApi,
adminGetUsersApi,
postUser,
type CreateUserRequest,
} from "@/api/admin/users";
import type { UserProfile } from "@/types";
import { create } from "zustand";
@ -9,6 +14,9 @@ export interface IUsersState {
current: UserProfile | null;
fetchingCurrent: boolean;
creating: boolean;
createUser: (req: CreateUserRequest) => Promise<void>;
fetchUsers: () => Promise<void>;
fetchUser: (id: string) => Promise<void>;
}
@ -17,9 +25,24 @@ export const useUsers = create<IUsersState>((set) => ({
users: [],
fetching: false,
creating: false,
current: null,
fetchingCurrent: false,
createUser: async (req: CreateUserRequest) => {
set({ creating: true });
try {
const response = await postUser(req);
console.log("INFO: User has been created:", response);
} catch (err) {
console.log("ERR: Failed to create user:", err);
} finally {
set({ creating: false });
}
},
fetchUsers: async () => {
set({ fetching: true });