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:
2026-03-17 20:49:38 +01:00
parent a52777602f
commit cf65dc0e41
14 changed files with 502 additions and 12 deletions

View File

@@ -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<User>(`/admin/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload)
}),
deleteUser: (userId: string) =>
request<{ ok: boolean }>(`/admin/users/${userId}`, {
method: "DELETE"
}),
devices: () => request<Device[]>("/admin/devices"),
deviceProfile: (deviceId: string) => request<DeviceProfile>(`/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<Policy>(`/admin/policies/${policyId}`, {
method: "PATCH",
body: JSON.stringify(payload)
}),
deletePolicy: (policyId: string) =>
request<{ ok: boolean }>(`/admin/policies/${policyId}`, {
method: "DELETE"
}),
gateways: () => request<Gateway[]>("/admin/gateways"),
updateGateway: (gatewayId: string, payload: {
endpoint: string;

View File

@@ -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<string | null>(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 (
<Page
title="Policies"
@@ -94,10 +153,46 @@ export function PoliciesPage() {
</form>
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create policy.</p> : null}
{updateMutation.isError ? <p className="notice">Unable to update policy.</p> : null}
{deleteMutation.isError ? <p className="notice">Unable to delete policy.</p> : null}
{editingPolicyId ? (
<form className="inline-form" onSubmit={onEditSubmit}>
<input placeholder="policy name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
<input
placeholder="destinations"
value={editForm.destinations}
onChange={(event) => setEditForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={editForm.fullTunnel}
/>
<label className="checkbox">
<input type="checkbox" checked={editForm.fullTunnel} onChange={(event) => setEditForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
</label>
<label className="checkbox">
<input type="checkbox" checked={editForm.isActive} onChange={(event) => setEditForm((value) => ({ ...value, isActive: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save policy</button>
<button className="ghost-button" type="button" onClick={() => setEditingPolicyId(null)}>Cancel</button>
</div>
</form>
) : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
renderCell={(row, column) => {
if (column.key === "actions") {
return (
<div className="action-row">
<button className="ghost-button" type="button" onClick={() => startEdit(row.id)}>Edit</button>
<button className="ghost-button" type="button" onClick={() => deleteMutation.mutate(row.id)}>Delete</button>
</div>
);
}
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/>
</Page>
);

View File

@@ -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<string | null>(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 (
<Page
title="Users"
@@ -65,10 +119,41 @@ export function UsersPage() {
</form>
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create user.</p> : null}
{updateMutation.isError ? <p className="notice">Unable to update user.</p> : null}
{deleteMutation.isError ? <p className="notice">Unable to delete user.</p> : null}
{editingUserId ? (
<form className="inline-form" onSubmit={onEditSubmit}>
<input placeholder="display name" value={editForm.display_name} onChange={(event) => setEditForm((value) => ({ ...value, display_name: event.target.value }))} />
<input placeholder="email" value={editForm.email} onChange={(event) => setEditForm((value) => ({ ...value, email: event.target.value }))} />
<select value={editForm.role} onChange={(event) => setEditForm((value) => ({ ...value, role: event.target.value }))}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<input placeholder="new password (optional)" type="password" value={editForm.password} onChange={(event) => setEditForm((value) => ({ ...value, password: event.target.value }))} />
<label className="checkbox">
<input type="checkbox" checked={editForm.is_active} onChange={(event) => setEditForm((value) => ({ ...value, is_active: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save user</button>
<button className="ghost-button" type="button" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
</form>
) : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
renderCell={(row, column) => {
if (column.key === "actions") {
return (
<div className="action-row">
<button className="ghost-button" type="button" onClick={() => startEdit(row.id)}>Edit</button>
<button className="ghost-button" type="button" onClick={() => deleteMutation.mutate(row.id)}>Delete</button>
</div>
);
}
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/>
</Page>
);

View File

@@ -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

View File

@@ -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)

View File

@@ -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})
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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()),
)
}