From cf65dc0e414258880a936f26146d0d680dad9ffc Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 17 Mar 2026 20:49:38 +0100 Subject: [PATCH] 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 --- admin-web/src/api/client.ts | 34 ++++++ .../src/features/policies/PoliciesPage.tsx | 101 +++++++++++++++++- admin-web/src/features/users/UsersPage.tsx | 89 ++++++++++++++- backend/internal/device/repository.go | 8 +- backend/internal/httpserver/router.go | 4 + backend/internal/policy/handler.go | 58 ++++++++++ backend/internal/policy/repository.go | 78 ++++++++++++++ backend/internal/policy/service.go | 8 ++ backend/internal/policy/types.go | 11 ++ backend/internal/user/handler.go | 55 ++++++++++ backend/internal/user/repository.go | 34 ++++++ backend/internal/user/service.go | 26 +++++ backend/internal/user/types.go | 2 + desktop-client/src-tauri/src/lib.rs | 6 +- 14 files changed, 502 insertions(+), 12 deletions(-) diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 606fe6e..4d92db8 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -5,6 +5,7 @@ export type User = { id: string; username: string; display_name: string; + email?: string; role: string; is_active: boolean; }; @@ -109,6 +110,21 @@ export const api = { method: "POST", body: JSON.stringify(payload) }), + updateUser: (userId: string, payload: { + display_name?: string; + email?: string; + role?: string; + password?: string; + is_active?: boolean; + }) => + request(`/admin/users/${userId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }), + deleteUser: (userId: string) => + request<{ ok: boolean }>(`/admin/users/${userId}`, { + method: "DELETE" + }), devices: () => request("/admin/devices"), deviceProfile: (deviceId: string) => request(`/admin/devices/${deviceId}/profile`), revokeDevice: (deviceId: string) => @@ -135,6 +151,24 @@ export const api = { method: "POST", body: JSON.stringify(payload) }), + updatePolicy: (policyId: string, payload: { + name?: string; + description?: string; + priority?: number; + effect?: string; + full_tunnel?: boolean; + is_active?: boolean; + destinations?: string[]; + targets?: Array<{ type: string; id: string }>; + }) => + request(`/admin/policies/${policyId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }), + deletePolicy: (policyId: string) => + request<{ ok: boolean }>(`/admin/policies/${policyId}`, { + method: "DELETE" + }), gateways: () => request("/admin/gateways"), updateGateway: (gatewayId: string, payload: { endpoint: string; diff --git a/admin-web/src/features/policies/PoliciesPage.tsx b/admin-web/src/features/policies/PoliciesPage.tsx index a75c5d9..4d70d26 100644 --- a/admin-web/src/features/policies/PoliciesPage.tsx +++ b/admin-web/src/features/policies/PoliciesPage.tsx @@ -9,7 +9,8 @@ const columns = [ { key: "name", label: "Policy" }, { key: "targets", label: "Targets" }, { key: "destinations", label: "Destinations" }, - { key: "mode", label: "Mode" } + { key: "mode", label: "Mode" }, + { key: "actions", label: "Actions" } ]; export function PoliciesPage() { @@ -29,6 +30,14 @@ export function PoliciesPage() { targetUserId: "", fullTunnel: false }); + const [editingPolicyId, setEditingPolicyId] = useState(null); + const [editForm, setEditForm] = useState({ + name: "", + description: "", + destinations: "", + fullTunnel: false, + isActive: true + }); const createMutation = useMutation({ mutationFn: api.createPolicy, @@ -38,9 +47,27 @@ export function PoliciesPage() { } }); + const updateMutation = useMutation({ + mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean } }) => + api.updatePolicy(policyId, payload), + onSuccess: () => { + setEditingPolicyId(null); + setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true }); + void queryClient.invalidateQueries({ queryKey: ["policies"] }); + } + }); + + const deleteMutation = useMutation({ + mutationFn: api.deletePolicy, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["policies"] }); + } + }); + const rows = query.data?.map((policy) => ({ + id: policy.id, name: policy.name, - targets: "assigned targets", + targets: policy.targets?.length ? `${policy.targets.length} target(s)` : "assigned targets", destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"), mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel" })) ?? []; @@ -63,6 +90,38 @@ export function PoliciesPage() { }); } + function startEdit(policyId: string) { + const policy = query.data?.find((item) => item.id === policyId); + if (!policy) { + return; + } + setEditingPolicyId(policyId); + setEditForm({ + name: policy.name, + description: policy.description, + destinations: policy.destinations?.join(", ") ?? "", + fullTunnel: policy.full_tunnel, + isActive: policy.is_active + }); + } + + function onEditSubmit(event: FormEvent) { + event.preventDefault(); + if (!editingPolicyId) { + return; + } + updateMutation.mutate({ + policyId: editingPolicyId, + payload: { + name: editForm.name, + description: editForm.description, + destinations: editForm.fullTunnel ? ["0.0.0.0/0"] : editForm.destinations.split(",").map((value) => value.trim()).filter(Boolean), + full_tunnel: editForm.fullTunnel, + is_active: editForm.isActive + } + }); + } + return ( {query.isError ?

Unable to load policies from the API.

: null} {createMutation.isError ?

Unable to create policy.

: null} + {updateMutation.isError ?

Unable to update policy.

: null} + {deleteMutation.isError ?

Unable to delete policy.

: null} + {editingPolicyId ? ( +
+ setEditForm((value) => ({ ...value, name: event.target.value }))} /> + setEditForm((value) => ({ ...value, description: event.target.value }))} /> + setEditForm((value) => ({ ...value, destinations: event.target.value }))} + disabled={editForm.fullTunnel} + /> + + +
+ + +
+
+ ) : null} {row[column.key as keyof (typeof rows)[number]]}} + renderCell={(row, column) => { + if (column.key === "actions") { + return ( +
+ + +
+ ); + } + return {row[column.key as keyof (typeof rows)[number]]}; + }} /> ); diff --git a/admin-web/src/features/users/UsersPage.tsx b/admin-web/src/features/users/UsersPage.tsx index de40ea6..adad485 100644 --- a/admin-web/src/features/users/UsersPage.tsx +++ b/admin-web/src/features/users/UsersPage.tsx @@ -8,8 +8,10 @@ import { Table } from "../../components/Table"; const columns = [ { key: "username", label: "Username" }, { key: "name", label: "Display name" }, + { key: "email", label: "Email" }, { key: "role", label: "Role" }, - { key: "status", label: "Status" } + { key: "status", label: "Status" }, + { key: "actions", label: "Actions" } ]; export function UsersPage() { @@ -25,6 +27,14 @@ export function UsersPage() { password: "", role: "user" }); + const [editingUserId, setEditingUserId] = useState(null); + const [editForm, setEditForm] = useState({ + display_name: "", + email: "", + role: "user", + password: "", + is_active: true + }); const createMutation = useMutation({ mutationFn: api.createUser, @@ -34,9 +44,27 @@ export function UsersPage() { } }); + const updateMutation = useMutation({ + mutationFn: ({ userId, payload }: { userId: string; payload: typeof editForm }) => api.updateUser(userId, payload), + onSuccess: () => { + setEditingUserId(null); + setEditForm({ display_name: "", email: "", role: "user", password: "", is_active: true }); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + } + }); + + const deleteMutation = useMutation({ + mutationFn: api.deleteUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["users"] }); + } + }); + const rows = query.data?.map((user) => ({ + id: user.id, username: user.username, name: user.display_name, + email: user.email || "-", role: user.role, status: user.is_active ? "active" : "disabled" })) ?? []; @@ -46,6 +74,32 @@ export function UsersPage() { createMutation.mutate(form); } + function startEdit(userId: string) { + const user = query.data?.find((item) => item.id === userId); + if (!user) { + return; + } + setEditingUserId(userId); + setEditForm({ + display_name: user.display_name, + email: user.email || "", + role: user.role, + password: "", + is_active: user.is_active + }); + } + + function onEditSubmit(event: FormEvent) { + event.preventDefault(); + if (!editingUserId) { + return; + } + updateMutation.mutate({ + userId: editingUserId, + payload: editForm + }); + } + return ( {query.isError ?

Unable to load users from the API.

: null} {createMutation.isError ?

Unable to create user.

: null} + {updateMutation.isError ?

Unable to update user.

: null} + {deleteMutation.isError ?

Unable to delete user.

: null} + {editingUserId ? ( +
+ setEditForm((value) => ({ ...value, display_name: event.target.value }))} /> + setEditForm((value) => ({ ...value, email: event.target.value }))} /> + + setEditForm((value) => ({ ...value, password: event.target.value }))} /> + +
+ + +
+ + ) : null}
{row[column.key as keyof (typeof rows)[number]]}} + renderCell={(row, column) => { + if (column.key === "actions") { + return ( +
+ + +
+ ); + } + return {row[column.key as keyof (typeof rows)[number]]}; + }} /> ); diff --git a/backend/internal/device/repository.go b/backend/internal/device/repository.go index 3b79f10..589a816 100644 --- a/backend/internal/device/repository.go +++ b/backend/internal/device/repository.go @@ -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 diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index 7c7ce92..eda3a34 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -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) diff --git a/backend/internal/policy/handler.go b/backend/internal/policy/handler.go index cac9861..12390d2 100644 --- a/backend/internal/policy/handler.go +++ b/backend/internal/policy/handler.go @@ -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}) +} diff --git a/backend/internal/policy/repository.go b/backend/internal/policy/repository.go index eaae8a6..40ab426 100644 --- a/backend/internal/policy/repository.go +++ b/backend/internal/policy/repository.go @@ -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 diff --git a/backend/internal/policy/service.go b/backend/internal/policy/service.go index 0c5df8b..e1fac9f 100644 --- a/backend/internal/policy/service.go +++ b/backend/internal/policy/service.go @@ -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) } diff --git a/backend/internal/policy/types.go b/backend/internal/policy/types.go index 49d5b91..cf98c54 100644 --- a/backend/internal/policy/types.go +++ b/backend/internal/policy/types.go @@ -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"` +} diff --git a/backend/internal/user/handler.go b/backend/internal/user/handler.go index 5b52897..b084bce 100644 --- a/backend/internal/user/handler.go +++ b/backend/internal/user/handler.go @@ -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 { diff --git a/backend/internal/user/repository.go b/backend/internal/user/repository.go index fffbe9e..db0259e 100644 --- a/backend/internal/user/repository.go +++ b/backend/internal/user/repository.go @@ -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 +} diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go index 8de3c55..52187bc 100644 --- a/backend/internal/user/service.go +++ b/backend/internal/user/service.go @@ -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 { diff --git a/backend/internal/user/types.go b/backend/internal/user/types.go index c779b1b..30c178e 100644 --- a/backend/internal/user/types.go +++ b/backend/internal/user/types.go @@ -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"` } diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index b25670b..90bdd28 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ mod tunnel_manager; use std::{fs, path::PathBuf, sync::Mutex}; -use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; use rand_core::OsRng; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -225,8 +225,8 @@ fn generate_keypair() -> (String, String) { let private = StaticSecret::random_from_rng(OsRng); let public = PublicKey::from(&private); ( - STANDARD_NO_PAD.encode(private.to_bytes()), - STANDARD_NO_PAD.encode(public.to_bytes()), + STANDARD.encode(private.to_bytes()), + STANDARD.encode(public.to_bytes()), ) }