feat: add groups management with CRUD operations and policy target assignment
Add Group type with id, name, description, members array and optional user_ids field. Add name field to policy targets for display. Add groups API client methods for list, create, update and delete operations. Add GroupsPage component with create form, edit modal, member selection and table view. Add groups route and navigation item to Layout. Add reusable Modal component with title, subtitle and close handler. Update
This commit is contained in:
117
backend/internal/group/handler.go
Normal file
117
backend/internal/group/handler.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"nexavpn/backend/internal/apiutil"
|
||||
"nexavpn/backend/internal/audit"
|
||||
"nexavpn/backend/internal/requestctx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
audit *audit.Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
||||
return &Handler{service: service, audit: auditService}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.service.List(r.Context())
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "groups_list_failed", "unable to list groups")
|
||||
return
|
||||
}
|
||||
apiutil.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var input CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.service.Create(r.Context(), input)
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "group_create_failed", "unable to create group")
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "group",
|
||||
EntityID: &item.ID,
|
||||
EventType: "admin.group.created",
|
||||
Status: "success",
|
||||
Message: "admin created group",
|
||||
})
|
||||
}
|
||||
|
||||
apiutil.JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_group_id", "invalid group id")
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.service.Update(r.Context(), groupID, input)
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "group_update_failed", "unable to update group")
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "group",
|
||||
EntityID: &groupID,
|
||||
EventType: "admin.group.updated",
|
||||
Status: "success",
|
||||
Message: "admin updated group",
|
||||
})
|
||||
}
|
||||
|
||||
apiutil.JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_group_id", "invalid group id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(r.Context(), groupID); err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "group_delete_failed", "unable to delete group")
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "group",
|
||||
EntityID: &groupID,
|
||||
EventType: "admin.group.deleted",
|
||||
Status: "success",
|
||||
Message: "admin deleted group",
|
||||
})
|
||||
}
|
||||
|
||||
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
183
backend/internal/group/repository.go
Normal file
183
backend/internal/group/repository.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
List(ctx context.Context) ([]Group, error)
|
||||
Create(ctx context.Context, input CreateRequest) (Group, error)
|
||||
Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error)
|
||||
Delete(ctx context.Context, groupID uuid.UUID) error
|
||||
}
|
||||
|
||||
type PGRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||
return &PGRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PGRepository) List(ctx context.Context) ([]Group, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
select id, name, description
|
||||
from groups
|
||||
where deleted_at is null
|
||||
order by created_at desc
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Group
|
||||
for rows.Next() {
|
||||
var item Group
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
members, err := r.listMembers(ctx, item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Members = members
|
||||
for _, member := range members {
|
||||
item.UserIDs = append(item.UserIDs, member.ID)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) Create(ctx context.Context, input CreateRequest) (Group, error) {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
groupID := uuid.New()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into groups (id, name, description)
|
||||
values ($1, $2, $3)
|
||||
`, groupID, input.Name, input.Description); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
|
||||
for _, userID := range input.UserIDs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into group_memberships (id, group_id, user_id)
|
||||
values ($1, $2, $3)
|
||||
`, uuid.New(), groupID, userID); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
|
||||
return r.getByID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (r *PGRepository) Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error) {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
update groups
|
||||
set name = coalesce($2, name),
|
||||
description = coalesce($3, description),
|
||||
updated_at = now()
|
||||
where id = $1 and deleted_at is null
|
||||
`, groupID, input.Name, input.Description); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
|
||||
if input.UserIDs != nil {
|
||||
if _, err := tx.Exec(ctx, `delete from group_memberships where group_id = $1`, groupID); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
for _, userID := range input.UserIDs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into group_memberships (id, group_id, user_id)
|
||||
values ($1, $2, $3)
|
||||
`, uuid.New(), groupID, userID); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
|
||||
return r.getByID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (r *PGRepository) Delete(ctx context.Context, groupID uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
update groups
|
||||
set deleted_at = now(), updated_at = now()
|
||||
where id = $1 and deleted_at is null
|
||||
`, groupID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) getByID(ctx context.Context, groupID uuid.UUID) (Group, error) {
|
||||
row := r.db.QueryRow(ctx, `
|
||||
select id, name, description
|
||||
from groups
|
||||
where id = $1 and deleted_at is null
|
||||
`, groupID)
|
||||
|
||||
var item Group
|
||||
if err := row.Scan(&item.ID, &item.Name, &item.Description); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
|
||||
members, err := r.listMembers(ctx, item.ID)
|
||||
if err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
item.Members = members
|
||||
for _, member := range members {
|
||||
item.UserIDs = append(item.UserIDs, member.ID)
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) listMembers(ctx context.Context, groupID uuid.UUID) ([]Member, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
select u.id, u.username, u.display_name
|
||||
from group_memberships gm
|
||||
join users u on u.id = gm.user_id
|
||||
where gm.group_id = $1 and u.deleted_at is null
|
||||
order by u.username asc
|
||||
`, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Member
|
||||
for rows.Next() {
|
||||
var item Member
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
31
backend/internal/group/service.go
Normal file
31
backend/internal/group/service.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Group, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input CreateRequest) (Group, error) {
|
||||
return s.repo.Create(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error) {
|
||||
return s.repo.Update(ctx, groupID, input)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, groupID uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, groupID)
|
||||
}
|
||||
29
backend/internal/group/types.go
Normal file
29
backend/internal/group/types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package group
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Member struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Members []Member `json:"members"`
|
||||
UserIDs []uuid.UUID `json:"user_ids,omitempty"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
UserIDs []uuid.UUID `json:"user_ids"`
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
UserIDs []uuid.UUID `json:"user_ids"`
|
||||
}
|
||||
Reference in New Issue
Block a user