Compare commits
7 Commits
9983c51e3a
...
34c152a459
Author | SHA1 | Date | |
---|---|---|---|
34c152a459 | |||
ad09e98bba | |||
d3fd5cba16 | |||
64dbb4368c | |||
cb3a6ddc58 | |||
e774f415d8 | |||
d5a22895e7 |
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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)
|
||||
|
12
migrations/00006_add_user_creator.sql
Normal file
12
migrations/00006_add_user_creator.sql
Normal 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
|
12
migrations/00007_add_user_verification.sql
Normal file
12
migrations/00007_add_user_verification.sql
Normal 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
|
@ -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;
|
||||
|
||||
|
@ -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 />,
|
||||
|
@ -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;
|
||||
};
|
||||
|
188
web/src/pages/Admin/Users/Create/index.tsx
Normal file
188
web/src/pages/Admin/Users/Create/index.tsx
Normal 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;
|
@ -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>
|
||||
|
@ -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 });
|
||||
|
||||
|
Reference in New Issue
Block a user