Compare commits

...

21 Commits

Author SHA1 Message Date
e85b23b3e8 feat: admin view user page 2025-06-04 19:33:41 +02:00
6164b77bee fix: avatar explicit color for icon 2025-06-04 19:33:28 +02:00
8b5a5744ab feat: get single user state 2025-06-04 19:33:12 +02:00
d9e9c5ab38 feat: get single user API 2025-06-04 19:33:07 +02:00
0dcef81b59 feat: get single user endpoint 2025-06-04 19:32:56 +02:00
426b70a1de feat: AdminUsersPage implementation 2025-06-04 19:17:20 +02:00
912973cdb5 feat: admin users state 2025-06-04 19:17:08 +02:00
e4ff799f05 feat: background enhancements 2025-06-04 19:16:48 +02:00
320715f5aa feat: move api services related pages to own folder 2025-06-04 19:11:51 +02:00
a3b04b6243 fix: correct spelling for is_admin 2025-06-04 19:11:28 +02:00
f610d7480f feat: register user related pages 2025-06-04 19:11:10 +02:00
11ac92a026 feat: dynamic user based roles 2025-06-04 19:11:01 +02:00
98ae3e06e9 feat: longer live time for token + correct user based role 2025-06-04 19:10:32 +02:00
a1146ce371 feat: separate file for api services store 2025-06-04 12:49:57 +02:00
a67ec7e78c fix: rename useAdmin to useApiServices 2025-06-04 12:46:48 +02:00
9895392b50 feat: fetch users API 2025-06-04 12:46:36 +02:00
c6998f33e1 feat: move user dto outside 2025-06-04 12:46:24 +02:00
81659181e4 feat: add get users route 2025-06-04 12:33:58 +02:00
849b5935c2 fix: type overriding 2025-06-04 12:33:22 +02:00
c27d837ab0 feat: get users endpoint 2025-06-04 12:33:06 +02:00
92e9b87227 feat: better types overriding 2025-06-04 12:32:47 +02:00
30 changed files with 789 additions and 241 deletions

View File

@ -11,14 +11,13 @@ import (
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type ApiServiceDTO struct {
ID uuid.UUID `json:"id"`
ClientID string `json:"client_id"`
Name string `json:"name"`
Description string `json:"description"`
Description *string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
@ -32,7 +31,7 @@ func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
ID: service.ID,
ClientID: service.ClientID,
Name: service.Name,
Description: service.Description.String,
Description: service.Description,
RedirectUris: service.RedirectUris,
Scopes: service.Scopes,
GrantTypes: service.GrantTypes,
@ -129,10 +128,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
}
if req.Description != "" {
params.Description = pgtype.Text{
String: req.Description,
Valid: true,
}
params.Description = &req.Description
}
service, err := h.repo.CreateApiService(r.Context(), params)
@ -262,12 +258,9 @@ func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request)
}
updated, err := h.repo.UpdateApiService(r.Context(), repository.UpdateApiServiceParams{
ClientID: service.ClientID,
Name: req.Name,
Description: pgtype.Text{
String: req.Description,
Valid: true,
},
ClientID: service.ClientID,
Name: req.Name,
Description: &req.Description,
RedirectUris: req.RedirectUris,
Scopes: req.Scopes,
GrantTypes: req.GrantTypes,

View File

@ -31,5 +31,8 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
r.Patch("/api-services/{id}", h.RegenerateApiServiceSecret)
r.Put("/api-services/{id}", h.UpdateApiService)
r.Patch("/api-services/toggle/{id}", h.ToggleApiService)
r.Get("/users", h.GetUsers)
r.Get("/users/{id}", h.GetUser)
})
}

77
internal/admin/users.go Normal file
View File

