Use hashed webhook secrets for constant-time comparison (closes #13) #15
3
go.mod
3
go.mod
@ -5,9 +5,11 @@ go 1.25
|
|||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/docker/docker v27.3.1+incompatible
|
github.com/docker/docker v27.3.1+incompatible
|
||||||
|
github.com/docker/go-connections v0.6.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
@ -27,7 +29,6 @@ require (
|
|||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -50,6 +50,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
|||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||||
|
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
|||||||
@ -29,8 +29,8 @@ const (
|
|||||||
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
data := h.addGlobals(map[string]any{})
|
data := h.addGlobals(map[string]any{}, request)
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -57,12 +57,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
|||||||
branch := request.FormValue("branch")
|
branch := request.FormValue("branch")
|
||||||
dockerfilePath := request.FormValue("dockerfile_path")
|
dockerfilePath := request.FormValue("dockerfile_path")
|
||||||
|
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Name": name,
|
"Name": name,
|
||||||
"RepoURL": repoURL,
|
"RepoURL": repoURL,
|
||||||
"Branch": branch,
|
"Branch": branch,
|
||||||
"DockerfilePath": dockerfilePath,
|
"DockerfilePath": dockerfilePath,
|
||||||
}
|
}, request)
|
||||||
|
|
||||||
if name == "" || repoURL == "" {
|
if name == "" || repoURL == "" {
|
||||||
data["Error"] = "Name and repository URL are required"
|
data["Error"] = "Name and repository URL are required"
|
||||||
@ -150,7 +150,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
|||||||
"WebhookURL": webhookURL,
|
"WebhookURL": webhookURL,
|
||||||
"DeployKey": deployKey,
|
"DeployKey": deployKey,
|
||||||
"Success": request.URL.Query().Get("success"),
|
"Success": request.URL.Query().Get("success"),
|
||||||
})
|
}, request)
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -183,7 +183,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
|||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"App": application,
|
"App": application,
|
||||||
})
|
}, request)
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -241,10 +241,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
|||||||
if saveErr != nil {
|
if saveErr != nil {
|
||||||
h.log.Error("failed to update app", "error", saveErr)
|
h.log.Error("failed to update app", "error", saveErr)
|
||||||
|
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"App": application,
|
"App": application,
|
||||||
"Error": "Failed to update app",
|
"Error": "Failed to update app",
|
||||||
}
|
}, request)
|
||||||
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -337,7 +337,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
|||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"App": application,
|
"App": application,
|
||||||
"Deployments": deployments,
|
"Deployments": deployments,
|
||||||
})
|
}, request)
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -10,8 +10,8 @@ import (
|
|||||||
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
|
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
|
||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
data := h.addGlobals(map[string]any{})
|
data := h.addGlobals(map[string]any{}, request)
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -38,7 +38,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
|
|||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Username": username,
|
"Username": username,
|
||||||
})
|
}, request)
|
||||||
|
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
data["Error"] = "Username and password are required"
|
data["Error"] = "Username and password are required"
|
||||||
|
|||||||
@ -67,7 +67,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
|||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"AppStats": appStats,
|
"AppStats": appStats,
|
||||||
})
|
}, request)
|
||||||
|
|
||||||
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
|
|||||||
@ -3,9 +3,11 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
@ -64,11 +66,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addGlobals adds version info to template data map.
|
// addGlobals adds version info and CSRF token to template data map.
|
||||||
func (h *Handlers) addGlobals(data map[string]any) map[string]any {
|
func (h *Handlers) addGlobals(
|
||||||
|
data map[string]any,
|
||||||
|
request *http.Request,
|
||||||
|
) map[string]any {
|
||||||
data["Version"] = h.globals.Version
|
data["Version"] = h.globals.Version
|
||||||
data["Appname"] = h.globals.Appname
|
data["Appname"] = h.globals.Appname
|
||||||
|
|
||||||
|
if request != nil {
|
||||||
|
data["CSRFField"] = template.HTML(csrf.TemplateField(request)) //nolint:gosec // csrf.TemplateField produces safe HTML
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,8 @@ const (
|
|||||||
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
|
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
|
||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
data := h.addGlobals(map[string]any{})
|
data := h.addGlobals(map[string]any{}, request)
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -54,13 +54,14 @@ func validateSetupForm(formData setupFormData) string {
|
|||||||
func (h *Handlers) renderSetupError(
|
func (h *Handlers) renderSetupError(
|
||||||
tmpl *templates.TemplateExecutor,
|
tmpl *templates.TemplateExecutor,
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
username string,
|
username string,
|
||||||
errorMsg string,
|
errorMsg string,
|
||||||
) {
|
) {
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Username": username,
|
"Username": username,
|
||||||
"Error": errorMsg,
|
"Error": errorMsg,
|
||||||
})
|
}, request)
|
||||||
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +84,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if validationErr := validateSetupForm(formData); validationErr != "" {
|
if validationErr := validateSetupForm(formData); validationErr != "" {
|
||||||
h.renderSetupError(tmpl, writer, formData.username, validationErr)
|
h.renderSetupError(tmpl, writer, request, formData.username, validationErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -95,7 +96,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
h.log.Error("failed to create user", "error", createErr)
|
h.log.Error("failed to create user", "error", createErr)
|
||||||
h.renderSetupError(tmpl, writer, formData.username, "Failed to create user")
|
h.renderSetupError(tmpl, writer, request, formData.username, "Failed to create user")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -106,6 +107,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
|||||||
h.renderSetupError(
|
h.renderSetupError(
|
||||||
tmpl,
|
tmpl,
|
||||||
writer,
|
writer,
|
||||||
|
request,
|
||||||
formData.username,
|
formData.username,
|
||||||
"Failed to create session",
|
"Failed to create session",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/99designs/basicauth-go"
|
"github.com/99designs/basicauth-go"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
@ -152,6 +153,15 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF returns CSRF protection middleware using gorilla/csrf.
|
||||||
|
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||||
|
return csrf.Protect(
|
||||||
|
[]byte(m.params.Config.SessionSecret),
|
||||||
|
csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS
|
||||||
|
csrf.Path("/"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// SetupRequired returns middleware that redirects to setup if no user exists.
|
// SetupRequired returns middleware that redirects to setup if no user exists.
|
||||||
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
|
|||||||
@ -37,18 +37,22 @@ func (s *Server) SetupRoutes() {
|
|||||||
http.FileServer(http.FS(static.Static)),
|
http.FileServer(http.FS(static.Static)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Public routes
|
// Webhook endpoint (uses secret for auth, not session — no CSRF)
|
||||||
s.router.Get("/login", s.handlers.HandleLoginGET())
|
|
||||||
s.router.Post("/login", s.handlers.HandleLoginPOST())
|
|
||||||
s.router.Get("/setup", s.handlers.HandleSetupGET())
|
|
||||||
s.router.Post("/setup", s.handlers.HandleSetupPOST())
|
|
||||||
|
|
||||||
// Webhook endpoint (uses secret for auth, not session)
|
|
||||||
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
|
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
|
||||||
|
|
||||||
// Protected routes (require session auth)
|
// All HTML-serving routes get CSRF protection
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
r.Use(s.mw.SessionAuth())
|
r.Use(s.mw.CSRF())
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
r.Get("/login", s.handlers.HandleLoginGET())
|
||||||
|
r.Post("/login", s.handlers.HandleLoginPOST())
|
||||||
|
r.Get("/setup", s.handlers.HandleSetupGET())
|
||||||
|
r.Post("/setup", s.handlers.HandleSetupPOST())
|
||||||
|
|
||||||
|
// Protected routes (require session auth)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.mw.SessionAuth())
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
r.Get("/", s.handlers.HandleDashboard())
|
r.Get("/", s.handlers.HandleDashboard())
|
||||||
@ -90,6 +94,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Ports
|
// Ports
|
||||||
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
|
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
|
||||||
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
|
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Metrics endpoint (optional, with basic auth)
|
// Metrics endpoint (optional, with basic auth)
|
||||||
|
|||||||
@ -61,15 +61,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
*/
|
*/
|
||||||
scrollToBottom(el) {
|
scrollToBottom(el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
// Use double RAF to ensure DOM has fully updated and reflowed
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
el.scrollTop = el.scrollHeight;
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a scrollable element is at (or near) the bottom.
|
||||||
|
* Tolerance of 30px accounts for rounding and partial lines.
|
||||||
|
*/
|
||||||
|
isScrolledToBottom(el, tolerance = 30) {
|
||||||
|
if (!el) return true;
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy text to clipboard
|
* Copy text to clipboard
|
||||||
*/
|
*/
|
||||||
@ -176,11 +182,27 @@ document.addEventListener("alpine:init", () => {
|
|||||||
showBuildLogs: !!config.initialDeploymentId,
|
showBuildLogs: !!config.initialDeploymentId,
|
||||||
deploying: false,
|
deploying: false,
|
||||||
deployments: [],
|
deployments: [],
|
||||||
|
// Track whether user wants auto-scroll (per log pane)
|
||||||
|
_containerAutoScroll: true,
|
||||||
|
_buildAutoScroll: true,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
|
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
|
||||||
this.fetchAll();
|
this.fetchAll();
|
||||||
setInterval(() => this.fetchAll(), 1000);
|
setInterval(() => this.fetchAll(), 1000);
|
||||||
|
|
||||||
|
// Set up scroll listeners after DOM is ready
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
|
||||||
|
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_initScrollTracking(el, flag) {
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('scroll', () => {
|
||||||
|
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
|
||||||
|
}, { passive: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchAll() {
|
fetchAll() {
|
||||||
@ -214,11 +236,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.containerLogs = data.logs || "No logs available";
|
const newLogs = data.logs || "No logs available";
|
||||||
|
const changed = newLogs !== this.containerLogs;
|
||||||
|
this.containerLogs = newLogs;
|
||||||
this.containerStatus = data.status;
|
this.containerStatus = data.status;
|
||||||
this.$nextTick(() => {
|
if (changed && this._containerAutoScroll) {
|
||||||
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
this.$nextTick(() => {
|
||||||
});
|
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.containerLogs = "Failed to fetch logs";
|
this.containerLogs = "Failed to fetch logs";
|
||||||
}
|
}
|
||||||
@ -231,11 +257,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.buildLogs = data.logs || "No build logs available";
|
const newLogs = data.logs || "No build logs available";
|
||||||
|
const changed = newLogs !== this.buildLogs;
|
||||||
|
this.buildLogs = newLogs;
|
||||||
this.buildStatus = data.status;
|
this.buildStatus = data.status;
|
||||||
this.$nextTick(() => {
|
if (changed && this._buildAutoScroll) {
|
||||||
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
this.$nextTick(() => {
|
||||||
});
|
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.buildLogs = "Failed to fetch logs";
|
this.buildLogs = "Failed to fetch logs";
|
||||||
}
|
}
|
||||||
@ -306,12 +336,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
logs: "",
|
logs: "",
|
||||||
status: config.status || "",
|
status: config.status || "",
|
||||||
pollInterval: null,
|
pollInterval: null,
|
||||||
|
_autoScroll: true,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Read initial logs from script tag (avoids escaping issues)
|
// Read initial logs from script tag (avoids escaping issues)
|
||||||
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
||||||
this.logs = initialLogsEl?.textContent || "Loading...";
|
this.logs = initialLogsEl?.textContent || "Loading...";
|
||||||
|
|
||||||
|
// Set up scroll tracking
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const wrapper = this.$refs.logsWrapper;
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.addEventListener('scroll', () => {
|
||||||
|
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Only poll if deployment is in progress
|
// Only poll if deployment is in progress
|
||||||
if (Alpine.store("utils").isDeploying(this.status)) {
|
if (Alpine.store("utils").isDeploying(this.status)) {
|
||||||
this.fetchLogs();
|
this.fetchLogs();
|
||||||
@ -336,8 +377,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.logs = newLogs;
|
this.logs = newLogs;
|
||||||
this.status = data.status;
|
this.status = data.status;
|
||||||
|
|
||||||
// Scroll to bottom only when content changes
|
// Scroll to bottom only when content changes AND user hasn't scrolled up
|
||||||
if (logsChanged) {
|
if (logsChanged && this._autoScroll) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
|
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline" @submit="submitDeploy()">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline" @submit="submitDeploy()">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="btn-success" x-bind:disabled="deploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': deploying }">
|
<button type="submit" class="btn-success" x-bind:disabled="deploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': deploying }">
|
||||||
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
|
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||||
</button>
|
</button>
|
||||||
@ -106,6 +107,7 @@
|
|||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@ -116,6 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
|
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
|
||||||
|
{{ .CSRFField }}
|
||||||
<input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm">
|
<input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm">
|
||||||
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
||||||
<button type="submit" class="btn-primary">Add</button>
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
@ -149,6 +152,7 @@
|
|||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@ -158,6 +162,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2">
|
<form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2">
|
||||||
|
{{ .CSRFField }}
|
||||||
<input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm">
|
<input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm">
|
||||||
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
||||||
<button type="submit" class="btn-primary">Add</button>
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
@ -192,6 +197,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@ -202,6 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/volumes" class="flex flex-col sm:flex-row gap-2 items-end">
|
<form method="POST" action="/apps/{{.App.ID}}/volumes" class="flex flex-col sm:flex-row gap-2 items-end">
|
||||||
|
{{ .CSRFField }}
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm">
|
<input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm">
|
||||||
</div>
|
</div>
|
||||||
@ -244,6 +251,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)">
|
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@ -254,6 +262,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
|
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
|
||||||
|
{{ .CSRFField }}
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<label class="block text-xs text-gray-500 mb-1">Host (external)</label>
|
<label class="block text-xs text-gray-500 mb-1">Host (external)</label>
|
||||||
<input type="text" name="host_port" placeholder="8080" required pattern="[0-9]+" class="input font-mono text-sm">
|
<input type="text" name="host_port" placeholder="8080" required pattern="[0-9]+" class="input font-mono text-sm">
|
||||||
@ -279,8 +288,17 @@
|
|||||||
<h2 class="section-title">Container Logs</h2>
|
<h2 class="section-title">Container Logs</h2>
|
||||||
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div class="relative">
|
||||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="containerLogs"></pre>
|
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
||||||
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="containerLogs"></pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="!_containerAutoScroll"
|
||||||
|
x-transition
|
||||||
|
@click="_containerAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.containerLogsWrapper)"
|
||||||
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
||||||
|
title="Scroll to bottom"
|
||||||
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -329,8 +347,17 @@
|
|||||||
<h2 class="section-title">Last Deployment Build Logs</h2>
|
<h2 class="section-title">Last Deployment Build Logs</h2>
|
||||||
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div class="relative">
|
||||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="buildLogs"></pre>
|
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
||||||
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="buildLogs"></pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="!_buildAutoScroll"
|
||||||
|
x-transition
|
||||||
|
@click="_buildAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.buildLogsWrapper)"
|
||||||
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
||||||
|
title="Scroll to bottom"
|
||||||
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -339,6 +366,7 @@
|
|||||||
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
|
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
|
||||||
<p class="text-error-600 text-sm mb-4">Deleting this app will remove all configuration and deployment history. This action cannot be undone.</p>
|
<p class="text-error-600 text-sm mb-4">Deleting this app will remove all configuration and deployment history. This action cannot be undone.</p>
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/delete" x-data="confirmAction('Are you sure you want to delete this app? This action cannot be undone.')" @submit="confirm($event)">
|
<form method="POST" action="/apps/{{.App.ID}}/delete" x-data="confirmAction('Are you sure you want to delete this app? This action cannot be undone.')" @submit="confirm($event)">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="btn-danger">Delete App</button>
|
<button type="submit" class="btn-danger">Delete App</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
{{template "alert-error" .}}
|
{{template "alert-error" .}}
|
||||||
|
|
||||||
<form method="POST" action="/apps/{{.App.ID}}" class="space-y-6">
|
<form method="POST" action="/apps/{{.App.ID}}" class="space-y-6">
|
||||||
|
{{ .CSRFField }}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">App Name</label>
|
<label for="name" class="label">App Name</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
{{template "alert-error" .}}
|
{{template "alert-error" .}}
|
||||||
|
|
||||||
<form method="POST" action="/apps" class="space-y-6">
|
<form method="POST" action="/apps" class="space-y-6">
|
||||||
|
{{ .CSRFField }}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">App Name</label>
|
<label for="name" class="label">App Name</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
New App
|
New App
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/logout" class="inline">
|
<form method="POST" action="/logout" class="inline">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -69,6 +69,7 @@
|
|||||||
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
|
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
|
||||||
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
|
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
|
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
|
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
|
||||||
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||||
</button>
|
</button>
|
||||||
@ -85,8 +86,17 @@
|
|||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div class="relative">
|
||||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="logs"></pre>
|
<div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
||||||
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="logs"></pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="!_autoScroll"
|
||||||
|
x-transition
|
||||||
|
@click="_autoScroll = true; Alpine.store('utils').scrollToBottom($refs.logsWrapper)"
|
||||||
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
||||||
|
title="Scroll to bottom"
|
||||||
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -103,6 +113,7 @@
|
|||||||
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
|
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
|
||||||
|
{{ .CSRFField }}
|
||||||
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
|
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
|
||||||
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
{{template "alert-error" .}}
|
{{template "alert-error" .}}
|
||||||
|
|
||||||
<form method="POST" action="/login" class="space-y-6">
|
<form method="POST" action="/login" class="space-y-6">
|
||||||
|
{{ .CSRFField }}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username" class="label">Username</label>
|
<label for="username" class="label">Username</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
{{template "alert-error" .}}
|
{{template "alert-error" .}}
|
||||||
|
|
||||||
<form method="POST" action="/setup" class="space-y-6">
|
<form method="POST" action="/setup" class="space-y-6">
|
||||||
|
{{ .CSRFField }}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username" class="label">Username</label>
|
<label for="username" class="label">Username</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user