mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 06:25:35 +08:00
Add ConfigRepository and enhance server configuration management
1. Database Enhancements (db/repositories.go): - Added ConfigRepository interface and implementations for PostgreSQL and SQLite - Updated Repositories struct to include ConfigRepository - Modified NewRepositories to initialize both UserRepo and ConfigRepo 2. FSD Server Improvements: - Removed hardcoded jwtSecret, now retrieved from ConfigRepository (fsd/conn.go, web/auth.go) - Added dynamic welcome message retrieval from ConfigRepository (fsd/conn.go) - Optimized METAR buffer size from 4096 to 512 bytes (fsd/metar.go) - Reduced minimum fields for DeleteATC and DeletePilot packets (fsd/packet.go) - Improved Haversine distance calculation with constants (fsd/postoffice.go) - Added thread-safety documentation for sendError (fsd/client.go) 3. Server Configuration (fsd/server.go): - Added NewDefaultServer to initialize server with environment-based config - Implemented automatic database migration and default admin user creation - Added configurable METAR worker count - Improved logging with slog and environment-based debug level 4. Web Interface Enhancements: - Added user and config editor frontend routes (web/frontend.go, web/routes.go) - Improved JWT handling by retrieving secret from ConfigRepository (web/auth.go) - Enhanced user management API endpoints (web/user.go) - Updated dashboard to display CID and conditional admin links (web/templates/dashboard.html) - Embedded templates using go:embed (web/templates.go) 5. Frontend JavaScript Improvements: - Added networkRatingFromInt helper for readable ratings (web/static/js/openfsd/dashboard.js) - Improved API request handling with auth/no-auth variants (web/static/js/openfsd/api.js) 6. Miscellaneous: - Added sethvargo/go-envconfig dependency for environment variable parsing - Fixed parseVisRange to use 64-bit float parsing (fsd/util.go) - Added strPtr utility function (fsd/util.go, web/main.go) - Improved SVG logo rendering in layout (web/templates/layout.html)
This commit is contained in:
66
web/api_tokens.go
Normal file
66
web/api_tokens.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"github.com/renorris/openfsd/fsd"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handleCreateNewAPIToken(c *gin.Context) {
|
||||
claims := getJwtContext(c)
|
||||
if claims.NetworkRating < fsd.NetworkRatingAdministator {
|
||||
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
type RequestBody struct {
|
||||
ExpiryDateTime time.Time `json:"expiry_date_time" time_format:"2006-01-02T15:04:05.000Z" binding:"required"`
|
||||
}
|
||||
|
||||
var reqBody RequestBody
|
||||
if ok := bindJSONOrAbort(c, &reqBody); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if reqBody.ExpiryDateTime.Before(now) {
|
||||
res := newAPIV1Failure("expiry_date_time cannot be in the past")
|
||||
writeAPIV1Response(c, http.StatusBadRequest, &res)
|
||||
return
|
||||
}
|
||||
|
||||
validityDuration := reqBody.ExpiryDateTime.Sub(now)
|
||||
|
||||
accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{
|
||||
TokenType: "access",
|
||||
CID: claims.CID,
|
||||
NetworkRating: fsd.NetworkRatingAdministator,
|
||||
}, validityDuration)
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
accessTokenStr, err := accessToken.SignedString([]byte(secretKey))
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
resBody := ResponseBody{Token: accessTokenStr}
|
||||
res := newAPIV1Success(&resBody)
|
||||
writeAPIV1Response(c, http.StatusCreated, &res)
|
||||
}
|
||||
46
web/auth.go
46
web/auth.go
@@ -71,7 +71,13 @@ func (s *Server) refreshAccessToken(c *gin.Context) {
|
||||
|
||||
badTokenRes := newAPIV1Failure("bad token")
|
||||
|
||||
refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, s.jwtSecret)
|
||||
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, []byte(jwtSecret))
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes)
|
||||
return
|
||||
@@ -90,7 +96,7 @@ func (s *Server) refreshAccessToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
access, err := s.makeAccessToken(user)
|
||||
access, err := s.makeAccessToken(user, []byte(jwtSecret))
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
@@ -174,7 +180,13 @@ func (s *Server) getFsdJwt(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fsdJwtTokenStr, err := fsdJwtToken.SignedString(s.jwtSecret)
|
||||
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fsdJwtTokenStr, err := fsdJwtToken.SignedString([]byte(jwtSecret))
|
||||
if err != nil {
|
||||
resBody := ResponseBody{
|
||||
ErrorMsg: "Internal server error",
|
||||
@@ -200,13 +212,22 @@ func (s *Server) jwtBearerMiddleware(c *gin.Context) {
|
||||
if !found {
|
||||
res := newAPIV1Failure("bad bearer token")
|
||||
writeAPIV1Response(c, http.StatusBadRequest, &res)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := fsd.ParseJwtToken(authHeader, s.jwtSecret)
|
||||
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := fsd.ParseJwtToken(authHeader, []byte(jwtSecret))
|
||||
if err != nil {
|
||||
res := newAPIV1Failure("invalid bearer token")
|
||||
writeAPIV1Response(c, http.StatusUnauthorized, &res)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,12 +254,17 @@ func getJwtContext(c *gin.Context) (claims *fsd.CustomClaims) {
|
||||
}
|
||||
|
||||
func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access string, refresh string, err error) {
|
||||
access, err = s.makeAccessToken(user)
|
||||
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
refresh, err = s.makeRefreshToken(user, rememberMe)
|
||||
access, err = s.makeAccessToken(user, []byte(jwtSecret))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
refresh, err = s.makeRefreshToken(user, rememberMe, []byte(jwtSecret))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -246,7 +272,7 @@ func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) makeAccessToken(user *db.User) (access string, err error) {
|
||||
func (s *Server) makeAccessToken(user *db.User, jwtSecret []byte) (access string, err error) {
|
||||
// Make access token
|
||||
accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{
|
||||
TokenType: "access",
|
||||
@@ -259,7 +285,7 @@ func (s *Server) makeAccessToken(user *db.User) (access string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
access, err = accessToken.SignedString(s.jwtSecret)
|
||||
access, err = accessToken.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -267,7 +293,7 @@ func (s *Server) makeAccessToken(user *db.User) (access string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) makeRefreshToken(user *db.User, rememberMe bool) (refresh string, err error) {
|
||||
func (s *Server) makeRefreshToken(user *db.User, rememberMe bool, jwtSecret []byte) (refresh string, err error) {
|
||||
refreshTokenDuration := time.Hour * 24
|
||||
if rememberMe {
|
||||
refreshTokenDuration = time.Hour * 24 * 30
|
||||
@@ -285,7 +311,7 @@ func (s *Server) makeRefreshToken(user *db.User, rememberMe bool) (refresh strin
|
||||
return
|
||||
}
|
||||
|
||||
refresh, err = refreshToken.SignedString(s.jwtSecret)
|
||||
refresh, err = refreshToken.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
107
web/config.go
Normal file
107
web/config.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"github.com/renorris/openfsd/fsd"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type KeyValuePair struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetConfig(c *gin.Context) {
|
||||
claims := getJwtContext(c)
|
||||
if claims.NetworkRating < fsd.NetworkRatingAdministator {
|
||||
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var configKeys = []string{
|
||||
db.ConfigWelcomeMessage,
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"`
|
||||
}
|
||||
|
||||
resBody := ResponseBody{
|
||||
KeyValuePairs: make([]KeyValuePair, 0, len(configKeys)),
|
||||
}
|
||||
|
||||
for i := range configKeys {
|
||||
key := configKeys[i]
|
||||
val, err := s.dbRepo.ConfigRepo.Get(key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrConfigKeyNotFound) {
|
||||
res := newAPIV1Failure("Error reading key/value from persistent storage")
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &res)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
resBody.KeyValuePairs = append(resBody.KeyValuePairs,
|
||||
KeyValuePair{
|
||||
Key: key,
|
||||
Value: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
res := newAPIV1Success(&resBody)
|
||||
writeAPIV1Response(c, http.StatusOK, &res)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateConfig(c *gin.Context) {
|
||||
claims := getJwtContext(c)
|
||||
if claims.NetworkRating < fsd.NetworkRatingAdministator {
|
||||
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
type RequestBody struct {
|
||||
KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"`
|
||||
}
|
||||
|
||||
var reqBody RequestBody
|
||||
if ok := bindJSONOrAbort(c, &reqBody); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range reqBody.KeyValuePairs {
|
||||
kv := reqBody.KeyValuePairs[i]
|
||||
if err := s.dbRepo.ConfigRepo.Set(kv.Key, kv.Value); err != nil {
|
||||
res := newAPIV1Failure("Error writing key/value into persistent storage")
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &res)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res := newAPIV1Success(nil)
|
||||
writeAPIV1Response(c, http.StatusOK, &res)
|
||||
}
|
||||
|
||||
func (s *Server) handleResetSecretKey(c *gin.Context) {
|
||||
claims := getJwtContext(c)
|
||||
if claims.NetworkRating < fsd.NetworkRatingAdministator {
|
||||
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
secretKey, err := db.GenerateJwtSecretKey()
|
||||
if err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, string(secretKey[:])); err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res := newAPIV1Success(nil)
|
||||
writeAPIV1Response(c, http.StatusOK, &res)
|
||||
}
|
||||
@@ -15,3 +15,11 @@ func (s *Server) handleFrontendLogin(c *gin.Context) {
|
||||
func (s *Server) handleFrontendDashboard(c *gin.Context) {
|
||||
writeTemplate(c, "dashboard", nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleFrontendUserEditor(c *gin.Context) {
|
||||
writeTemplate(c, "usereditor", nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleFrontendConfigEditor(c *gin.Context) {
|
||||
writeTemplate(c, "configeditor", nil)
|
||||
}
|
||||
|
||||
17
web/main.go
17
web/main.go
@@ -21,14 +21,29 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
strPtr := func(str string) *string {
|
||||
return &str
|
||||
}
|
||||
|
||||
if err = dbRepo.UserRepo.CreateUser(&db.User{
|
||||
FirstName: strPtr("Default Administrator"),
|
||||
Password: "12345",
|
||||
NetworkRating: 12,
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
server, err := NewServer(dbRepo, []byte("abcdef"))
|
||||
err = dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, "abcdef")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = dbRepo.ConfigRepo.InitDefault()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
server, err := NewServer(dbRepo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
|
||||
apiV1Group.POST("/fsd-jwt", s.getFsdJwt)
|
||||
s.setupAuthRoutes(apiV1Group)
|
||||
s.setupUserRoutes(apiV1Group)
|
||||
s.setupConfigRoutes(apiV1Group)
|
||||
|
||||
// Frontend groups
|
||||
s.setupFrontendRoutes(e.Group(""))
|
||||
@@ -39,9 +40,20 @@ func (s *Server) setupAuthRoutes(parent *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func (s *Server) setupUserRoutes(parent *gin.RouterGroup) {
|
||||
usersGroup := parent.Group("/user").Use(s.jwtBearerMiddleware)
|
||||
usersGroup.POST("/load", s.getUserInfo)
|
||||
usersGroup.POST("/update", s.updateUser)
|
||||
usersGroup := parent.Group("/user")
|
||||
usersGroup.Use(s.jwtBearerMiddleware)
|
||||
usersGroup.POST("/load", s.getUserByCID)
|
||||
usersGroup.PATCH("/update", s.updateUser)
|
||||
usersGroup.POST("/create", s.createUser)
|
||||
}
|
||||
|
||||
func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
|
||||
configGroup := parent.Group("/config")
|
||||
configGroup.Use(s.jwtBearerMiddleware)
|
||||
configGroup.GET("/load", s.handleGetConfig)
|
||||
configGroup.POST("/update", s.handleUpdateConfig)
|
||||
configGroup.POST("/resetsecretkey", s.handleResetSecretKey)
|
||||
configGroup.POST("/createtoken", s.handleCreateNewAPIToken)
|
||||
}
|
||||
|
||||
func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
|
||||
@@ -49,4 +61,6 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
|
||||
frontendGroup.GET("", s.handleFrontendLanding)
|
||||
frontendGroup.GET("/login", s.handleFrontendLogin)
|
||||
frontendGroup.GET("/dashboard", s.handleFrontendDashboard)
|
||||
frontendGroup.GET("/usereditor", s.handleFrontendUserEditor)
|
||||
frontendGroup.GET("/configeditor", s.handleFrontendConfigEditor)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,12 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
dbRepo *db.Repositories
|
||||
jwtSecret []byte
|
||||
dbRepo *db.Repositories
|
||||
}
|
||||
|
||||
func NewServer(dbRepo *db.Repositories, jwtSecret []byte) (server *Server, err error) {
|
||||
func NewServer(dbRepo *db.Repositories) (server *Server, err error) {
|
||||
server = &Server{
|
||||
dbRepo: dbRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
dbRepo: dbRepo,
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
async function doAPIRequestWithAuth(method, url, data) {
|
||||
return doAPIRequest(method, url, true, data)
|
||||
}
|
||||
|
||||
async function doAPIRequestNoAuth(method, url, data) {
|
||||
return doAPIRequest(method, url, false, data)
|
||||
}
|
||||
|
||||
async function doAPIRequest(method, url, withAuth, data) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let accessToken = "";
|
||||
@@ -15,6 +23,7 @@ async function doAPIRequest(method, url, withAuth, data) {
|
||||
}).done((res) => {
|
||||
resolve(res)
|
||||
}).fail((xhr) => {
|
||||
logout()
|
||||
reject(xhr)
|
||||
});
|
||||
});
|
||||
@@ -86,3 +95,9 @@ function decodeJwt(token) {
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
window.location.href = "/login"
|
||||
}
|
||||
|
||||
189
web/static/js/openfsd/configeditor.js
Normal file
189
web/static/js/openfsd/configeditor.js
Normal file
@@ -0,0 +1,189 @@
|
||||
const keyLabels = {
|
||||
"WELCOME_MESSAGE": "Welcome Message",
|
||||
};
|
||||
|
||||
// Function to show message modal
|
||||
function showMessageModal(message, token) {
|
||||
const messageText = document.getElementById('messageText');
|
||||
if (token) {
|
||||
messageText.innerHTML = message + ' <div class="d-flex align-items-center"><code class="api-key me-2">' + token + '</code><button class="btn btn-sm btn-outline-secondary copy-btn">Copy</button></div>';
|
||||
const copyBtn = messageText.querySelector('.copy-btn');
|
||||
copyBtn.addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = 'Copy';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
messageText.textContent = message;
|
||||
}
|
||||
const messageModal = new bootstrap.Modal(document.getElementById('messageModal'));
|
||||
messageModal.show();
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('GET', '/api/v1/config/load');
|
||||
if (res.err) {
|
||||
alert('Error: ' + res.err);
|
||||
return;
|
||||
}
|
||||
const configForm = document.getElementById('config-form');
|
||||
configForm.innerHTML = ''; // Clear existing fields
|
||||
res.data.key_value_pairs.forEach(kv => {
|
||||
const label = keyLabels[kv.key] || kv.key;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mb-3';
|
||||
div.innerHTML = `
|
||||
<label for="${kv.key}" class="form-label">${label}</label>
|
||||
<input type="text" class="form-control" id="${kv.key}" value="${kv.value}" data-key="${kv.key}">
|
||||
`;
|
||||
configForm.appendChild(div);
|
||||
});
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
alert('Error: ' + errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('add-config').addEventListener('click', function() {
|
||||
const configForm = document.getElementById('config-form');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mb-3 new-config';
|
||||
// Create dropdown options from keyLabels
|
||||
let options = '';
|
||||
Object.keys(keyLabels).forEach(key => {
|
||||
options += `<option value="${key}">${keyLabels[key]}</option>`;
|
||||
});
|
||||
div.innerHTML = `
|
||||
<label class="form-label">New Key</label>
|
||||
<select class="form-control mb-2" data-type="new-key">
|
||||
<option value="" disabled selected>Select a key</option>
|
||||
${options}
|
||||
</select>
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" placeholder="Value" data-type="new-value">
|
||||
`;
|
||||
configForm.appendChild(div);
|
||||
});
|
||||
|
||||
document.getElementById('save-config').addEventListener('click', async function() {
|
||||
const keyValuePairs = [];
|
||||
|
||||
// Existing configs
|
||||
const existingInputs = document.querySelectorAll('#config-form input[data-key]');
|
||||
existingInputs.forEach(input => {
|
||||
keyValuePairs.push({
|
||||
key: input.getAttribute('data-key'),
|
||||
value: input.value
|
||||
});
|
||||
});
|
||||
|
||||
// New configs
|
||||
const newConfigDivs = document.querySelectorAll('#config-form .new-config');
|
||||
newConfigDivs.forEach(div => {
|
||||
const keySelect = div.querySelector('select[data-type="new-key"]');
|
||||
const valueInput = div.querySelector('input[data-type="new-value"]');
|
||||
if (keySelect && valueInput && keySelect.value.trim() !== '') {
|
||||
keyValuePairs.push({
|
||||
key: keySelect.value,
|
||||
value: valueInput.value
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/config/update', { key_value_pairs: keyValuePairs });
|
||||
if (res.err) {
|
||||
alert('Error: ' + res.err);
|
||||
} else {
|
||||
alert('Config updated successfully');
|
||||
loadConfig(); // Reload to show new configs if added
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
alert('Error: ' + errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
// Added function to reset the JWT Secret Key
|
||||
async function resetJwtSecretKey() {
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/config/resetsecretkey');
|
||||
if (res.err) {
|
||||
alert('Error: ' + res.err);
|
||||
} else {
|
||||
alert('JWT Secret Key reset successfully');
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
alert('Error: ' + errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
}
|
||||
|
||||
// Added event listener for the reset button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('reset-jwt-secret').addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to reset the JWT Secret Key? All previously generated API tokens and all active sessions will be invalidated.')) {
|
||||
resetJwtSecretKey();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Modified event listener for create new API token button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('create-new-api-key').addEventListener('click', function() {
|
||||
const createTokenModal = new bootstrap.Modal(document.getElementById('createTokenModal'));
|
||||
createTokenModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Added event listener for submit create token
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('submitCreateToken').addEventListener('click', async function() {
|
||||
const expiryDateStr = document.getElementById('expiryDate').value;
|
||||
let expiryDate;
|
||||
if (expiryDateStr) {
|
||||
const [year, month, day] = expiryDateStr.split('-').map(Number);
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) {
|
||||
showMessageModal('Invalid date format.');
|
||||
return;
|
||||
}
|
||||
expiryDate = new Date(Date.UTC(year, month - 1, day));
|
||||
if (isNaN(expiryDate.getTime())) {
|
||||
showMessageModal('Invalid date.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
expiryDate = new Date();
|
||||
expiryDate.setFullYear(expiryDate.getFullYear() + 1);
|
||||
}
|
||||
const expiryDateTime = expiryDate.toJSON();
|
||||
const data = {
|
||||
"expiry_date_time": expiryDateTime
|
||||
};
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/config/createtoken', data);
|
||||
if (res.err) {
|
||||
showMessageModal('Error: ' + res.err);
|
||||
} else {
|
||||
showMessageModal('API Token created:', res.data.token);
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
showMessageModal('Error: ' + errMsg);
|
||||
}
|
||||
// Hide the createTokenModal
|
||||
const createTokenModal = bootstrap.Modal.getInstance(document.getElementById('createTokenModal'));
|
||||
createTokenModal.hide();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConfig);
|
||||
@@ -1,3 +1,23 @@
|
||||
function networkRatingFromInt(val) {
|
||||
switch (val) {
|
||||
case -1: return "Inactive"
|
||||
case 0: return "Suspended"
|
||||
case 1: return "Observer"
|
||||
case 2: return "Student 1"
|
||||
case 3: return "Student 2"
|
||||
case 4: return "Student 3"
|
||||
case 5: return "Controller 1"
|
||||
case 6: return "Controller 2"
|
||||
case 7: return "Controller 3"
|
||||
case 8: return "Instructor 1"
|
||||
case 9: return "Instructor 2"
|
||||
case 10: return "Instructor 3"
|
||||
case 11: return "Supervisor"
|
||||
case 12: return "Administrator"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(async () => {
|
||||
const claims = getAccessTokenClaims()
|
||||
loadUserInfo(claims.cid)
|
||||
@@ -27,7 +47,15 @@ async function loadUserInfo(cid) {
|
||||
$("#dashboard-real-name").text("Welcome!")
|
||||
}
|
||||
|
||||
$("#dashboard-network-rating").text(res.data.network_rating)
|
||||
$("#dashboard-network-rating").text(networkRatingFromInt(res.data.network_rating))
|
||||
$("#dashboard-cid").text(`CID: ${res.data.cid}`)
|
||||
|
||||
if (res.data.network_rating >= 11) {
|
||||
$("#dashboard-user-editor").html(`
|
||||
<div class="mb-2"><a href="/usereditor" class="btn btn-primary">Edit Users</a></div>
|
||||
<div class="mb-2"><a href="/configeditor" class="btn btn-primary">Configure Server</a></div>
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
let dashboardMarkers = [];
|
||||
|
||||
128
web/static/js/openfsd/usereditor.js
Normal file
128
web/static/js/openfsd/usereditor.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Form Handlers
|
||||
document.getElementById('search-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
const cid = document.getElementById('search-cid').value;
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/load', {cid: parseInt(cid)});
|
||||
if (res.err) {
|
||||
alert('Error: ' + res.err);
|
||||
} else {
|
||||
const user = res.data;
|
||||
document.getElementById('edit-cid').value = user.cid;
|
||||
document.getElementById('edit-cid').hidden = false;
|
||||
document.getElementById('edit-first-name').value = user.first_name || '';
|
||||
document.getElementById('edit-last-name').value = user.last_name || '';
|
||||
document.getElementById('edit-network-rating').value = user.network_rating;
|
||||
document.getElementById('edit-password').value = '';
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
alert('Error: ' + errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
const firstName = document.getElementById('create-first-name').value;
|
||||
const lastName = document.getElementById('create-last-name').value;
|
||||
const password = document.getElementById('create-password').value;
|
||||
const networkRating = document.getElementById('create-network-rating').value;
|
||||
const data = {
|
||||
password: password,
|
||||
network_rating: parseInt(networkRating)
|
||||
};
|
||||
if (firstName) data.first_name = firstName;
|
||||
if (lastName) data.last_name = lastName;
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/create', data);
|
||||
if (res.err) {
|
||||
alert('Error: ' + res.err);
|
||||
} else {
|
||||
alert('User created successfully. CID: ' + res.data.cid);
|
||||
document.getElementById('create-first-name').value = '';
|
||||
document.getElementById('create-last-name').value = '';
|
||||
document.getElementById('create-password').value = '';
|
||||
document.getElementById('create-network-rating').value = '-1';
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
alert('Error: ' + errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('edit-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
const cid = document.getElementById('edit-cid').value;
|
||||
const firstName = document.getElementById('edit-first-name').value;
|
||||
const lastName = document.getElementById('edit-last-name').value;
|
||||
const networkRating = document.getElementById('edit-network-rating').value;
|
||||
const password = document.getElementById('edit-password').value;
|
||||
const data = {
|
||||
cid: parseInt(cid),
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
network_rating: parseInt(networkRating)
|
||||
};
|
||||
if (password) data.password = password;
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('PATCH', '/api/v1/user/update', data);
|
||||
if (res.err) {
|
||||
alert('Error: ' + res.err);
|
||||
} else {
|
||||
alert('User updated successfully');
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
alert('Error: ' + errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const createPasswordInput = document.getElementById('create-password');
|
||||
const createStrengthBar = document.getElementById('create-password-strength');
|
||||
const createFeedback = document.getElementById('create-password-feedback');
|
||||
const editPasswordInput = document.getElementById('edit-password');
|
||||
const editStrengthBar = document.getElementById('edit-password-strength');
|
||||
const editFeedback = document.getElementById('edit-password-feedback');
|
||||
|
||||
const evaluatePassword = (password, strengthBar, feedback) => {
|
||||
if (!password) {
|
||||
strengthBar.style.width = '0%';
|
||||
strengthBar.className = 'progress-bar';
|
||||
feedback.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let strength = 0;
|
||||
if (password.length === 8) strength += 50;
|
||||
if (/[A-Z]/.test(password)) strength += 15;
|
||||
if (/[a-z]/.test(password)) strength += 15;
|
||||
if (/[0-9]/.test(password)) strength += 10;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength += 10;
|
||||
|
||||
strength = Math.min(strength, 100);
|
||||
strengthBar.style.width = `${strength}%`;
|
||||
|
||||
if (strength < 60) {
|
||||
strengthBar.className = 'progress-bar bg-danger';
|
||||
feedback.textContent = 'Weak: Include uppercase, lowercase, numbers, or symbols.';
|
||||
} else if (strength < 80) {
|
||||
strengthBar.className = 'progress-bar bg-warning';
|
||||
feedback.textContent = 'Moderate: Add more character types for strength.';
|
||||
} else {
|
||||
strengthBar.className = 'progress-bar bg-success';
|
||||
feedback.textContent = 'Strong: Good password!';
|
||||
}
|
||||
};
|
||||
|
||||
createPasswordInput.addEventListener('input', () => {
|
||||
evaluatePassword(createPasswordInput.value, createStrengthBar, createFeedback);
|
||||
});
|
||||
|
||||
editPasswordInput.addEventListener('input', () => {
|
||||
evaluatePassword(editPasswordInput.value, editStrengthBar, editFeedback);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"github.com/gin-gonic/gin"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -9,10 +10,13 @@ import (
|
||||
"path"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
var basePath = path.Join(".", "templates")
|
||||
|
||||
func loadTemplate(key string) (t *template.Template) {
|
||||
t, err := template.ParseFiles(path.Join(basePath, "layout.html"), path.Join(basePath, key+".html"))
|
||||
t, err := template.ParseFS(templatesFS, path.Join(basePath, "layout.html"), path.Join(basePath, key+".html"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
71
web/templates/configeditor.html
Normal file
71
web/templates/configeditor.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{{ define "title" }}Config Editor{{ end }}
|
||||
|
||||
{{ define "body" }}
|
||||
<div class="container mt-4" style="max-width: 600px">
|
||||
<div class="mb-3 card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Config Editor</h5>
|
||||
<form id="config-form">
|
||||
<!-- Config fields will be dynamically added here -->
|
||||
</form>
|
||||
<button type="button" class="btn btn-secondary mt-2" id="add-config">Add New Config</button>
|
||||
<button type="button" class="btn btn-primary mt-2" id="save-config">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="my-2 rounded text-center fs-5">
|
||||
API Tokens
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-success mx-2 mb-2" id="create-new-api-key">Create</button>
|
||||
<button type="button" class="btn btn-danger mx-2 mb-2" id="reset-jwt-secret">Reset All</button>
|
||||
</div>
|
||||
<!-- Create Token Modal -->
|
||||
<div class="modal fade" id="createTokenModal" tabindex="-1" aria-labelledby="createTokenModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="createTokenModalLabel">Create New API Token</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="createTokenForm">
|
||||
<div class="mb-3">
|
||||
<label for="expiryDate" class="form-label">Expiry Date (YYYY-MM-DD, leave blank for one year from now)</label>
|
||||
<input type="date" class="form-control" id="expiryDate">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="submitCreateToken">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Message Modal -->
|
||||
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="messageModalLabel">Message</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="messageText"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.api-key {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="/static/js/openfsd/configeditor.js"></script>
|
||||
{{ end }}
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
{{ define "body" }}
|
||||
<div class="container-fluid d-flex flex-column justify-content-center align-items-center">
|
||||
<div id="dashboard-real-name">Loading...</div>
|
||||
<div>Network Rating: <span id="dashboard-network-rating">Loading...</span></div>
|
||||
<div><span id="dashboard-connection-count"></span> users connected</div>
|
||||
<div id="map" class="rounded" style="width: 600px; height: 400px;"></div>
|
||||
<div id="map" class="mb-3 rounded" style="width: 600px; height: 400px;"></div>
|
||||
<div class="d-flex justify-content-around w-100 mt-auto" style="max-width: 600px">
|
||||
<div>
|
||||
<div id="dashboard-real-name">Loading...</div>
|
||||
<div id="dashboard-cid">Loading...</div>
|
||||
<div>Network Rating: <span id="dashboard-network-rating">Loading...</span></div>
|
||||
</div>
|
||||
<div id="dashboard-user-editor">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/openfsd/dashboard.js" defer></script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -17,9 +17,9 @@
|
||||
<title>openfsd - {{ template "title" . }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid p-2 d-flex justify-content-start align-items-center border-bottom border-2">
|
||||
<div class="container-fluid p-2 d-flex justify-content-start align-items-center border-bottom border-1">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="64px" height="64px" viewBox="0 0 120.135 120.148" style="enable-background:new 0 0 120.135 120.148;" xml:space="preserve">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 120.135 120.148" style="enable-background:new 0 0 120.135 120.148;" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M105.771,120.147c7.933,0,14.363-6.438,14.363-14.359V14.352C120.134,6.422,113.702,0,105.771,0H14.337 C6.408,0,0,6.422,0,14.352v91.436c0,7.922,6.408,14.359,14.337,14.359H105.771z"/>
|
||||
<path style="fill:#FFFFFF;" d="M14.337,2.435c-6.564,0-11.908,5.347-11.908,11.917v91.436c0,6.58,5.344,11.926,11.908,11.926 h91.434c6.584,0,11.932-5.346,11.932-11.926V14.352c0-6.57-5.348-11.917-11.932-11.917H14.337z"/>
|
||||
|
||||
284
web/templates/usereditor.html
Normal file
284
web/templates/usereditor.html
Normal file
@@ -0,0 +1,284 @@
|
||||
{{ define "title" }}User Editor{{ end }}
|
||||
|
||||
{{ define "body" }}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Create User</h5>
|
||||
<form class="mb-3" id="create-form">
|
||||
<div class="mb-3">
|
||||
<label for="create-first-name" class="form-label">First Name</label>
|
||||
<input type="text" class="form-control" id="create-first-name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="create-last-name" class="form-label">Last Name</label>
|
||||
<input type="text" class="form-control" id="create-last-name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="create-password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="create-password" minlength="8" required>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Password Strength</label>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div id="create-password-strength" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small id="create-password-feedback" class="form-text text-muted"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="create-network-rating" class="form-label">Network Rating</label>
|
||||
<select class="form-select" id="create-network-rating" required aria-label="Select network rating">
|
||||
<option value="-1">Inactive</option>
|
||||
<option value="0">Suspended</option>
|
||||
<option value="1" selected>Observer</option>
|
||||
<option value="2">Student 1</option>
|
||||
<option value="3">Student 2</option>
|
||||
<option value="4">Student 3</option>
|
||||
<option value="5">Controller 1</option>
|
||||
<option value="6">Controller 2</option>
|
||||
<option value="7">Controller 3</option>
|
||||
<option value="8">Instructor 1</option>
|
||||
<option value="9">Instructor 2</option>
|
||||
<option value="10">Instructor 3</option>
|
||||
<option value="11">Supervisor</option>
|
||||
<option value="12">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</form>
|
||||
<div id="create-success-message" class="alert alert-success d-none" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Search for User by CID</h5>
|
||||
<form id="search-form">
|
||||
<div class="mb-3">
|
||||
<label for="search-cid" class="form-label">CID</label>
|
||||
<input type="number" class="form-control" id="search-cid" min="1" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Edit User</h5>
|
||||
<form class="mb-3" id="edit-form">
|
||||
<div class="mb-3">
|
||||
<label for="edit-cid" class="form-label">CID</label>
|
||||
<input type="text" class="form-control" id="edit-cid" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-first-name" class="form-label">First Name</label>
|
||||
<input type="text" class="form-control" id="edit-first-name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-last-name" class="form-label">Last Name</label>
|
||||
<input type="text" class="form-control" id="edit-last-name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-network-rating" class="form-label">Network Rating</label>
|
||||
<select class="form-select" id="edit-network-rating" aria-label="Select network rating">
|
||||
<option value="-1">Inactive</option>
|
||||
<option value="0">Suspended</option>
|
||||
<option value="1" selected>Observer</option>
|
||||
<option value="2">Student 1</option>
|
||||
<option value="3">Student 2</option>
|
||||
<option value="4">Student 3</option>
|
||||
<option value="5">Controller 1</option>
|
||||
<option value="6">Controller 2</option>
|
||||
<option value="7">Controller 3</option>
|
||||
<option value="8">Instructor 1</option>
|
||||
<option value="9">Instructor 2</option>
|
||||
<option value="10">Instructor 3</option>
|
||||
<option value="11">Supervisor</option>
|
||||
<option value="12">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-password" class="form-label">New Password (leave blank to keep current)</label>
|
||||
<input type="password" class="form-control" id="edit-password" minlength="8">
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Password Strength</label>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div id="edit-password-strength" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small id="edit-password-feedback" class="form-text text-muted"></small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</form>
|
||||
<div id="edit-success-message" class="alert alert-success d-none" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for error messages only -->
|
||||
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="messageModalLabel">Error</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="messageModalBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Function to show modal for errors only
|
||||
function showModal(message) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('messageModal'));
|
||||
const modalBody = document.getElementById('messageModalBody');
|
||||
modalBody.textContent = message;
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Form Handlers
|
||||
document.getElementById('search-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
const cid = document.getElementById('search-cid').value;
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/load', {cid: parseInt(cid)});
|
||||
if (res.err) {
|
||||
showModal(res.err);
|
||||
} else {
|
||||
const user = res.data;
|
||||
document.getElementById('edit-cid').value = user.cid;
|
||||
document.getElementById('edit-first-name').value = user.first_name || '';
|
||||
document.getElementById('edit-last-name').value = user.last_name || '';
|
||||
document.getElementById('edit-network-rating').value = user.network_rating;
|
||||
document.getElementById('edit-password').value = '';
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
showModal(errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('create-success-message').classList.add('d-none');
|
||||
const firstName = document.getElementById('create-first-name').value;
|
||||
const lastName = document.getElementById('create-last-name').value;
|
||||
const password = document.getElementById('create-password').value;
|
||||
const networkRating = document.getElementById('create-network-rating').value;
|
||||
const data = {
|
||||
password: password,
|
||||
network_rating: parseInt(networkRating)
|
||||
};
|
||||
if (firstName) data.first_name = firstName;
|
||||
if (lastName) data.last_name = lastName;
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/create', data);
|
||||
if (res.err) {
|
||||
showModal(res.err);
|
||||
} else {
|
||||
const successMessage = document.getElementById('create-success-message');
|
||||
successMessage.textContent = 'User created successfully. CID: ' + res.data.cid;
|
||||
successMessage.classList.remove('d-none');
|
||||
document.getElementById('create-first-name').value = '';
|
||||
document.getElementById('create-last-name').value = '';
|
||||
document.getElementById('create-password').value = '';
|
||||
document.getElementById('create-network-rating').value = '-1';
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
showModal(errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('edit-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('edit-success-message').classList.add('d-none');
|
||||
const cid = document.getElementById('edit-cid').value;
|
||||
const firstName = document.getElementById('edit-first-name').value;
|
||||
const lastName = document.getElementById('edit-last-name').value;
|
||||
const networkRating = document.getElementById('edit-network-rating').value;
|
||||
const password = document.getElementById('edit-password').value;
|
||||
const data = {
|
||||
cid: parseInt(cid),
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
network_rating: parseInt(networkRating)
|
||||
};
|
||||
if (password) data.password = password;
|
||||
try {
|
||||
const res = await doAPIRequestWithAuth('PATCH', '/api/v1/user/update', data);
|
||||
if (res.err) {
|
||||
showModal(res.err);
|
||||
} else {
|
||||
const successMessage = document.getElementById('edit-success-message');
|
||||
successMessage.textContent = 'User updated successfully';
|
||||
successMessage.classList.remove('d-none');
|
||||
}
|
||||
} catch (xhr) {
|
||||
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
|
||||
showModal(errMsg);
|
||||
console.error('Request failed:', xhr);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const createPasswordInput = document.getElementById('create-password');
|
||||
const createStrengthBar = document.getElementById('create-password-strength');
|
||||
const createFeedback = document.getElementById('create-password-feedback');
|
||||
const editPasswordInput = document.getElementById('edit-password');
|
||||
const editStrengthBar = document.getElementById('edit-password-strength');
|
||||
const editFeedback = document.getElementById('edit-password-feedback');
|
||||
|
||||
const evaluatePassword = (password, strengthBar, feedback) => {
|
||||
if (!password) {
|
||||
strengthBar.style.width = '0%';
|
||||
strengthBar.className = 'progress-bar';
|
||||
feedback.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength += 50;
|
||||
if (/[A-Z]/.test(password)) strength += 15;
|
||||
if (/[a-z]/.test(password)) strength += 15;
|
||||
if (/[0-9]/.test(password)) strength += 10;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength += 10;
|
||||
|
||||
strength = Math.min(strength, 100);
|
||||
strengthBar.style.width = `${strength}%`;
|
||||
|
||||
if (strength < 60) {
|
||||
strengthBar.className = 'progress-bar bg-danger';
|
||||
feedback.textContent = 'Weak: Include uppercase, lowercase, numbers, or symbols.';
|
||||
} else if (strength < 80) {
|
||||
strengthBar.className = 'progress-bar bg-warning';
|
||||
feedback.textContent = 'Moderate: Add more character types for strength.';
|
||||
} else {
|
||||
strengthBar.className = 'progress-bar bg-success';
|
||||
feedback.textContent = 'Strong: Good password!';
|
||||
}
|
||||
};
|
||||
|
||||
createPasswordInput.addEventListener('input', () => {
|
||||
evaluatePassword(createPasswordInput.value, createStrengthBar, createFeedback);
|
||||
});
|
||||
|
||||
editPasswordInput.addEventListener('input', () => {
|
||||
evaluatePassword(editPasswordInput.value, editStrengthBar, editFeedback);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
45
web/user.go
45
web/user.go
@@ -4,14 +4,15 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"github.com/renorris/openfsd/fsd"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// getUserInfo returns the user info of the specified CID.
|
||||
// getUserByCID returns the user info of the specified CID.
|
||||
//
|
||||
// Only >= SUP can request CIDs other than what is indicated in their bearer token.
|
||||
func (s *Server) getUserInfo(c *gin.Context) {
|
||||
func (s *Server) getUserByCID(c *gin.Context) {
|
||||
type RequestBody struct {
|
||||
CID int `json:"cid" binding:"min=1,required"`
|
||||
}
|
||||
@@ -61,6 +62,12 @@ func (s *Server) getUserInfo(c *gin.Context) {
|
||||
// The CID itself is immutable and cannot be changed.
|
||||
// Only >= SUP can update CIDs other than what is indicated in their bearer token.
|
||||
func (s *Server) updateUser(c *gin.Context) {
|
||||
claims := getJwtContext(c)
|
||||
if claims.NetworkRating < fsd.NetworkRatingSupervisor {
|
||||
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
type RequestBody struct {
|
||||
CID int `json:"cid" binding:"min=1,required"`
|
||||
Password *string `json:"password"`
|
||||
@@ -80,8 +87,6 @@ func (s *Server) updateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
claims := getJwtContext(c)
|
||||
|
||||
if targetUser.NetworkRating > int(claims.NetworkRating) {
|
||||
res := newAPIV1Failure("cannot update user with higher network rating")
|
||||
writeAPIV1Response(c, http.StatusForbidden, &res)
|
||||
@@ -144,10 +149,38 @@ func (s *Server) createUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
claims := getJwtContext(c)
|
||||
|
||||
if claims.NetworkRating < fsd.NetworkRatingSupervisor || reqBody.NetworkRating > int(claims.NetworkRating) {
|
||||
if claims.NetworkRating < fsd.NetworkRatingSupervisor ||
|
||||
reqBody.NetworkRating > int(claims.NetworkRating) {
|
||||
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
user := &db.User{
|
||||
Password: reqBody.Password,
|
||||
FirstName: reqBody.FirstName,
|
||||
LastName: reqBody.LastName,
|
||||
NetworkRating: reqBody.NetworkRating,
|
||||
}
|
||||
|
||||
if err := s.dbRepo.UserRepo.CreateUser(user); err != nil {
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
CID int `json:"cid"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
NetworkRating int `json:"network_rating" binding:"min=-1,max=12,required"`
|
||||
}
|
||||
|
||||
resBody := ResponseBody{
|
||||
CID: user.CID,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
NetworkRating: user.NetworkRating,
|
||||
}
|
||||
|
||||
res := newAPIV1Success(&resBody)
|
||||
writeAPIV1Response(c, http.StatusCreated, &res)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user