@ -0,0 +1,77 @@
package admin
import (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func NewUserDTO(row *repository.User) types.UserDTO {
return types.UserDTO{
ID: row.ID,
Email: row.Email,
FullName: row.FullName,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
LastLogin: row.LastLogin,
PhoneNumber: row.PhoneNumber,
ProfilePicture: row.ProfilePicture,
}
}
func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.repo.FindAllUsers(r.Context())
if err != nil {
log.Println("ERR: Failed to query users from db:", err)
web.Error(w, "failed to get all users", http.StatusInternalServerError)
return
}
type Response struct {
Items []types.UserDTO `json:"items"`
Count int `json:"count"`
}
var items []types.UserDTO
for _, user := range users {
items = append(items, NewUserDTO(&user))
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(&Response{
Items: items,
Count: len(items),
}); err != nil {
web.Error(w, "failed to send response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
userId := chi.URLParam(r, "id")
parsed, err := uuid.Parse(userId)
if err != nil {
web.Error(w, "user id provided is not a valid uuid", http.StatusBadRequest)
return
}
user, err := h.repo.FindUserId(r.Context(), parsed)
if err != nil {
web.Error(w, "user with provided id not found", http.StatusNotFound)
return
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
}
}

View File

@ -32,7 +32,7 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
Issuer: h.cfg.Jwt.Issuer,
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Second)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
@ -160,16 +160,16 @@ func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"id": user.ID.String(),
"full_name": user.FullName,
"email": user.Email,
"phone_number": user.PhoneNumber,
"isAdmin": user.IsAdmin,
"last_login": user.LastLogin,
"profile_picture": user.ProfilePicture.String,
"updated_at": user.UpdatedAt,
"created_at": user.CreatedAt,
if err := json.NewEncoder(w).Encode(types.UserDTO{
ID: user.ID,
FullName: user.FullName,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
IsAdmin: user.IsAdmin,
LastLogin: user.LastLogin,
ProfilePicture: user.ProfilePicture,
UpdatedAt: user.UpdatedAt,
CreatedAt: user.CreatedAt,
}); err != nil {
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
}
@ -219,10 +219,10 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture string `json:"profile_picture"`
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture *string `json:"profile_picture"`
// Avatar
}
@ -234,7 +234,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
ProfilePicture: user.ProfilePicture.String,
ProfilePicture: user.ProfilePicture,
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)

View File

