Compare commits
21 Commits
243b7cce33
...
e85b23b3e8
Author | SHA1 | Date | |
---|---|---|---|
e85b23b3e8 | |||
6164b77bee | |||
8b5a5744ab | |||
d9e9c5ab38 | |||
0dcef81b59 | |||
426b70a1de | |||
912973cdb5 | |||
e4ff799f05 | |||
320715f5aa | |||
a3b04b6243 | |||
f610d7480f | |||
11ac92a026 | |||
98ae3e06e9 | |||
a1146ce371 | |||
a67ec7e78c | |||
9895392b50 | |||
c6998f33e1 | |||
81659181e4 | |||
849b5935c2 | |||
c27d837ab0 | |||
92e9b87227 |
@ -11,14 +11,13 @@ import (
|
|||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiServiceDTO struct {
|
type ApiServiceDTO struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description"`
|
||||||
RedirectUris []string `json:"redirect_uris"`
|
RedirectUris []string `json:"redirect_uris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
GrantTypes []string `json:"grant_types"`
|
GrantTypes []string `json:"grant_types"`
|
||||||
@ -32,7 +31,7 @@ func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
|
|||||||
ID: service.ID,
|
ID: service.ID,
|
||||||
ClientID: service.ClientID,
|
ClientID: service.ClientID,
|
||||||
Name: service.Name,
|
Name: service.Name,
|
||||||
Description: service.Description.String,
|
Description: service.Description,
|
||||||
RedirectUris: service.RedirectUris,
|
RedirectUris: service.RedirectUris,
|
||||||
Scopes: service.Scopes,
|
Scopes: service.Scopes,
|
||||||
GrantTypes: service.GrantTypes,
|
GrantTypes: service.GrantTypes,
|
||||||
@ -129,10 +128,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Description != "" {
|
if req.Description != "" {
|
||||||
params.Description = pgtype.Text{
|
params.Description = &req.Description
|
||||||
String: req.Description,
|
|
||||||
Valid: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service, err := h.repo.CreateApiService(r.Context(), params)
|
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{
|
updated, err := h.repo.UpdateApiService(r.Context(), repository.UpdateApiServiceParams{
|
||||||
ClientID: service.ClientID,
|
ClientID: service.ClientID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: pgtype.Text{
|
Description: &req.Description,
|
||||||
String: req.Description,
|
|
||||||
Valid: true,
|
|
||||||
},
|
|
||||||
RedirectUris: req.RedirectUris,
|
RedirectUris: req.RedirectUris,
|
||||||
Scopes: req.Scopes,
|
Scopes: req.Scopes,
|
||||||
GrantTypes: req.GrantTypes,
|
GrantTypes: req.GrantTypes,
|
||||||
|
@ -31,5 +31,8 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
|||||||
r.Patch("/api-services/{id}", h.RegenerateApiServiceSecret)
|
r.Patch("/api-services/{id}", h.RegenerateApiServiceSecret)
|
||||||
r.Put("/api-services/{id}", h.UpdateApiService)
|
r.Put("/api-services/{id}", h.UpdateApiService)
|
||||||
r.Patch("/api-services/toggle/{id}", h.ToggleApiService)
|
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
77
internal/admin/users.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -32,7 +32,7 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
|
|||||||
Issuer: h.cfg.Jwt.Issuer,
|
Issuer: h.cfg.Jwt.Issuer,
|
||||||
Subject: user.ID.String(),
|
Subject: user.ID.String(),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
if err := json.NewEncoder(w).Encode(types.UserDTO{
|
||||||
"id": user.ID.String(),
|
ID: user.ID,
|
||||||
"full_name": user.FullName,
|
FullName: user.FullName,
|
||||||
"email": user.Email,
|
Email: user.Email,
|
||||||
"phone_number": user.PhoneNumber,
|
PhoneNumber: user.PhoneNumber,
|
||||||
"isAdmin": user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
"last_login": user.LastLogin,
|
LastLogin: user.LastLogin,
|
||||||
"profile_picture": user.ProfilePicture.String,
|
ProfilePicture: user.ProfilePicture,
|
||||||
"updated_at": user.UpdatedAt,
|
UpdatedAt: user.UpdatedAt,
|
||||||
"created_at": user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
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"`
|
AccessToken string `json:"access"`
|
||||||
RefreshToken string `json:"refresh"`
|
RefreshToken string `json:"refresh"`
|
||||||
// fields required for UI in account selector, e.g. email, full name and avatar
|
// fields required for UI in account selector, e.g. email, full name and avatar
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
ProfilePicture string `json:"profile_picture"`
|
ProfilePicture *string `json:"profile_picture"`
|
||||||
// Avatar
|
// Avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,7 +234,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
FullName: user.FullName,
|
FullName: user.FullName,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Id: user.ID.String(),
|
Id: user.ID.String(),
|
||||||
ProfilePicture: user.ProfilePicture.String,
|
ProfilePicture: user.ProfilePicture,
|
||||||
// Avatar
|
// Avatar
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
|
@ -76,14 +76,20 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var roles = []string{"user"}
|
||||||
|
|
||||||
|
if user.IsAdmin {
|
||||||
|
roles = append(roles, "admin")
|
||||||
|
}
|
||||||
|
|
||||||
claims := types.ApiClaims{
|
claims := types.ApiClaims{
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
// TODO:
|
// TODO:
|
||||||
EmailVerified: true,
|
EmailVerified: true,
|
||||||
Name: user.FullName,
|
Name: user.FullName,
|
||||||
Picture: user.ProfilePicture.String,
|
Picture: user.ProfilePicture,
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
Roles: []string{"user", "admin"},
|
Roles: roles,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: h.cfg.Jwt.Issuer,
|
Issuer: h.cfg.Jwt.Issuer,
|
||||||
// TODO: use dedicated API id that is in local DB and bind to user there
|
// TODO: use dedicated API id that is in local DB and bind to user there
|
||||||
|
@ -9,7 +9,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const activateApiService = `-- name: ActivateApiService :exec
|
const activateApiService = `-- name: ActivateApiService :exec
|
||||||
@ -33,14 +32,14 @@ INSERT INTO api_services (
|
|||||||
`
|
`
|
||||||
|
|
||||||
type CreateApiServiceParams struct {
|
type CreateApiServiceParams struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientSecret string `json:"client_secret"`
|
ClientSecret string `json:"client_secret"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description *string `json:"description"`
|
||||||
RedirectUris []string `json:"redirect_uris"`
|
RedirectUris []string `json:"redirect_uris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
GrantTypes []string `json:"grant_types"`
|
GrantTypes []string `json:"grant_types"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServiceParams) (ApiService, error) {
|
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 {
|
type UpdateApiServiceParams struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description *string `json:"description"`
|
||||||
RedirectUris []string `json:"redirect_uris"`
|
RedirectUris []string `json:"redirect_uris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
GrantTypes []string `json:"grant_types"`
|
GrantTypes []string `json:"grant_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServiceParams) (ApiService, error) {
|
func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServiceParams) (ApiService, error) {
|
||||||
|
@ -8,32 +8,31 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiService struct {
|
type ApiService struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientSecret string `json:"client_secret"`
|
ClientSecret string `json:"client_secret"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
RedirectUris []string `json:"redirect_uris"`
|
RedirectUris []string `json:"redirect_uris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
GrantTypes []string `json:"grant_types"`
|
GrantTypes []string `json:"grant_types"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description *string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
PasswordHash string `json:"password_hash"`
|
PasswordHash string `json:"password_hash"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt *time.Time `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt *time.Time `json:"updated_at"`
|
||||||
LastLogin pgtype.Timestamptz `json:"last_login"`
|
LastLogin *time.Time `json:"last_login"`
|
||||||
PhoneNumber pgtype.Text `json:"phone_number"`
|
PhoneNumber *string `json:"phone_number"`
|
||||||
ProfilePicture pgtype.Text `json:"profile_picture"`
|
ProfilePicture *string `json:"profile_picture"`
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const findAllUsers = `-- name: FindAllUsers :many
|
const findAllUsers = `-- name: FindAllUsers :many
|
||||||
@ -126,8 +125,8 @@ WHERE id = $2
|
|||||||
`
|
`
|
||||||
|
|
||||||
type UpdateProfilePictureParams struct {
|
type UpdateProfilePictureParams struct {
|
||||||
ProfilePicture pgtype.Text `json:"profile_picture"`
|
ProfilePicture *string `json:"profile_picture"`
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
|
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
|
||||||
|
@ -12,7 +12,7 @@ type ApiClaims struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
EmailVerified bool `json:"email_verified"`
|
EmailVerified bool `json:"email_verified"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Picture string `json:"picture"`
|
Picture *string `json:"picture"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
// TODO: add given_name, family_name, locale...
|
// TODO: add given_name, family_name, locale...
|
||||||
|
19
internal/types/user.go
Normal file
19
internal/types/user.go
Normal 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"`
|
||||||
|
}
|
@ -19,7 +19,6 @@ import (
|
|||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
"github.com/minio/minio-go/v7"
|
"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{
|
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
|
||||||
ProfilePicture: pgtype.Text{
|
ProfilePicture: &uploadInfo.Key,
|
||||||
String: uploadInfo.Key,
|
ID: user.ID,
|
||||||
Valid: true,
|
|
||||||
},
|
|
||||||
ID: user.ID,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
|
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
92
sqlc.yaml
92
sqlc.yaml
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
version: "2"
|
version: "2"
|
||||||
sql:
|
sql:
|
||||||
- engine: "postgresql"
|
- engine: "postgresql"
|
||||||
@ -14,8 +13,95 @@ sql:
|
|||||||
- db_type: "uuid"
|
- db_type: "uuid"
|
||||||
go_type:
|
go_type:
|
||||||
import: "github.com/google/uuid"
|
import: "github.com/google/uuid"
|
||||||
type: "UUID"
|
type: UUID
|
||||||
- db_type: "timestamptz"
|
- 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:
|
go_type:
|
||||||
import: "time"
|
import: "time"
|
||||||
type: "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
|
||||||
|
@ -9,13 +9,15 @@ import AuthenticatePage from "./pages/Authenticate";
|
|||||||
import AuthLayout from "./layout/AuthLayout";
|
import AuthLayout from "./layout/AuthLayout";
|
||||||
import DashboardLayout from "./layout/DashboardLayout";
|
import DashboardLayout from "./layout/DashboardLayout";
|
||||||
import PersonalInfoPage from "./pages/PersonalInfo";
|
import PersonalInfoPage from "./pages/PersonalInfo";
|
||||||
import ApiServicesPage from "./pages/ApiServices";
|
import ApiServicesPage from "./pages/Admin/ApiServices";
|
||||||
import AdminLayout from "./layout/AdminLayout";
|
import AdminLayout from "./layout/AdminLayout";
|
||||||
import ApiServiceCreatePage from "./pages/ApiServices/Create";
|
import ApiServiceCreatePage from "./pages/Admin/ApiServices/Create";
|
||||||
import ViewApiServicePage from "./pages/ApiServices/View";
|
import ViewApiServicePage from "./pages/Admin/ApiServices/View";
|
||||||
import NotAllowedPage from "./pages/NotAllowed";
|
import NotAllowedPage from "./pages/NotAllowed";
|
||||||
import NotFoundPage from "./pages/NotFound";
|
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([
|
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 />,
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
31
web/src/api/admin/users.ts
Normal file
31
web/src/api/admin/users.ts
Normal 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;
|
||||||
|
};
|
@ -2,7 +2,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useAdmin } from "@/store/admin";
|
import { useApiServices } from "@/store/admin/apiServices";
|
||||||
import type { ApiServiceCredentials } from "@/types";
|
import type { ApiServiceCredentials } from "@/types";
|
||||||
|
|
||||||
const download = (credentials: ApiServiceCredentials) => {
|
const download = (credentials: ApiServiceCredentials) => {
|
||||||
@ -19,8 +19,8 @@ const download = (credentials: ApiServiceCredentials) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ApiServiceCredentialsModal = () => {
|
const ApiServiceCredentialsModal = () => {
|
||||||
const credentials = useAdmin((state) => state.createdCredentials);
|
const credentials = useApiServices((state) => state.createdCredentials);
|
||||||
const resetCredentials = useAdmin((state) => state.resetCredentials);
|
const resetCredentials = useApiServices((state) => state.resetCredentials);
|
||||||
|
|
||||||
const portalRoot = document.getElementById("portal-root");
|
const portalRoot = document.getElementById("portal-root");
|
||||||
if (!portalRoot || !credentials) return null;
|
if (!portalRoot || !credentials) return null;
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CircleCheckBig, X } from "lucide-react";
|
import { CircleCheckBig, X } from "lucide-react";
|
||||||
import { useAdmin } from "@/store/admin";
|
import { useApiServices } from "@/store/admin/apiServices";
|
||||||
|
|
||||||
const ApiServiceUpdatedModal = () => {
|
const ApiServiceUpdatedModal = () => {
|
||||||
const resetUpdated = useAdmin((state) => state.resetUpdatedApiService);
|
const resetUpdated = useApiServices((state) => state.resetUpdated);
|
||||||
|
|
||||||
const portalRoot = document.getElementById("portal-root");
|
const portalRoot = document.getElementById("portal-root");
|
||||||
if (!portalRoot) return null;
|
if (!portalRoot) return null;
|
||||||
|
@ -27,7 +27,7 @@ const Avatar: FC<AvatarProps> = ({ iconSize = 32, className, avatarId }) => {
|
|||||||
alt="profile"
|
alt="profile"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<User size={iconSize} />
|
<User size={iconSize} className="text-gray-800" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -46,7 +46,7 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
|||||||
tab: "data-personalization",
|
tab: "data-personalization",
|
||||||
pathname: "/data-personalize",
|
pathname: "/data-personalize",
|
||||||
},
|
},
|
||||||
...(profile.isAdmin
|
...(profile.is_admin
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: <Blocks />,
|
icon: <Blocks />,
|
||||||
|
@ -32,7 +32,7 @@ const AdminLayout: FC = () => {
|
|||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile?.isAdmin) {
|
if (!profile?.is_admin) {
|
||||||
return <Navigate to="/not-allowed" />;
|
return <Navigate to="/not-allowed" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,8 @@ export interface IBackgroundLayoutProps {
|
|||||||
|
|
||||||
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
|
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -2,7 +2,7 @@ import Breadcrumbs from "@/components/ui/breadcrumbs";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import ApiServiceCredentialsModal from "@/feature/ApiServiceCredentialsModal";
|
import ApiServiceCredentialsModal from "@/feature/ApiServiceCredentialsModal";
|
||||||
import { useAdmin } from "@/store/admin";
|
import { useApiServices } from "@/store/admin/apiServices";
|
||||||
import { useCallback, type FC } from "react";
|
import { useCallback, type FC } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link } from "react-router";
|
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(
|
const onSubmit = useCallback(
|
||||||
(data: FormData) => {
|
(data: FormData) => {
|
@ -2,7 +2,7 @@ import Breadcrumbs from "@/components/ui/breadcrumbs";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import ApiServiceUpdatedModal from "@/feature/ApiServiceUpdatedModal";
|
import ApiServiceUpdatedModal from "@/feature/ApiServiceUpdatedModal";
|
||||||
import { useAdmin } from "@/store/admin";
|
import { useApiServices } from "@/store/admin/apiServices";
|
||||||
import { useCallback, useEffect, type FC } from "react";
|
import { useCallback, useEffect, type FC } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
@ -28,13 +28,13 @@ const ApiServiceEditPage: FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { serviceId } = useParams();
|
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 updateApiService = useApiServices((state) => state.update);
|
||||||
const updating = useAdmin((state) => state.updatingApiService);
|
const updating = useApiServices((state) => state.updating);
|
||||||
const updated = useAdmin((state) => state.updatedApiService);
|
const updated = useApiServices((state) => state.updated);
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(data: FormData) => {
|
(data: FormData) => {
|
@ -1,7 +1,7 @@
|
|||||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAdmin } from "@/store/admin";
|
import { useApiServices } from "@/store/admin/apiServices";
|
||||||
import { useEffect, type FC } from "react";
|
import { useEffect, type FC } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
@ -24,13 +24,13 @@ const InfoCard = ({
|
|||||||
|
|
||||||
const ViewApiServicePage: FC = () => {
|
const ViewApiServicePage: FC = () => {
|
||||||
const { serviceId } = useParams();
|
const { serviceId } = useParams();
|
||||||
const apiService = useAdmin((state) => state.viewApiService);
|
const apiService = useApiServices((state) => state.view);
|
||||||
// const loading = useAdmin((state) => state.fetchingApiService);
|
// 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 toggling = useApiServices((state) => state.toggling);
|
||||||
const toggle = useAdmin((state) => state.toggleApiService);
|
const toggle = useApiServices((state) => state.toggle);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof serviceId === "string") loadService(serviceId);
|
if (typeof serviceId === "string") loadService(serviceId);
|
@ -1,14 +1,14 @@
|
|||||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAdmin } from "@/store/admin";
|
import { useApiServices } from "@/store/admin/apiServices";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useEffect, type FC } from "react";
|
import { useEffect, type FC } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
const ApiServicesPage: FC = () => {
|
const ApiServicesPage: FC = () => {
|
||||||
const apiServices = useAdmin((state) => state.apiServices);
|
const apiServices = useApiServices((state) => state.apiServices);
|
||||||
const loading = useAdmin((state) => state.loadingApiServices);
|
const loading = useApiServices((state) => state.loading);
|
||||||
const fetchApiServices = useAdmin((state) => state.fetchApiServices);
|
const fetchApiServices = useApiServices((state) => state.fetch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchApiServices();
|
fetchApiServices();
|
139
web/src/pages/Admin/Users/View/index.tsx
Normal file
139
web/src/pages/Admin/Users/View/index.tsx
Normal 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;
|
135
web/src/pages/Admin/Users/index.tsx
Normal file
135
web/src/pages/Admin/Users/index.tsx
Normal 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;
|
@ -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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
127
web/src/store/admin/apiServices.ts
Normal file
127
web/src/store/admin/apiServices.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
48
web/src/store/admin/users.ts
Normal file
48
web/src/store/admin/users.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
@ -3,7 +3,7 @@ export interface UserProfile {
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone_number: string;
|
phone_number: string;
|
||||||
isAdmin: boolean;
|
is_admin: boolean;
|
||||||
last_login: string;
|
last_login: string;
|
||||||
profile_picture: string | null;
|
profile_picture: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
Reference in New Issue
Block a user