chore: initial project scaffold with admin web, backend, desktop client, and deployment setup
Add monorepo structure for NexaVPN WireGuard control plane including: - .gitignore for node_modules, build artifacts, and environment files - README with project overview, monorepo layout, and quick start guide - Admin web UI with React, Vite, TypeScript, and nginx reverse proxy - API client with type definitions for users, devices, policies, gateways, and audit logs - Admin pages for dashboard, users, devices, policies, g
This commit is contained in:
137
backend/internal/auth/handler.go
Normal file
137
backend/internal/auth/handler.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
|
||||
)
|
||||
|
||||
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) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.service.Login(r.Context(), input.Username, input.Password, r.RemoteAddr, r.UserAgent())
|
||||
if err != nil {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
EventType: "auth.login.failed",
|
||||
EntityType: "user",
|
||||
Status: "failed",
|
||||
Message: "user login failed",
|
||||
Metadata: map[string]any{
|
||||
"username": input.Username,
|
||||
},
|
||||
})
|
||||
apiutil.Error(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &response.User.ID,
|
||||
EventType: "auth.login",
|
||||
EntityType: "user",
|
||||
EntityID: &response.User.ID,
|
||||
Status: "success",
|
||||
Message: "user login succeeded",
|
||||
})
|
||||
apiutil.JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
var input BootstrapRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||
return
|
||||
}
|
||||
if input.Username == "" || input.Password == "" {
|
||||
apiutil.Error(w, http.StatusBadRequest, "validation_error", "username and password are required")
|
||||
return
|
||||
}
|
||||
if input.DisplayName == "" {
|
||||
input.DisplayName = input.Username
|
||||
}
|
||||
|
||||
user, err := h.service.BootstrapAdmin(r.Context(), input.Username, input.DisplayName, input.Password)
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusConflict, "bootstrap_failed", "initial admin already exists or could not be created")
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &user.ID,
|
||||
EntityType: "user",
|
||||
EntityID: &user.ID,
|
||||
EventType: "system.bootstrap_admin",
|
||||
Status: "success",
|
||||
Message: "initial admin account created",
|
||||
})
|
||||
apiutil.JSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var input RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.service.Refresh(r.Context(), input.RefreshToken)
|
||||
if err != nil {
|
||||
apiutil.Error(w, http.StatusUnauthorized, "invalid_refresh_token", "unable to refresh session")
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var input RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(r.Context(), input.RefreshToken); err != nil {
|
||||
apiutil.Error(w, http.StatusBadRequest, "logout_failed", "unable to revoke session")
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||
ActorUserID: &claims.UserID,
|
||||
EventType: "auth.logout",
|
||||
EntityType: "session",
|
||||
Status: "success",
|
||||
Message: "session logout succeeded",
|
||||
})
|
||||
}
|
||||
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := httpserver.ClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(w, http.StatusOK, map[string]any{
|
||||
"id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
})
|
||||
}
|
||||
11
backend/internal/auth/hash.go
Normal file
11
backend/internal/auth/hash.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
func base64Hash(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
40
backend/internal/auth/password.go
Normal file
40
backend/internal/auth/password.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||
return fmt.Sprintf("argon2id$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(hashValue, password string) bool {
|
||||
parts := strings.Split(hashValue, "$")
|
||||
if len(parts) != 3 || parts[0] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||
return subtle.ConstantTimeCompare(expected, actual) == 1
|
||||
}
|
||||
105
backend/internal/auth/repository.go
Normal file
105
backend/internal/auth/repository.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PGRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||
return &PGRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PGRepository) FindUserByUsername(ctx context.Context, username string) (UserRecord, error) {
|
||||
const query = `
|
||||
select u.id, u.username, u.display_name, r.name, u.password_hash, u.is_active
|
||||
from users u
|
||||
join roles r on r.id = u.role_id
|
||||
where u.username = $1 and u.deleted_at is null
|
||||
`
|
||||
|
||||
row := r.db.QueryRow(ctx, query, username)
|
||||
record := UserRecord{}
|
||||
if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive); err != nil {
|
||||
return UserRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) {
|
||||
const query = `
|
||||
insert into sessions (id, user_id, ip_address, user_agent, expires_at)
|
||||
values ($1, $2, nullif($3, '')::inet, $4, $5)
|
||||
`
|
||||
|
||||
id := uuid.New()
|
||||
_, err := r.db.Exec(ctx, query, id, userID, ipAddress, userAgent, expiresAt)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error {
|
||||
const query = `
|
||||
insert into refresh_tokens (id, session_id, user_id, token_hash, expires_at)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(ctx, query, uuid.New(), sessionID, userID, tokenHash, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error) {
|
||||
const query = `
|
||||
select u.id, u.username, u.display_name, roles.name, u.password_hash, u.is_active, rt.session_id
|
||||
from refresh_tokens rt
|
||||
join users u on u.id = rt.user_id
|
||||
join roles on roles.id = u.role_id
|
||||
where rt.token_hash = $1 and rt.revoked_at is null and rt.expires_at > now()
|
||||
`
|
||||
|
||||
record := UserRecord{}
|
||||
var sessionID uuid.UUID
|
||||
row := r.db.QueryRow(ctx, query, tokenHash)
|
||||
if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive, &sessionID); err != nil {
|
||||
return UserRecord{}, uuid.Nil, err
|
||||
}
|
||||
if !record.IsActive {
|
||||
return UserRecord{}, uuid.Nil, errors.New("user inactive")
|
||||
}
|
||||
|
||||
return record, sessionID, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||
const query = `update refresh_tokens set revoked_at = now() where token_hash = $1 and revoked_at is null`
|
||||
_, err := r.db.Exec(ctx, query, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) HasUsers(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
if err := r.db.QueryRow(ctx, `select count(*) from users where deleted_at is null`).Scan(&count); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error) {
|
||||
const query = `
|
||||
insert into users (id, role_id, username, display_name, password_hash, is_active)
|
||||
values ($1, (select id from roles where name = 'admin'), $2, $3, $4, true)
|
||||
returning id, username, display_name, password_hash, is_active
|
||||
`
|
||||
|
||||
record := UserRecord{Role: "admin"}
|
||||
err := r.db.QueryRow(ctx, query, uuid.New(), username, displayName, passwordHash).
|
||||
Scan(&record.ID, &record.Username, &record.DisplayName, &record.PasswordHash, &record.IsActive)
|
||||
return record, err
|
||||
}
|
||||
155
backend/internal/auth/service.go
Normal file
155
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
|
||||
type UserRecord struct {
|
||||
ID uuid.UUID
|
||||
Username string
|
||||
DisplayName string
|
||||
Role string
|
||||
PasswordHash string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
FindUserByUsername(ctx context.Context, username string) (UserRecord, error)
|
||||
CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error)
|
||||
StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error
|
||||
FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error)
|
||||
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
||||
HasUsers(ctx context.Context) (bool, error)
|
||||
CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
jwtSecret string
|
||||
jwtIssuer string
|
||||
accessTokenTTL time.Duration
|
||||
refreshTokenTTL time.Duration
|
||||
}
|
||||
|
||||
func NewService(repo Repository, jwtSecret, jwtIssuer string, accessTokenTTL, refreshTokenTTL time.Duration) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
jwtIssuer: jwtIssuer,
|
||||
accessTokenTTL: accessTokenTTL,
|
||||
refreshTokenTTL: refreshTokenTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password, ipAddress, userAgent string) (LoginResponse, error) {
|
||||
record, err := s.repo.FindUserByUsername(ctx, username)
|
||||
if err != nil || !record.IsActive || !VerifyPassword(record.PasswordHash, password) {
|
||||
return LoginResponse{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
sessionID, err := s.repo.CreateSession(ctx, record.ID, time.Now().Add(s.refreshTokenTTL), ipAddress, userAgent)
|
||||
if err != nil {
|
||||
return LoginResponse{}, err
|
||||
}
|
||||
|
||||
plainRefresh, hashedRefresh, err := NewRefreshToken()
|
||||
if err != nil {
|
||||
return LoginResponse{}, err
|
||||
}
|
||||
|
||||
if err := s.repo.StoreRefreshToken(ctx, sessionID, record.ID, hashedRefresh, time.Now().Add(s.refreshTokenTTL)); err != nil {
|
||||
return LoginResponse{}, err
|
||||
}
|
||||
|
||||
access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{
|
||||
UserID: record.ID,
|
||||
Username: record.Username,
|
||||
Role: record.Role,
|
||||
Session: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return LoginResponse{}, err
|
||||
}
|
||||
|
||||
return LoginResponse{
|
||||
AccessToken: access,
|
||||
RefreshToken: plainRefresh,
|
||||
ExpiresIn: int64(s.accessTokenTTL.Seconds()),
|
||||
User: UserView{
|
||||
ID: record.ID,
|
||||
Username: record.Username,
|
||||
DisplayName: record.DisplayName,
|
||||
Role: record.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (LoginResponse, error) {
|
||||
record, sessionID, err := s.repo.FindRefreshToken(ctx, hashToken(refreshToken))
|
||||
if err != nil {
|
||||
return LoginResponse{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{
|
||||
UserID: record.ID,
|
||||
Username: record.Username,
|
||||
Role: record.Role,
|
||||
Session: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return LoginResponse{}, err
|
||||
}
|
||||
|
||||
return LoginResponse{
|
||||
AccessToken: access,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int64(s.accessTokenTTL.Seconds()),
|
||||
User: UserView{
|
||||
ID: record.ID,
|
||||
Username: record.Username,
|
||||
DisplayName: record.DisplayName,
|
||||
Role: record.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
|
||||
return s.repo.RevokeRefreshToken(ctx, hashToken(refreshToken))
|
||||
}
|
||||
|
||||
func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) {
|
||||
hasUsers, err := s.repo.HasUsers(ctx)
|
||||
if err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
if hasUsers {
|
||||
return UserView{}, errors.New("bootstrap already completed")
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
|
||||
record, err := s.repo.CreateBootstrapAdmin(ctx, username, displayName, passwordHash)
|
||||
if err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
|
||||
return UserView{
|
||||
ID: record.ID,
|
||||
Username: record.Username,
|
||||
DisplayName: record.DisplayName,
|
||||
Role: record.Role,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hashToken(plain string) string {
|
||||
return base64Hash(plain)
|
||||
}
|
||||
77
backend/internal/auth/token.go
Normal file
77
backend/internal/auth/token.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func NewRefreshToken() (plain string, hashed string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err = rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
plain = base64.RawURLEncoding.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hashed = base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return plain, hashed, nil
|
||||
}
|
||||
|
||||
func SignAccessToken(secret, issuer string, ttl time.Duration, claims Claims) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"sub": claims.UserID.String(),
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
"session_id": claims.Session.String(),
|
||||
"exp": time.Now().Add(ttl).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseAccessToken(secret string, tokenString string) (Claims, error) {
|
||||
claims := Claims{}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return claims, err
|
||||
}
|
||||
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return claims, jwt.ErrTokenMalformed
|
||||
}
|
||||
|
||||
subject, ok := mapClaims["sub"].(string)
|
||||
if !ok {
|
||||
return claims, jwt.ErrTokenMalformed
|
||||
}
|
||||
sessionValue, ok := mapClaims["session_id"].(string)
|
||||
if !ok {
|
||||
return claims, jwt.ErrTokenMalformed
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(subject)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
sessionID, err := uuid.Parse(sessionValue)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
|
||||
claims.UserID = userID
|
||||
claims.Session = sessionID
|
||||
claims.Username, _ = mapClaims["username"].(string)
|
||||
claims.Role, _ = mapClaims["role"].(string)
|
||||
return claims, nil
|
||||
}
|
||||
39
backend/internal/auth/types.go
Normal file
39
backend/internal/auth/types.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Claims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Session uuid.UUID `json:"session_id"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type BootstrapRequest struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
User UserView `json:"user"`
|
||||
}
|
||||
|
||||
type UserView struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
Reference in New Issue
Block a user