@ -76,14 +76,20 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
return
}
var roles = []string{"user"}
if user.IsAdmin {
roles = append(roles, "admin")
}
claims := types.ApiClaims{
Email: user.Email,
// TODO:
EmailVerified: true,
Name: user.FullName,
Picture: user.ProfilePicture.String,
Picture: user.ProfilePicture,
Nonce: nonce,
Roles: []string{"user", "admin"},
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.cfg.Jwt.Issuer,
// TODO: use dedicated API id that is in local DB and bind to user there

View File

@ -9,7 +9,6 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const activateApiService = `-- name: ActivateApiService :exec
@ -33,14 +32,14 @@ INSERT INTO api_services (
`
type CreateApiServiceParams struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
IsActive bool `json:"is_active"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
Description *string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
IsActive bool `json:"is_active"`
}
func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServiceParams) (ApiService, error) {
@ -185,12 +184,12 @@ RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types
`
type UpdateApiServiceParams struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
ClientID string `json:"client_id"`
Name string `json:"name"`
Description *string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
}
func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServiceParams) (ApiService, error) {

View File

@ -8,32 +8,31 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type ApiService struct {
ID uuid.UUID `json:"id"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
IsActive bool `json:"is_active"`
Description pgtype.Text `json:"description"`
ID uuid.UUID `json:"id"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
IsActive bool `json:"is_active"`
Description *string `json:"description"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLogin pgtype.Timestamptz `json:"last_login"`
PhoneNumber pgtype.Text `json:"phone_number"`
ProfilePicture pgtype.Text `json:"profile_picture"`
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
LastLogin *time.Time `json:"last_login"`
PhoneNumber *string `json:"phone_number"`
ProfilePicture *string `json:"profile_picture"`
}

View File

@ -9,7 +9,6 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const findAllUsers = `-- name: FindAllUsers :many
@ -126,8 +125,8 @@ WHERE id = $2
`
type UpdateProfilePictureParams struct {
ProfilePicture pgtype.Text `json:"profile_picture"`
ID uuid.UUID `json:"id"`
ProfilePicture *string `json:"profile_picture"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {

View File

@ -12,7 +12,7 @@ type ApiClaims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
Picture *string `json:"picture"`
Nonce string `json:"nonce"`
Roles []string `json:"roles"`
// TODO: add given_name, family_name, locale...

19
internal/types/user.go Normal file
View File

@ -0,0 +1,19 @@
package types
import (
"time"
"github.com/google/uuid"
)
type UserDTO struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
IsAdmin bool `json:"is_admin"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
LastLogin *time.Time `json:"last_login"`
PhoneNumber *string `json:"phone_number"`
ProfilePicture *string `json:"profile_picture"`
}

View File

@ -19,7 +19,6 @@ import (
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/minio/minio-go/v7"
)
@ -170,11 +169,8 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
}
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
ProfilePicture: pgtype.Text{
String: uploadInfo.Key,
Valid: true,
},
ID: user.ID,
ProfilePicture: &uploadInfo.Key,
ID: user.ID,
}); err != nil {
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
return

View File

@ -1,4 +1,3 @@
version: "2"
sql:
- engine: "postgresql"
@ -14,8 +13,95 @@ sql:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "timestamptz"
type: UUID
- db_type: "uuid"
nullable: true
go_type:
import: "github.com/google/uuid"
type: UUID
pointer: true
# ───── bool ──────────────────────────────────────────
- db_type: "pg_catalog.bool" # or just "bool"
go_type: { type: "bool" }
- db_type: "bool" # or just "bool"
go_type: { type: "bool" }
- db_type: "pg_catalog.bool"
nullable: true
go_type:
type: "bool"
pointer: true # ⇒ *bool for NULLable columns
- db_type: "bool"
nullable: true
go_type:
type: "bool"
pointer: true # ⇒ *bool for NULLable columns
# ───── text ──────────────────────────────────────────
- db_type: "pg_catalog.text"
go_type: { type: "string" }
- db_type: "text" # or just "bool"
go_type: { type: "string" }
- db_type: "pg_catalog.text"
nullable: true
go_type:
type: "string"
pointer: true # ⇒ *bool for NULLable columns
- db_type: "text"
nullable: true
go_type:
type: "string"
pointer: true # ⇒ *bool for NULLable columns
# ───── timestamp (WITHOUT TZ) ────────────────────────
- db_type: "pg_catalog.timestamp" # or "timestamp"
go_type:
import: "time"
type: "Time"
- db_type: "timestamp" # or "timestamp"
go_type:
import: "time"
type: "Time"
- db_type: "pg_catalog.timestamp"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
- db_type: "timestamp"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
# ───── timestamptz (WITH TZ) ─────────────────────────
- db_type: "pg_catalog.timestamptz" # or "timestamptz"
go_type:
import: "time"
type: "Time"
- db_type: "timestamptz" # or "timestamptz"
go_type:
import: "time"
type: "Time"
- db_type: "pg_catalog.timestamptz"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
- db_type: "timestamptz"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true

View File

@ -9,13 +9,15 @@ import AuthenticatePage from "./pages/Authenticate";
import AuthLayout from "./layout/AuthLayout";
import DashboardLayout from "./layout/DashboardLayout";
import PersonalInfoPage from "./pages/PersonalInfo";
import ApiServicesPage from "./pages/ApiServices";
import ApiServicesPage from "./pages/Admin/ApiServices";
import AdminLayout from "./layout/AdminLayout";
import ApiServiceCreatePage from "./pages/ApiServices/Create";
import ViewApiServicePage from "./pages/ApiServices/View";
import ApiServiceCreatePage from "./pages/Admin/ApiServices/Create";
import ViewApiServicePage from "./pages/Admin/ApiServices/View";
import NotAllowedPage from "./pages/NotAllowed";
import NotFoundPage from "./pages/NotFound";
import ApiServiceEditPage from "./pages/ApiServices/Update";
import ApiServiceEditPage from "./pages/Admin/ApiServices/Update";
import AdminUsersPage from "./pages/Admin/Users";
import AdminViewUserPage from "./pages/Admin/Users/View";
const router = createBrowserRouter([
{
@ -53,6 +55,21 @@ const router = createBrowserRouter([
},
],
},
{
path: "users",
children: [
{ index: true, element: <AdminUsersPage /> },
// { path: "create", element: <ApiServiceCreatePage /> },
{
path: "view/:userId",
element: <AdminViewUserPage />,
},
// {
// path: "edit/:serviceId",
// element: <ApiServiceEditPage />,
// },
],
},
],
},
],

View File

@ -0,0 +1,31 @@
import type { UserProfile } from "@/types";
import { axios, handleApiError } from "..";
export interface FetchUsersResponse {
items: UserProfile[];
count: number;
}
export const adminGetUsersApi = async (): Promise<FetchUsersResponse> => {
const response = await axios.get<FetchUsersResponse>("/api/v1/admin/users");
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export type FetchUserResponse = UserProfile;
export const adminGetUserApi = async (
id: string,
): Promise<FetchUserResponse> => {
const response = await axios.get<FetchUserResponse>(
`/api/v1/admin/users/${id}`,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -2,7 +2,7 @@ import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X } from "lucide-react";
import { useAdmin } from "@/store/admin";
import { useApiServices } from "@/store/admin/apiServices";
import type { ApiServiceCredentials } from "@/types";
const download = (credentials: ApiServiceCredentials) => {
@ -19,8 +19,8 @@ const download = (credentials: ApiServiceCredentials) => {
};
const ApiServiceCredentialsModal = () => {
const credentials = useAdmin((state) => state.createdCredentials);
const resetCredentials = useAdmin((state) => state.resetCredentials);
const credentials = useApiServices((state) => state.createdCredentials);
const resetCredentials = useApiServices((state) => state.resetCredentials);
const portalRoot = document.getElementById("portal-root");
if (!portalRoot || !credentials) return null;

View File

@ -1,10 +1,10 @@
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { CircleCheckBig, X } from "lucide-react";
import { useAdmin } from "@/store/admin";
import { useApiServices } from "@/store/admin/apiServices";
const ApiServiceUpdatedModal = () => {
const resetUpdated = useAdmin((state) => state.resetUpdatedApiService);
const resetUpdated = useApiServices((state) => state.resetUpdated);
const portalRoot = document.getElementById("portal-root");
if (!portalRoot) return null;

View File

@ -27,7 +27,7 @@ const Avatar: FC<AvatarProps> = ({ iconSize = 32, className, avatarId }) => {
alt="profile"
/>
) : (
<User size={iconSize} />
<User size={iconSize} className="text-gray-800" />
)}
</div>
);

View File

@ -46,7 +46,7 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
tab: "data-personalization",
pathname: "/data-personalize",
},
...(profile.isAdmin
...(profile.is_admin
? [
{
icon: <Blocks />,

View File

@ -32,7 +32,7 @@ const AdminLayout: FC = () => {
</div>;
}
if (!profile?.isAdmin) {
if (!profile?.is_admin) {
return <Navigate to="/not-allowed" />;
}

View File

@ -6,7 +6,8 @@ export interface IBackgroundLayoutProps {
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
return (
<div className="relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
// <div className="relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
<div className="relative min-h-screen bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-gradient-to-br from-[#101112] to-[#041758]">
{children}
</div>
);

View File

@ -2,7 +2,7 @@ import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ApiServiceCredentialsModal from "@/feature/ApiServiceCredentialsModal";
import { useAdmin } from "@/store/admin";
import { useApiServices } from "@/store/admin/apiServices";
import { useCallback, type FC } from "react";
import { useForm } from "react-hook-form";
import { Link } from "react-router";
@ -28,9 +28,9 @@ const ApiServiceCreatePage: FC = () => {
},
});
const createApiService = useAdmin((state) => state.createApiService);
const createApiService = useApiServices((state) => state.create);
const credentials = useAdmin((state) => state.createdCredentials);
const credentials = useApiServices((state) => state.createdCredentials);
const onSubmit = useCallback(
(data: FormData) => {

View File

@ -2,7 +2,7 @@ import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ApiServiceUpdatedModal from "@/feature/ApiServiceUpdatedModal";
import { useAdmin } from "@/store/admin";
import { useApiServices } from "@/store/admin/apiServices";
import { useCallback, useEffect, type FC } from "react";
import { useForm } from "react-hook-form";
import { Link, useParams } from "react-router";
@ -28,13 +28,13 @@ const ApiServiceEditPage: FC = () => {
});
const { serviceId } = useParams();
const apiService = useAdmin((state) => state.viewApiService);
const apiService = useApiServices((state) => state.view);
const loadService = useAdmin((state) => state.fetchApiService);
const loadService = useApiServices((state) => state.fetch);
const updateApiService = useAdmin((state) => state.updateApiService);
const updating = useAdmin((state) => state.updatingApiService);
const updated = useAdmin((state) => state.updatedApiService);
const updateApiService = useApiServices((state) => state.update);
const updating = useApiServices((state) => state.updating);
const updated = useApiServices((state) => state.updated);
const onSubmit = useCallback(
(data: FormData) => {

View File

@ -1,7 +1,7 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAdmin } from "@/store/admin";
import { useApiServices } from "@/store/admin/apiServices";
import { useEffect, type FC } from "react";
import { Link, useParams } from "react-router";
@ -24,13 +24,13 @@ const InfoCard = ({
const ViewApiServicePage: FC = () => {
const { serviceId } = useParams();
const apiService = useAdmin((state) => state.viewApiService);
// const loading = useAdmin((state) => state.fetchingApiService);
const apiService = useApiServices((state) => state.view);
// const loading = useApiServices((state) => state.fetchingApiService);
const loadService = useAdmin((state) => state.fetchApiService);
const loadService = useApiServices((state) => state.fetchSingle);
const toggling = useAdmin((state) => state.togglingApiService);
const toggle = useAdmin((state) => state.toggleApiService);
const toggling = useApiServices((state) => state.toggling);
const toggle = useApiServices((state) => state.toggle);
useEffect(() => {
if (typeof serviceId === "string") loadService(serviceId);

View File

@ -1,14 +1,14 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { useAdmin } from "@/store/admin";
import { useApiServices } from "@/store/admin/apiServices";
import { Plus } from "lucide-react";
import { useEffect, type FC } from "react";
import { Link } from "react-router";
const ApiServicesPage: FC = () => {
const apiServices = useAdmin((state) => state.apiServices);
const loading = useAdmin((state) => state.loadingApiServices);
const fetchApiServices = useAdmin((state) => state.fetchApiServices);
const apiServices = useApiServices((state) => state.apiServices);
const loading = useApiServices((state) => state.loading);
const fetchApiServices = useApiServices((state) => state.fetch);
useEffect(() => {
fetchApiServices();

View File

@ -0,0 +1,139 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import Avatar from "@/feature/Avatar";
import { useUsers } from "@/store/admin/users";
import { useEffect, type FC } from "react";
import { Link, useParams } from "react-router";
const InfoCard = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="border dark:border-gray-800 border-gray-300 rounded mb-4">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200 font-semibold text-lg">
{title}
</h2>
</div>
<div className="p-4">{children}</div>
</div>
);
const AdminViewUserPage: FC = () => {
const { userId } = useParams();
const user = useUsers((state) => state.current);
// const loading = useApiServices((state) => state.fetchingApiService);
const loadUser = useUsers((state) => state.fetchUser);
useEffect(() => {
if (typeof userId === "string") loadUser(userId);
}, [loadUser, userId]);
if (!user) {
return (
<div className="p-4 flex items-center justify-center h-[60vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400">Loading User...</p>
</div>
</div>
);
}
return (
<div className="dark:text-gray-200 text-gray-800 p-4">
<Breadcrumbs
items={[
{ href: "/admin", label: "Admin" },
{ href: "/admin/users", label: "Users" },
{ label: "View User" },
]}
/>
<div className="sm:p-4 pt-4">
{/* 📋 Main Details */}
<InfoCard title="Personal Info">
<div className="flex flex-col gap-4 text-sm">
<div className="flex flex-col gap-4">
<span className="font-medium text-gray-900 dark:text-white">
Avatar:
</span>
<Avatar
avatarId={user.profile_picture ?? undefined}
className="w-16 h-16"
iconSize={28}
/>
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Full Name:
</span>{" "}
{user.full_name}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Email:
</span>{" "}
{user.email}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Phone Number:
</span>{" "}
{user.phone_number || "-"}{" "}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Is Admin:
</span>{" "}
<span
className={`font-semibold px-2 py-1 rounded ${
user.is_admin
? "bg-green-200 text-green-800 dark:bg-green-700/20 dark:text-green-300"
: "bg-red-200 text-red-800 dark:bg-red-700/20 dark:text-red-300"
}`}
>
{user.is_admin ? "Yes" : "No"}
</span>
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Created At:
</span>{" "}
{new Date(user.created_at).toLocaleString()}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Last Login At:
</span>{" "}
{user.last_login
? new Date(user.last_login).toLocaleString()
: "never"}
</div>
</div>
</InfoCard>
{/* 🚀 Actions */}
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center">
<Link to="/admin/users">
<Button variant="outlined">Back</Button>
</Link>
<div className="flex flex-row items-center gap-4">
<Link
to={`/admin/users/edit/${userId}`}
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<Button variant="contained">Edit</Button>
</Link>
</div>
</div>
</div>
</div>
);
};
export default AdminViewUserPage;

View File

@ -0,0 +1,135 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import Avatar from "@/feature/Avatar";
import { useUsers } from "@/store/admin/users";
import { UserPlus } from "lucide-react";
import { useEffect, type FC } from "react";
import { Link } from "react-router";
const AdminUsersPage: FC = () => {
const users = useUsers((state) => state.users);
const loading = useUsers((state) => state.fetching);
const fetchUsers = useUsers((state) => state.fetchUsers);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<div className="relative flex flex-col items-stretch w-full h-full">
<div className="p-4">
<Breadcrumbs
className="pb-2"
items={[
{
href: "/admin",
label: "Admin",
},
{
label: "Users",
},
]}
/>
</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">
<Button className="flex flex-row items-center gap-2">
<UserPlus /> Add User
</Button>
</Link>
</div>
<div className="flex-1 overflow-x-auto">
<table className="relative min-w-full border-l-0 border border-gray-300 dark:border-gray-700 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
<div className="text-gray-800 dark:text-gray-200 font-medium">
Loading...
</div>
</div>
)}
<thead className="bg-black/5 dark:bg-white/5 text-nowrap">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Full Name
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Email
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Is Admin
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Created At
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Last Login
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{!loading && users.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
>
No services found.
</td>
</tr>
) : (
users.map((user) => (
<tr
key={user.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="px-6 py-4 text-sm font-medium text-blue-600 border border-gray-300 dark:border-gray-700">
<Link
to={`/admin/users/view/${user.id}`}
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<div className="flex flex-row items-center gap-3">
<Avatar
iconSize={21}
className="w-8 h-8"
avatarId={user.profile_picture ?? undefined}
/>
<p>{user.full_name}</p>
</div>
</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
{user.email}
</td>
<td className="px-6 py-4 text-sm border border-gray-300 dark:border-gray-700">
<span
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
user.is_admin
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
}`}
>
{user.is_admin ? "Yes" : "No"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
{new Date(user.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
{user.last_login
? new Date(user.last_login).toLocaleString()
: "never"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
export default AdminUsersPage;

View File

@ -1,127 +0,0 @@
import {
getApiService,
getApiServices,
patchToggleApiService,
postApiService,
putApiService,
type CreateApiServiceRequest,
type UpdateApiServiceRequest,
} from "@/api/admin/apiServices";
import type { ApiService, ApiServiceCredentials } from "@/types";
import { create } from "zustand";
interface IAdminState {
apiServices: ApiService[];
loadingApiServices: boolean;
createdCredentials: ApiServiceCredentials | null;
creatingApiService: boolean;
viewApiService: ApiService | null;
fetchingApiService: boolean;
fetchApiServices: () => Promise<void>;
fetchApiService: (id: string) => Promise<void>;
createApiService: (req: CreateApiServiceRequest) => Promise<void>;
resetCredentials: () => void;
togglingApiService: boolean;
toggleApiService: () => Promise<void>;
updateApiService: (req: UpdateApiServiceRequest) => Promise<void>;
updatingApiService: boolean;
updatedApiService: boolean;
resetUpdatedApiService: () => void;
}
export const useAdmin = create<IAdminState>((set, get) => ({
apiServices: [],
loadingApiServices: false,
createdCredentials: null,
creatingApiService: false,
viewApiService: null,
fetchingApiService: false,
togglingApiService: false,
updatingApiService: false,
updatedApiService: false,
resetUpdatedApiService: () => set({ updatedApiService: false }),
resetCredentials: () => set({ createdCredentials: null }),
fetchApiServices: async () => {
set({ loadingApiServices: true });
try {
const response = await getApiServices();
set({ apiServices: response.items });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ loadingApiServices: false });
}
},
fetchApiService: async (id: string) => {
set({ fetchingApiService: true });
try {
const response = await getApiService(id);
set({ viewApiService: response });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ fetchingApiService: false });
}
},
updateApiService: async (req: UpdateApiServiceRequest) => {
const viewService = get().viewApiService;
if (!viewService) return;
set({ updatingApiService: true });
try {
await putApiService(viewService.id, req);
get().fetchApiService(viewService.id);
set({ updatedApiService: true });
} catch (err) {
console.log("ERR: Failed to toggle service:", err);
} finally {
set({ updatingApiService: false });
}
},
toggleApiService: async () => {
const viewService = get().viewApiService;
if (!viewService) return;
set({ togglingApiService: true });
try {
await patchToggleApiService(viewService.id);
get().fetchApiService(viewService.id);
} catch (err) {
console.log("ERR: Failed to toggle service:", err);
} finally {
set({ togglingApiService: false });
}
},
createApiService: async (req: CreateApiServiceRequest) => {
set({ creatingApiService: true });
try {
const response = await postApiService(req);
set({ createdCredentials: response.credentials });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ creatingApiService: false });
}
},
}));

