feat: add update and delete operations for users and policies in admin interface
Add updateUser and deleteUser API client methods with PATCH and DELETE endpoints. Add updatePolicy and deletePolicy API client methods. Add email field to User type. Add Actions column to users and policies tables with Edit and Delete buttons. Implement inline edit forms for users and policies with state management for editing mode. Add update and delete mutations with query invalidation on success. Add error notices
This commit is contained in:
@@ -94,8 +94,8 @@ func (r *PGRepository) GetLatestEnrollmentByUser(ctx context.Context, userID uui
|
||||
g.name,
|
||||
g.endpoint,
|
||||
g.public_key,
|
||||
wp.dns_servers,
|
||||
wp.allowed_ips
|
||||
coalesce(wp.dns_servers, '{}')::text[],
|
||||
coalesce(array(select cidr::text from unnest(wp.allowed_ips) as cidr), '{}')::text[]
|
||||
from devices d
|
||||
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||
join gateways g on g.id = wp.gateway_id
|
||||
@@ -121,8 +121,8 @@ func (r *PGRepository) GetEnrollmentByDeviceID(ctx context.Context, deviceID uui
|
||||
g.name,
|
||||
g.endpoint,
|
||||
g.public_key,
|
||||
wp.dns_servers,
|
||||
wp.allowed_ips
|
||||
coalesce(wp.dns_servers, '{}')::text[],
|
||||
coalesce(array(select cidr::text from unnest(wp.allowed_ips) as cidr), '{}')::text[]
|
||||
from devices d
|
||||
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||
join gateways g on g.id = wp.gateway_id
|
||||
|
||||
@@ -52,6 +52,8 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
|
||||
r.Use(AdminOnly)
|
||||
r.Get("/users", handlers.User.List)
|
||||
r.Post("/users", handlers.User.Create)
|
||||
r.Patch("/users/{id}", handlers.User.Update)
|
||||
r.Delete("/users/{id}", handlers.User.Delete)
|
||||
r.Post("/users/{id}/disable", handlers.User.Disable)
|
||||
r.Post("/users/{id}/enable", handlers.User.Enable)
|
||||
r.Get("/devices", handlers.Device.ListAll)
|
||||
@@ -60,6 +62,8 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
|
||||
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
|
||||
r.Get("/policies", handlers.Policy.List)
|
||||
r.Post("/policies", handlers.Policy.Create)
|
||||
r.Patch("/policies/{id}", handlers.Policy.Update)
|
||||
r.Delete("/policies/{id}", handlers.Policy.Delete)
|
||||
r.Get("/gateways", handlers.Gateway.List)
|
||||
r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle)
|
||||
r.Patch("/gateways/{id}", handlers.Gateway.Update)
|
||||
|
||||
@@ -4,6 +4,9 @@ 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"
|
||||
@@ -60,3 +63,58 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
apiutil.JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
policyID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_policy_id", "invalid policy 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(), policyID, input)
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "policy_update_failed", "unable to update policy")
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "policy",
|
||||
EntityID: &policyID,
|
||||
EventType: "admin.policy.updated",
|
||||
Status: "success",
|
||||
Message: "admin updated policy",
|
||||
})
|
||||
}
|
||||
apiutil.JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
policyID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_policy_id", "invalid policy id")
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(r.Context(), policyID); err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "policy_delete_failed", "unable to delete policy")
|
||||
return
|
||||
}
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "policy",
|
||||
EntityID: &policyID,
|
||||
EventType: "admin.policy.deleted",
|
||||
Status: "success",
|
||||
Message: "admin deleted policy",
|
||||
})
|
||||
}
|
||||
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
type Repository interface {
|
||||
List(ctx context.Context) ([]Policy, error)
|
||||
Create(ctx context.Context, input CreateRequest, createdBy uuid.UUID) (Policy, error)
|
||||
Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error)
|
||||
Delete(ctx context.Context, policyID uuid.UUID) error
|
||||
ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error)
|
||||
}
|
||||
|
||||
@@ -106,6 +109,81 @@ func (r *PGRepository) Create(ctx context.Context, input CreateRequest, createdB
|
||||
return inputPolicy, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error) {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
update policies
|
||||
set
|
||||
name = coalesce($2, name),
|
||||
description = coalesce($3, description),
|
||||
priority = coalesce($4, priority),
|
||||
effect = coalesce($5, effect),
|
||||
full_tunnel = coalesce($6, full_tunnel),
|
||||
is_active = coalesce($7, is_active),
|
||||
updated_at = now()
|
||||
where id = $1 and deleted_at is null
|
||||
`, policyID, input.Name, input.Description, input.Priority, input.Effect, input.FullTunnel, input.IsActive)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
|
||||
if input.Destinations != nil {
|
||||
if _, err := tx.Exec(ctx, `delete from policy_destinations where policy_id = $1`, policyID); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
for _, destination := range input.Destinations {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into policy_destinations (id, policy_id, destination)
|
||||
values ($1, $2, $3::cidr)
|
||||
`, uuid.New(), policyID, destination); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if input.Targets != nil {
|
||||
if _, err := tx.Exec(ctx, `delete from policy_targets where policy_id = $1`, policyID); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
for _, target := range input.Targets {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into policy_targets (id, policy_id, target_type, target_id)
|
||||
values ($1, $2, $3, $4)
|
||||
`, uuid.New(), policyID, target.Type, target.ID); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
|
||||
items, err := r.List(ctx)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID == policyID {
|
||||
if input.Targets != nil {
|
||||
item.Targets = input.Targets
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return Policy{}, errors.New("policy not found after update")
|
||||
}
|
||||
|
||||
func (r *PGRepository) Delete(ctx context.Context, policyID uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx, `update policies set deleted_at = now(), updated_at = now() where id = $1 and deleted_at is null`, policyID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) {
|
||||
query := `
|
||||
select distinct pd.destination::text
|
||||
|
||||
@@ -28,6 +28,14 @@ func (s *Service) Create(ctx context.Context, actorID uuid.UUID, input CreateReq
|
||||
return s.repo.Create(ctx, input, actorID)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error) {
|
||||
return s.repo.Update(ctx, policyID, input)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, policyID uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, policyID)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) {
|
||||
return s.repo.ResolveDestinations(ctx, userID, deviceID)
|
||||
}
|
||||
|
||||
@@ -28,3 +28,14 @@ type CreateRequest struct {
|
||||
Destinations []string `json:"destinations"`
|
||||
Targets []Target `json:"targets"`
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Priority *int `json:"priority"`
|
||||
Effect *string `json:"effect"`
|
||||
FullTunnel *bool `json:"full_tunnel"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Destinations []string `json:"destinations"`
|
||||
Targets []Target `json:"targets"`
|
||||
}
|
||||
|
||||
@@ -86,6 +86,61 @@ func (h *Handler) Disable(w http.ResponseWriter, r *http.Request) {
|
||||
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_user_id", "invalid user 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
|
||||
}
|
||||
|
||||
updated, err := h.service.Update(r.Context(), targetID.String(), input)
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "user_update_failed", "unable to update user")
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "user",
|
||||
EntityID: &targetID,
|
||||
EventType: "admin.user.updated",
|
||||
Status: "success",
|
||||
Message: "admin updated user",
|
||||
})
|
||||
}
|
||||
apiutil.JSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_user_id", "invalid user id")
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(r.Context(), targetID.String()); err != nil {
|
||||
apiutil.Error(w, http.StatusInternalServerError, "user_delete_failed", "unable to delete user")
|
||||
return
|
||||
}
|
||||
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EntityType: "user",
|
||||
EntityID: &targetID,
|
||||
EventType: "admin.user.deleted",
|
||||
Status: "success",
|
||||
Message: "admin deleted user",
|
||||
})
|
||||
}
|
||||
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Handler) Enable(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
type Repository interface {
|
||||
List(ctx context.Context) ([]User, error)
|
||||
Create(ctx context.Context, input CreateRequest, passwordHash string) (User, error)
|
||||
Update(ctx context.Context, userID uuid.UUID, input UpdateRequest, passwordHash *string) (User, error)
|
||||
Delete(ctx context.Context, userID uuid.UUID) error
|
||||
SetActive(ctx context.Context, userID uuid.UUID, active bool) error
|
||||
}
|
||||
|
||||
@@ -69,3 +71,35 @@ func (r *PGRepository) SetActive(ctx context.Context, userID uuid.UUID, active b
|
||||
_, err := r.db.Exec(ctx, `update users set is_active = $2, updated_at = now() where id = $1`, userID, active)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) Update(ctx context.Context, userID uuid.UUID, input UpdateRequest, passwordHash *string) (User, error) {
|
||||
const query = `
|
||||
update users u
|
||||
set
|
||||
role_id = coalesce((select id from roles where name = $2), u.role_id),
|
||||
display_name = coalesce($3, u.display_name),
|
||||
email = case when $4 is null then u.email else nullif($4, '')::citext end,
|
||||
password_hash = coalesce($5, u.password_hash),
|
||||
is_active = coalesce($6, u.is_active),
|
||||
updated_at = now()
|
||||
where u.id = $1 and u.deleted_at is null
|
||||
returning
|
||||
u.id,
|
||||
u.role_id,
|
||||
(select name from roles where id = u.role_id),
|
||||
u.username::text,
|
||||
u.display_name,
|
||||
coalesce(u.email::text, ''),
|
||||
u.is_active
|
||||
`
|
||||
|
||||
var item User
|
||||
err := r.db.QueryRow(ctx, query, userID, input.Role, input.DisplayName, input.Email, passwordHash, input.IsActive).
|
||||
Scan(&item.ID, &item.RoleID, &item.RoleName, &item.Username, &item.DisplayName, &item.Email, &item.IsActive)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) Delete(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx, `update users set deleted_at = now(), updated_at = now() where id = $1 and deleted_at is null`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,6 +28,32 @@ func (s *Service) Create(ctx context.Context, input CreateRequest) (User, error)
|
||||
return s.repo.Create(ctx, input, passwordHash)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, userID string, input UpdateRequest) (User, error) {
|
||||
id, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var passwordHash *string
|
||||
if input.Password != nil && *input.Password != "" {
|
||||
hashed, err := auth.HashPassword(*input.Password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
passwordHash = &hashed
|
||||
}
|
||||
|
||||
return s.repo.Update(ctx, id, input, passwordHash)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, userID string) error {
|
||||
id, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) SetActive(ctx context.Context, userID string, active bool) error {
|
||||
id, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,7 +21,9 @@ type CreateRequest struct {
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Role *string `json:"role"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user