View File

@ -0,0 +1,127 @@
import {
getApiService,
getApiServices,
patchToggleApiService,
postApiService,
putApiService,
type CreateApiServiceRequest,
type UpdateApiServiceRequest,
} from "@/api/admin/apiServices";
import type { ApiService, ApiServiceCredentials } from "@/types";
import { create } from "zustand";
interface IApiServicesState {
apiServices: ApiService[];
loading: boolean;
createdCredentials: ApiServiceCredentials | null;
creating: boolean;
view: ApiService | null;
fetching: boolean;
fetch: () => Promise<void>;
fetchSingle: (id: string) => Promise<void>;
create: (req: CreateApiServiceRequest) => Promise<void>;
resetCredentials: () => void;
toggling: boolean;
toggle: () => Promise<void>;
update: (req: UpdateApiServiceRequest) => Promise<void>;
updating: boolean;
updated: boolean;
resetUpdated: () => void;
}
export const useApiServices = create<IApiServicesState>((set, get) => ({
apiServices: [],
loading: false,
createdCredentials: null,
creating: false,
view: null,
fetching: false,
toggling: false,
updating: false,
updated: false,
resetUpdated: () => set({ updated: false }),
resetCredentials: () => set({ createdCredentials: null }),
fetch: async () => {
set({ loading: true });
try {
const response = await getApiServices();
set({ apiServices: response.items });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ loading: false });
}
},
fetchSingle: async (id: string) => {
set({ fetching: true });
try {
const response = await getApiService(id);
set({ view: response });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ fetching: false });
}
},
update: async (req: UpdateApiServiceRequest) => {
const viewService = get().view;
if (!viewService) return;
set({ updating: true });
try {
await putApiService(viewService.id, req);
get().fetchSingle(viewService.id);
set({ updated: true });
} catch (err) {
console.log("ERR: Failed to toggle service:", err);
} finally {
set({ updating: false });
}
},
toggle: async () => {
const viewService = get().view;
if (!viewService) return;
set({ toggling: true });
try {
await patchToggleApiService(viewService.id);
get().fetchSingle(viewService.id);
} catch (err) {
console.log("ERR: Failed to toggle service:", err);
} finally {
set({ toggling: false });
}
},
create: async (req: CreateApiServiceRequest) => {
set({ creating: true });
try {
const response = await postApiService(req);
set({ createdCredentials: response.credentials });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ creating: false });
}
},
}));

View File

@ -0,0 +1,48 @@
import { adminGetUserApi, adminGetUsersApi } from "@/api/admin/users";
import type { UserProfile } from "@/types";
import { create } from "zustand";
export interface IUsersState {
users: UserProfile[];
fetching: boolean;
current: UserProfile | null;
fetchingCurrent: boolean;
fetchUsers: () => Promise<void>;
fetchUser: (id: string) => Promise<void>;
}
export const useUsers = create<IUsersState>((set) => ({
users: [],
fetching: false,
current: null,
fetchingCurrent: false,
fetchUsers: async () => {
set({ fetching: true });
try {
const response = await adminGetUsersApi();
set({ users: response.items });
} catch (err) {
console.log("ERR: Failed to fetch users for admin:", err);
} finally {
set({ fetching: false });
}
},
fetchUser: async (id: string) => {
set({ fetchingCurrent: true });
try {
const response = await adminGetUserApi(id);
set({ current: response });
} catch (err) {
console.log("ERR: Failed to fetch single user for admin:", err);
} finally {
set({ fetchingCurrent: false });
}
},
}));

View File

@ -3,7 +3,7 @@ export interface UserProfile {
full_name: string;
email: string;
phone_number: string;
isAdmin: boolean;
is_admin: boolean;
last_login: string;
profile_picture: string | null;
updated_at: string;