mirror of
https://github.com/knadh/listmonk.git
synced 2024-09-20 07:16:33 +08:00
Compare commits
64 commits
01bce16f84
...
65f36f63d2
Author | SHA1 | Date | |
---|---|---|---|
65f36f63d2 | |||
20502c029c | |||
3cbf67b943 | |||
83d3af3527 | |||
806e499e87 | |||
95f63a13af | |||
601e1e6e65 | |||
b667a9230a | |||
62d9a39e80 | |||
d3d8908de2 | |||
55bd98630a | |||
c6281b987b | |||
316d574d80 | |||
e4a7d307b3 | |||
862d4240c5 | |||
af63c1628e | |||
d33341f731 | |||
6236c42c12 | |||
ba1e74540a | |||
6226f85caf | |||
a94d7cc8c4 | |||
2fe1b808d0 | |||
96f85308c1 | |||
1437fe8f8a | |||
94446ca744 | |||
874e12ed10 | |||
01c64de7a8 | |||
fbc5807f4a | |||
64e56b58d5 | |||
46fbac3c00 | |||
d1184de18d | |||
f632dfbce1 | |||
728877dbe6 | |||
fc95985ef4 | |||
5ea931a2ff | |||
bca487cbee | |||
e865847b66 | |||
6d1dabd0bf | |||
217590ea0e | |||
5c5fd8a15d | |||
f57ac201ff | |||
2bb4e19b74 | |||
13ac249afb | |||
5e5d012312 | |||
938a5c5077 | |||
7c3ee469bd | |||
a87167ac9c | |||
31c5358d0e | |||
a4e8c1daea | |||
c2bd15a641 | |||
8b2f385708 | |||
2fcecd6db5 | |||
5832ea5384 | |||
98213ebf24 | |||
4679f98067 | |||
b832f8e82e | |||
e6f59da886 | |||
06264ca13f | |||
011d89144d | |||
67a33b40eb | |||
550cd3e1f8 | |||
06e49831dd | |||
51e3f1789b | |||
139267d57e |
4
Makefile
4
Makefile
|
@ -21,7 +21,7 @@ FRONTEND_DEPS = \
|
|||
|
||||
BIN := listmonk
|
||||
STATIC := config.toml.sample \
|
||||
schema.sql queries.sql \
|
||||
schema.sql queries.sql permissions.json \
|
||||
static/public:/public \
|
||||
static/email-templates \
|
||||
frontend/dist:/admin \
|
||||
|
@ -38,7 +38,7 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
|
|||
touch -c $(FRONTEND_YARN_MODULES)
|
||||
|
||||
# Build the backend to ./listmonk.
|
||||
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum
|
||||
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json
|
||||
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
|
||||
|
||||
# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
|
||||
|
|
|
@ -48,7 +48,7 @@ __________________
|
|||
|
||||
|
||||
## Developers
|
||||
listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI.
|
||||
listmonk is free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI.
|
||||
|
||||
|
||||
## License
|
||||
|
|
26
cmd/admin.go
26
cmd/admin.go
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
@ -11,20 +12,30 @@ import (
|
|||
)
|
||||
|
||||
type serverConfig struct {
|
||||
Messengers []string `json:"messengers"`
|
||||
Langs []i18nLang `json:"langs"`
|
||||
Lang string `json:"lang"`
|
||||
Update *AppUpdate `json:"update"`
|
||||
NeedsRestart bool `json:"needs_restart"`
|
||||
Version string `json:"version"`
|
||||
RootURL string `json:"root_url"`
|
||||
FromEmail string `json:"from_email"`
|
||||
Messengers []string `json:"messengers"`
|
||||
Langs []i18nLang `json:"langs"`
|
||||
Lang string `json:"lang"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Update *AppUpdate `json:"update"`
|
||||
NeedsRestart bool `json:"needs_restart"`
|
||||
HasLegacyUser bool `json:"has_legacy_user"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// handleGetServerConfig returns general server config.
|
||||
func handleGetServerConfig(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out = serverConfig{}
|
||||
)
|
||||
out := serverConfig{
|
||||
RootURL: app.constants.RootURL,
|
||||
FromEmail: app.constants.FromEmail,
|
||||
Lang: app.constants.Lang,
|
||||
Permissions: app.constants.PermissionsRaw,
|
||||
HasLegacyUser: app.constants.HasLegacyUser,
|
||||
}
|
||||
|
||||
// Language list.
|
||||
langList, err := getI18nLangList(app.constants.Lang, app)
|
||||
|
@ -33,7 +44,6 @@ func handleGetServerConfig(c echo.Context) error {
|
|||
fmt.Sprintf("Error loading language list: %v", err))
|
||||
}
|
||||
out.Langs = langList
|
||||
out.Lang = app.constants.Lang
|
||||
|
||||
// Sort messenger names with `email` always as the first item.
|
||||
var names []string
|
||||
|
|
221
cmd/auth.go
Normal file
221
cmd/auth.go
Normal file
|
@ -0,0 +1,221 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/zerodha/simplesessions/v3"
|
||||
)
|
||||
|
||||
type loginTpl struct {
|
||||
Title string
|
||||
Description string
|
||||
|
||||
NextURI string
|
||||
Nonce string
|
||||
PasswordEnabled bool
|
||||
OIDCProvider string
|
||||
OIDCProviderLogo string
|
||||
Error string
|
||||
}
|
||||
|
||||
var oidcProviders = map[string]bool{
|
||||
"google.com": true,
|
||||
"microsoftonline.com": true,
|
||||
"auth0.com": true,
|
||||
"github.com": true,
|
||||
}
|
||||
|
||||
// handleLoginPage renders the login page and handles the login form.
|
||||
func handleLoginPage(c echo.Context) error {
|
||||
// Process POST login request.
|
||||
var loginErr error
|
||||
if c.Request().Method == http.MethodPost {
|
||||
loginErr = doLogin(c)
|
||||
if loginErr == nil {
|
||||
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
|
||||
}
|
||||
}
|
||||
|
||||
return renderLoginPage(c, loginErr)
|
||||
}
|
||||
|
||||
// handleLogout logs a user out.
|
||||
func handleLogout(c echo.Context) error {
|
||||
var (
|
||||
sess = c.Get(auth.SessionKey).(*simplesessions.Session)
|
||||
)
|
||||
|
||||
// Clear the session.
|
||||
_ = sess.Destroy()
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleOIDCLogin initializes an OIDC request and redirects to the OIDC provider for login.
|
||||
func handleOIDCLogin(c echo.Context) error {
|
||||
app := c.Get("app").(*App)
|
||||
|
||||
// Verify that the request came from the login page (CSRF).
|
||||
nonce, err := c.Cookie("nonce")
|
||||
if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))
|
||||
}
|
||||
|
||||
next := utils.SanitizeURI(c.FormValue("next"))
|
||||
if next == "/" {
|
||||
next = uriAdmin
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusFound, app.auth.GetOIDCAuthURL(next, nonce.Value))
|
||||
}
|
||||
|
||||
// handleOIDCFinish receives the redirect callback from the OIDC provider and completes the handshake.
|
||||
func handleOIDCFinish(c echo.Context) error {
|
||||
app := c.Get("app").(*App)
|
||||
|
||||
nonce, err := c.Cookie("nonce")
|
||||
if err != nil || nonce.Value == "" {
|
||||
return renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")))
|
||||
}
|
||||
|
||||
// Validate the OIDC token.
|
||||
oidcToken, claims, err := app.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value)
|
||||
if err != nil {
|
||||
return renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
// Get the user by e-mail received from OIDC.
|
||||
user, err := app.core.GetUser(0, "", claims.Email)
|
||||
if err != nil {
|
||||
return renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
// Update user login.
|
||||
if err := app.core.UpdateUserLogin(user.ID, claims.Picture); err != nil {
|
||||
return renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
// Set the session.
|
||||
if err := app.auth.SaveSession(user, oidcToken, c); err != nil {
|
||||
return renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.QueryParam("state")))
|
||||
}
|
||||
|
||||
// renderLoginPage renders the login page and handles the login form.
|
||||
func renderLoginPage(c echo.Context, loginErr error) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
next = utils.SanitizeURI(c.FormValue("next"))
|
||||
)
|
||||
|
||||
if next == "/" {
|
||||
next = uriAdmin
|
||||
}
|
||||
|
||||
oidcProvider := ""
|
||||
oidcProviderLogo := ""
|
||||
if app.constants.Security.OIDC.Enabled {
|
||||
oidcProviderLogo = "oidc.png"
|
||||
u, err := url.Parse(app.constants.Security.OIDC.Provider)
|
||||
if err == nil {
|
||||
h := strings.Split(u.Hostname(), ".")
|
||||
|
||||
// Get the last two h for the root domain
|
||||
if len(h) >= 2 {
|
||||
oidcProvider = h[len(h)-2] + "." + h[len(h)-1]
|
||||
} else {
|
||||
oidcProvider = u.Hostname()
|
||||
}
|
||||
|
||||
if _, ok := oidcProviders[oidcProvider]; ok {
|
||||
oidcProviderLogo = oidcProvider + ".png"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := loginTpl{
|
||||
Title: app.i18n.T("users.login"),
|
||||
PasswordEnabled: true,
|
||||
OIDCProvider: oidcProvider,
|
||||
OIDCProviderLogo: oidcProviderLogo,
|
||||
NextURI: next,
|
||||
}
|
||||
|
||||
if loginErr != nil {
|
||||
if e, ok := loginErr.(*echo.HTTPError); ok {
|
||||
out.Error = e.Message.(string)
|
||||
} else {
|
||||
out.Error = loginErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and set a nonce for preventing CSRF requests.
|
||||
nonce, err := utils.GenerateRandomString(16)
|
||||
if err != nil {
|
||||
app.log.Printf("error generating OIDC nonce: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "nonce",
|
||||
Value: nonce,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
out.Nonce = nonce
|
||||
|
||||
return c.Render(http.StatusOK, "admin-login", out)
|
||||
}
|
||||
|
||||
// doLogin logs a user in with a username and password.
|
||||
func doLogin(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
// Verify that the request came from the login page (CSRF).
|
||||
// nonce, err := c.Cookie("nonce")
|
||||
// if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") {
|
||||
// return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))
|
||||
// }
|
||||
|
||||
var (
|
||||
username = strings.TrimSpace(c.FormValue("username"))
|
||||
password = strings.TrimSpace(c.FormValue("password"))
|
||||
)
|
||||
|
||||
if !strHasLen(username, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
|
||||
if !strHasLen(password, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
user, err := app.core.LoginUser(username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resist potential constant-time-comparison attacks with a min response time.
|
||||
if ms := time.Now().Sub(start).Milliseconds(); ms < 100 {
|
||||
time.Sleep(time.Duration(ms))
|
||||
}
|
||||
|
||||
// Set the session.
|
||||
if err := app.auth.SaveSession(user, "", c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
323
cmd/handlers.go
323
cmd/handlers.go
|
@ -2,11 +2,12 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/paginator"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
|
@ -18,6 +19,11 @@ const (
|
|||
|
||||
sortAsc = "asc"
|
||||
sortDesc = "desc"
|
||||
|
||||
basicAuthd = "basicauthd"
|
||||
|
||||
// URIs.
|
||||
uriAdmin = "/admin"
|
||||
)
|
||||
|
||||
type okResp struct {
|
||||
|
@ -47,16 +53,7 @@ var (
|
|||
|
||||
// registerHandlers registers HTTP handlers.
|
||||
func initHTTPHandlers(e *echo.Echo, app *App) {
|
||||
// Group of private handlers with BasicAuth.
|
||||
var g *echo.Group
|
||||
|
||||
if len(app.constants.AdminUsername) == 0 ||
|
||||
len(app.constants.AdminPassword) == 0 {
|
||||
g = e.Group("")
|
||||
} else {
|
||||
g = e.Group("", middleware.BasicAuth(basicAuth))
|
||||
}
|
||||
|
||||
// Default error handler.
|
||||
e.HTTPErrorHandler = func(err error, c echo.Context) {
|
||||
// Generic, non-echo error. Log it.
|
||||
if _, ok := err.(*echo.HTTPError); !ok {
|
||||
|
@ -65,166 +62,233 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
e.DefaultHTTPErrorHandler(err, c)
|
||||
}
|
||||
|
||||
// Admin JS app views.
|
||||
// /admin/static/* file server is registered in initHTTPServer().
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
|
||||
})
|
||||
var (
|
||||
// Authenticated /api/* handlers.
|
||||
api = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
u := c.Get(auth.UserKey)
|
||||
|
||||
g.GET(path.Join(adminRoot, ""), handleAdminPage)
|
||||
g.GET(path.Join(adminRoot, "/custom.css"), serveCustomAppearance("admin.custom_css"))
|
||||
g.GET(path.Join(adminRoot, "/custom.js"), serveCustomAppearance("admin.custom_js"))
|
||||
g.GET(path.Join(adminRoot, "/*"), handleAdminPage)
|
||||
// On no-auth, respond with a JSON error.
|
||||
if err, ok := u.(*echo.HTTPError); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
// Authenticated non /api handlers.
|
||||
a = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
u := c.Get(auth.UserKey)
|
||||
// On no-auth, redirect to login page
|
||||
if _, ok := u.(*echo.HTTPError); ok {
|
||||
u, _ := url.Parse(app.constants.LoginURL)
|
||||
q := url.Values{}
|
||||
q.Set("next", c.Request().RequestURI)
|
||||
u.RawQuery = q.Encode()
|
||||
return c.Redirect(http.StatusTemporaryRedirect, u.String())
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
// Public unauthenticated endpoints.
|
||||
p = e.Group("")
|
||||
)
|
||||
|
||||
// Authenticated endpoints.
|
||||
a.GET(path.Join(uriAdmin, ""), handleAdminPage)
|
||||
a.GET(path.Join(uriAdmin, "/custom.css"), serveCustomAppearance("admin.custom_css"))
|
||||
a.GET(path.Join(uriAdmin, "/custom.js"), serveCustomAppearance("admin.custom_js"))
|
||||
a.GET(path.Join(uriAdmin, "/*"), handleAdminPage)
|
||||
|
||||
pm := app.auth.Perm
|
||||
|
||||
// API endpoints.
|
||||
g.GET("/api/health", handleHealthCheck)
|
||||
g.GET("/api/config", handleGetServerConfig)
|
||||
g.GET("/api/lang/:lang", handleGetI18nLang)
|
||||
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
||||
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
||||
api.GET("/api/health", handleHealthCheck)
|
||||
api.GET("/api/config", handleGetServerConfig)
|
||||
api.GET("/api/lang/:lang", handleGetI18nLang)
|
||||
api.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
||||
api.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
||||
|
||||
g.GET("/api/settings", handleGetSettings)
|
||||
g.PUT("/api/settings", handleUpdateSettings)
|
||||
g.POST("/api/settings/smtp/test", handleTestSMTPSettings)
|
||||
g.POST("/api/admin/reload", handleReloadApp)
|
||||
g.GET("/api/logs", handleGetLogs)
|
||||
g.GET("/api/about", handleGetAboutInfo)
|
||||
api.GET("/api/settings", pm(handleGetSettings, "settings:get"))
|
||||
api.PUT("/api/settings", pm(handleUpdateSettings, "settings:manage"))
|
||||
api.POST("/api/settings/smtp/test", pm(handleTestSMTPSettings, "settings:manage"))
|
||||
api.POST("/api/admin/reload", pm(handleReloadApp, "settings:manage"))
|
||||
api.GET("/api/logs", pm(handleGetLogs, "settings:get"))
|
||||
api.GET("/api/events", pm(handleEventStream, "settings:get"))
|
||||
api.GET("/api/about", handleGetAboutInfo)
|
||||
|
||||
g.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||
g.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||
g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces)
|
||||
g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces)
|
||||
g.POST("/api/subscribers", handleCreateSubscriber)
|
||||
g.PUT("/api/subscribers/:id", handleUpdateSubscriber)
|
||||
g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin)
|
||||
g.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers)
|
||||
g.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers)
|
||||
g.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists)
|
||||
g.PUT("/api/subscribers/lists", handleManageSubscriberLists)
|
||||
g.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
|
||||
g.DELETE("/api/subscribers", handleDeleteSubscribers)
|
||||
api.GET("/api/subscribers", pm(handleQuerySubscribers, "subscribers:get_all", "subscribers:get"))
|
||||
api.GET("/api/subscribers/:id", pm(handleGetSubscriber, "subscribers:get_all", "subscribers:get"))
|
||||
api.GET("/api/subscribers/:id/export", pm(handleExportSubscriberData, "subscribers:get_all", "subscribers:get"))
|
||||
api.GET("/api/subscribers/:id/bounces", pm(handleGetSubscriberBounces, "bounces:get"))
|
||||
api.DELETE("/api/subscribers/:id/bounces", pm(handleDeleteSubscriberBounces, "bounces:manage"))
|
||||
api.POST("/api/subscribers", pm(handleCreateSubscriber, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/:id", pm(handleUpdateSubscriber, "subscribers:manage"))
|
||||
api.POST("/api/subscribers/:id/optin", pm(handleSubscriberSendOptin, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/:id/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/lists/:id", pm(handleManageSubscriberLists, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/lists", pm(handleManageSubscriberLists, "subscribers:manage"))
|
||||
api.DELETE("/api/subscribers/:id", pm(handleDeleteSubscribers, "subscribers:manage"))
|
||||
api.DELETE("/api/subscribers", pm(handleDeleteSubscribers, "subscribers:manage"))
|
||||
|
||||
g.GET("/api/bounces", handleGetBounces)
|
||||
g.GET("/api/bounces/:id", handleGetBounces)
|
||||
g.DELETE("/api/bounces", handleDeleteBounces)
|
||||
g.DELETE("/api/bounces/:id", handleDeleteBounces)
|
||||
api.GET("/api/bounces", pm(handleGetBounces, "bounces:get"))
|
||||
api.GET("/api/bounces/:id", pm(handleGetBounces, "bounces:get"))
|
||||
api.DELETE("/api/bounces", pm(handleDeleteBounces, "bounces:manage"))
|
||||
api.DELETE("/api/bounces/:id", pm(handleDeleteBounces, "bounces:manage"))
|
||||
|
||||
// Subscriber operations based on arbitrary SQL queries.
|
||||
// These aren't very REST-like.
|
||||
g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
|
||||
g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery)
|
||||
g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
|
||||
g.GET("/api/subscribers", handleQuerySubscribers)
|
||||
g.GET("/api/subscribers/export",
|
||||
middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers))
|
||||
api.POST("/api/subscribers/query/delete", pm(handleDeleteSubscribersByQuery, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/query/blocklist", pm(handleBlocklistSubscribersByQuery, "subscribers:manage"))
|
||||
api.PUT("/api/subscribers/query/lists", pm(handleManageSubscriberListsByQuery, "subscribers:manage"))
|
||||
api.GET("/api/subscribers/export",
|
||||
pm(middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers), "subscribers:get_all", "subscribers:get"))
|
||||
|
||||
g.GET("/api/import/subscribers", handleGetImportSubscribers)
|
||||
g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats)
|
||||
g.POST("/api/import/subscribers", handleImportSubscribers)
|
||||
g.DELETE("/api/import/subscribers", handleStopImportSubscribers)
|
||||
api.GET("/api/import/subscribers", pm(handleGetImportSubscribers, "subscribers:import"))
|
||||
api.GET("/api/import/subscribers/logs", pm(handleGetImportSubscriberStats, "subscribers:import"))
|
||||
api.POST("/api/import/subscribers", pm(handleImportSubscribers, "subscribers:import"))
|
||||
api.DELETE("/api/import/subscribers", pm(handleStopImportSubscribers, "subscribers:import"))
|
||||
|
||||
g.GET("/api/lists", handleGetLists)
|
||||
g.GET("/api/lists/:id", handleGetLists)
|
||||
g.POST("/api/lists", handleCreateList)
|
||||
g.PUT("/api/lists/:id", handleUpdateList)
|
||||
g.DELETE("/api/lists/:id", handleDeleteLists)
|
||||
// Individual list permissions are applied directly within handleGetLists.
|
||||
api.GET("/api/lists", handleGetLists)
|
||||
api.GET("/api/lists/:id", listPerm(handleGetLists))
|
||||
api.POST("/api/lists", pm(handleCreateList, "lists:manage_all"))
|
||||
api.PUT("/api/lists/:id", listPerm(handleUpdateList))
|
||||
api.DELETE("/api/lists/:id", listPerm(handleDeleteLists))
|
||||
|
||||
g.GET("/api/campaigns", handleGetCampaigns)
|
||||
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
|
||||
g.GET("/api/campaigns/:id", handleGetCampaign)
|
||||
g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
|
||||
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||
g.POST("/api/campaigns/:id/content", handleCampaignContent)
|
||||
g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
|
||||
g.POST("/api/campaigns/:id/test", handleTestCampaign)
|
||||
g.POST("/api/campaigns", handleCreateCampaign)
|
||||
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
||||
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
|
||||
g.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive)
|
||||
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
|
||||
api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get"))
|
||||
api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get"))
|
||||
api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get"))
|
||||
api.GET("/api/campaigns/analytics/:type", pm(handleGetCampaignViewAnalytics, "campaigns:get_analytics"))
|
||||
api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage"))
|
||||
api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:manage"))
|
||||
api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage"))
|
||||
api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage"))
|
||||
api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage"))
|
||||
|
||||
g.GET("/api/media", handleGetMedia)
|
||||
g.GET("/api/media/:id", handleGetMedia)
|
||||
g.POST("/api/media", handleUploadMedia)
|
||||
g.DELETE("/api/media/:id", handleDeleteMedia)
|
||||
api.GET("/api/media", pm(handleGetMedia, "media:get"))
|
||||
api.GET("/api/media/:id", pm(handleGetMedia, "media:get"))
|
||||
api.POST("/api/media", pm(handleUploadMedia, "media:manage"))
|
||||
api.DELETE("/api/media/:id", pm(handleDeleteMedia, "media:manage"))
|
||||
|
||||
g.GET("/api/templates", handleGetTemplates)
|
||||
g.GET("/api/templates/:id", handleGetTemplates)
|
||||
g.GET("/api/templates/:id/preview", handlePreviewTemplate)
|
||||
g.POST("/api/templates/preview", handlePreviewTemplate)
|
||||
g.POST("/api/templates", handleCreateTemplate)
|
||||
g.PUT("/api/templates/:id", handleUpdateTemplate)
|
||||
g.PUT("/api/templates/:id/default", handleTemplateSetDefault)
|
||||
g.DELETE("/api/templates/:id", handleDeleteTemplate)
|
||||
api.GET("/api/templates", pm(handleGetTemplates, "templates:get"))
|
||||
api.GET("/api/templates/:id", pm(handleGetTemplates, "templates:get"))
|
||||
api.GET("/api/templates/:id/preview", pm(handlePreviewTemplate, "templates:get"))
|
||||
api.POST("/api/templates/preview", pm(handlePreviewTemplate, "templates:get"))
|
||||
api.POST("/api/templates", pm(handleCreateTemplate, "templates:manage"))
|
||||
api.PUT("/api/templates/:id", pm(handleUpdateTemplate, "templates:manage"))
|
||||
api.PUT("/api/templates/:id/default", pm(handleTemplateSetDefault, "templates:manage"))
|
||||
api.DELETE("/api/templates/:id", pm(handleDeleteTemplate, "templates:manage"))
|
||||
|
||||
g.DELETE("/api/maintenance/subscribers/:type", handleGCSubscribers)
|
||||
g.DELETE("/api/maintenance/analytics/:type", handleGCCampaignAnalytics)
|
||||
g.DELETE("/api/maintenance/subscriptions/unconfirmed", handleGCSubscriptions)
|
||||
api.DELETE("/api/maintenance/subscribers/:type", pm(handleGCSubscribers, "settings:maintain"))
|
||||
api.DELETE("/api/maintenance/analytics/:type", pm(handleGCCampaignAnalytics, "settings:maintain"))
|
||||
api.DELETE("/api/maintenance/subscriptions/unconfirmed", pm(handleGCSubscriptions, "settings:maintain"))
|
||||
|
||||
g.POST("/api/tx", handleSendTxMessage)
|
||||
api.POST("/api/tx", pm(handleSendTxMessage, "tx:send"))
|
||||
|
||||
g.GET("/api/events", handleEventStream)
|
||||
api.GET("/api/profile", handleGetUserProfile)
|
||||
api.PUT("/api/profile", handleUpdateUserProfile)
|
||||
api.GET("/api/users", pm(handleGetUsers, "users:get"))
|
||||
api.GET("/api/users/:id", pm(handleGetUsers, "users:get"))
|
||||
api.POST("/api/users", pm(handleCreateUser, "users:manage"))
|
||||
api.PUT("/api/users/:id", pm(handleUpdateUser, "users:manage"))
|
||||
api.DELETE("/api/users", pm(handleDeleteUsers, "users:manage"))
|
||||
api.DELETE("/api/users/:id", pm(handleDeleteUsers, "users:manage"))
|
||||
api.POST("/api/logout", handleLogout)
|
||||
|
||||
api.GET("/api/roles/users", pm(handleGetUserRoles, "roles:get"))
|
||||
api.GET("/api/roles/lists", pm(handleGeListRoles, "roles:get"))
|
||||
api.POST("/api/roles/users", pm(handleCreateUserRole, "roles:manage"))
|
||||
api.POST("/api/roles/lists", pm(handleCreateListRole, "roles:manage"))
|
||||
api.PUT("/api/roles/users/:id", pm(handleUpdateUserRole, "roles:manage"))
|
||||
api.PUT("/api/roles/lists/:id", pm(handleUpdateListRole, "roles:manage"))
|
||||
api.DELETE("/api/roles/:id", pm(handleDeleteRole, "roles:manage"))
|
||||
|
||||
if app.constants.BounceWebhooksEnabled {
|
||||
// Private authenticated bounce endpoint.
|
||||
g.POST("/webhooks/bounce", handleBounceWebhook)
|
||||
api.POST("/webhooks/bounce", pm(handleBounceWebhook, "webhooks:post_bounce"))
|
||||
|
||||
// Public bounce endpoints for webservices like SES.
|
||||
e.POST("/webhooks/service/:service", handleBounceWebhook)
|
||||
p.POST("/webhooks/service/:service", handleBounceWebhook)
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Public API endpoints.
|
||||
e.GET("/api/public/lists", handleGetPublicLists)
|
||||
e.POST("/api/public/subscription", handlePublicSubscription)
|
||||
|
||||
// Landing page.
|
||||
p.GET("/", func(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
|
||||
})
|
||||
|
||||
// Public admin endpoints (login page, OIDC endpoints).
|
||||
p.GET(path.Join(uriAdmin, "/login"), handleLoginPage)
|
||||
p.POST(path.Join(uriAdmin, "/login"), handleLoginPage)
|
||||
|
||||
if app.constants.Security.OIDC.Enabled {
|
||||
p.POST("/auth/oidc", handleOIDCLogin)
|
||||
p.GET("/auth/oidc", handleOIDCFinish)
|
||||
}
|
||||
|
||||
// Public APIs.
|
||||
p.GET("/api/public/lists", handleGetPublicLists)
|
||||
p.POST("/api/public/subscription", handlePublicSubscription)
|
||||
if app.constants.EnablePublicArchive {
|
||||
e.GET("/api/public/archive", handleGetCampaignArchives)
|
||||
p.GET("/api/public/archive", handleGetCampaignArchives)
|
||||
}
|
||||
|
||||
// /public/static/* file server is registered in initHTTPServer().
|
||||
// Public subscriber facing views.
|
||||
e.GET("/subscription/form", handleSubscriptionFormPage)
|
||||
e.POST("/subscription/form", handleSubscriptionForm)
|
||||
e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
|
||||
p.GET("/subscription/form", handleSubscriptionFormPage)
|
||||
p.POST("/subscription/form", handleSubscriptionForm)
|
||||
p.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
|
||||
"campUUID", "subUUID")))
|
||||
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs),
|
||||
p.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs),
|
||||
"campUUID", "subUUID"))
|
||||
e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
|
||||
e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
|
||||
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
|
||||
p.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
|
||||
p.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
|
||||
p.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
|
||||
"subUUID"))
|
||||
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
|
||||
p.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
|
||||
"subUUID"))
|
||||
e.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect,
|
||||
p.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect,
|
||||
"linkUUID", "campUUID", "subUUID")))
|
||||
e.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage,
|
||||
p.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage,
|
||||
"campUUID", "subUUID")))
|
||||
e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
|
||||
p.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
|
||||
"campUUID", "subUUID")))
|
||||
|
||||
if app.constants.EnablePublicArchive {
|
||||
e.GET("/archive", handleCampaignArchivesPage)
|
||||
e.GET("/archive.xml", handleGetCampaignArchivesFeed)
|
||||
e.GET("/archive/:id", handleCampaignArchivePage)
|
||||
e.GET("/archive/latest", handleCampaignArchivePageLatest)
|
||||
p.GET("/archive", handleCampaignArchivesPage)
|
||||
p.GET("/archive.xml", handleGetCampaignArchivesFeed)
|
||||
p.GET("/archive/:id", handleCampaignArchivePage)
|
||||
p.GET("/archive/latest", handleCampaignArchivePageLatest)
|
||||
}
|
||||
|
||||
e.GET("/public/custom.css", serveCustomAppearance("public.custom_css"))
|
||||
e.GET("/public/custom.js", serveCustomAppearance("public.custom_js"))
|
||||
p.GET("/public/custom.css", serveCustomAppearance("public.custom_css"))
|
||||
p.GET("/public/custom.js", serveCustomAppearance("public.custom_js"))
|
||||
|
||||
// Public health API endpoint.
|
||||
e.GET("/health", handleHealthCheck)
|
||||
p.GET("/health", handleHealthCheck)
|
||||
|
||||
// 404 pages.
|
||||
e.RouteNotFound("/*", func(c echo.Context) error {
|
||||
p.RouteNotFound("/*", func(c echo.Context) error {
|
||||
return c.Render(http.StatusNotFound, tplMessage,
|
||||
makeMsgTpl("404 - "+app.i18n.T("public.notFoundTitle"), "", ""))
|
||||
})
|
||||
e.RouteNotFound("/api/*", func(c echo.Context) error {
|
||||
p.RouteNotFound("/api/*", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "404 unknown endpoint")
|
||||
})
|
||||
e.RouteNotFound("/admin/*", func(c echo.Context) error {
|
||||
p.RouteNotFound("/admin/*", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "404 page not found")
|
||||
})
|
||||
}
|
||||
|
@ -233,7 +297,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
func handleAdminPage(c echo.Context) error {
|
||||
app := c.Get("app").(*App)
|
||||
|
||||
b, err := app.fs.Read(path.Join(adminRoot, "/index.html"))
|
||||
b, err := app.fs.Read(path.Join(uriAdmin, "/index.html"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
@ -281,23 +345,6 @@ func serveCustomAppearance(name string) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
|
||||
func basicAuth(username, password string, c echo.Context) (bool, error) {
|
||||
app := c.Get("app").(*App)
|
||||
|
||||
// Auth is disabled.
|
||||
if len(app.constants.AdminUsername) == 0 &&
|
||||
len(app.constants.AdminPassword) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(username), app.constants.AdminUsername) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(password), app.constants.AdminPassword) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// validateUUID middleware validates the UUID string format for a given set of params.
|
||||
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
|
130
cmd/init.go
130
cmd/init.go
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -28,6 +29,7 @@ import (
|
|||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/internal/bounce"
|
||||
"github.com/knadh/listmonk/internal/bounce/mailbox"
|
||||
"github.com/knadh/listmonk/internal/captcha"
|
||||
|
@ -45,13 +47,11 @@ import (
|
|||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
flag "github.com/spf13/pflag"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
const (
|
||||
queryFilePath = "queries.sql"
|
||||
|
||||
// Root URI of the admin frontend.
|
||||
adminRoot = "/admin"
|
||||
)
|
||||
|
||||
// constants contains static, constant config values required by the app.
|
||||
|
@ -60,6 +60,7 @@ type constants struct {
|
|||
RootURL string `koanf:"root_url"`
|
||||
LogoURL string `koanf:"logo_url"`
|
||||
FaviconURL string `koanf:"favicon_url"`
|
||||
LoginURL string `koanf:"login_url"`
|
||||
FromEmail string `koanf:"from_email"`
|
||||
NotifyEmails []string `koanf:"notify_emails"`
|
||||
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
|
||||
|
@ -79,12 +80,17 @@ type constants struct {
|
|||
DomainBlocklist []string `koanf:"-"`
|
||||
} `koanf:"privacy"`
|
||||
Security struct {
|
||||
OIDC struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
Provider string `koanf:"provider_url"`
|
||||
ClientID string `koanf:"client_id"`
|
||||
ClientSecret string `koanf:"client_secret"`
|
||||
} `koanf:"oidc"`
|
||||
|
||||
EnableCaptcha bool `koanf:"enable_captcha"`
|
||||
CaptchaKey string `koanf:"captcha_key"`
|
||||
CaptchaSecret string `koanf:"captcha_secret"`
|
||||
} `koanf:"security"`
|
||||
AdminUsername []byte `koanf:"admin_username"`
|
||||
AdminPassword []byte `koanf:"admin_password"`
|
||||
|
||||
Appearance struct {
|
||||
AdminCSS []byte `koanf:"admin.custom_css"`
|
||||
|
@ -93,13 +99,14 @@ type constants struct {
|
|||
PublicJS []byte `koanf:"public.custom_js"`
|
||||
}
|
||||
|
||||
UnsubURL string
|
||||
LinkTrackURL string
|
||||
ViewTrackURL string
|
||||
OptinURL string
|
||||
MessageURL string
|
||||
ArchiveURL string
|
||||
AssetVersion string
|
||||
HasLegacyUser bool
|
||||
UnsubURL string
|
||||
LinkTrackURL string
|
||||
ViewTrackURL string
|
||||
OptinURL string
|
||||
MessageURL string
|
||||
ArchiveURL string
|
||||
AssetVersion string
|
||||
|
||||
MediaUpload struct {
|
||||
Provider string
|
||||
|
@ -110,6 +117,9 @@ type constants struct {
|
|||
BounceSESEnabled bool
|
||||
BounceSendgridEnabled bool
|
||||
BouncePostmarkEnabled bool
|
||||
|
||||
PermissionsRaw json.RawMessage
|
||||
Permissions map[string]struct{}
|
||||
}
|
||||
|
||||
type notifTpls struct {
|
||||
|
@ -171,6 +181,7 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem
|
|||
"./config.toml.sample:config.toml.sample",
|
||||
"./queries.sql:queries.sql",
|
||||
"./schema.sql:schema.sql",
|
||||
"./permissions.json:permissions.json",
|
||||
}
|
||||
|
||||
frontendFiles = []string{
|
||||
|
@ -385,11 +396,13 @@ func initConstants() *constants {
|
|||
if err := ko.Unmarshal("security", &c.Security); err != nil {
|
||||
lo.Fatalf("error loading app.security config: %v", err)
|
||||
}
|
||||
|
||||
if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil {
|
||||
lo.Fatalf("error loading app.appearance config: %v", err)
|
||||
}
|
||||
|
||||
c.RootURL = strings.TrimRight(c.RootURL, "/")
|
||||
c.LoginURL = path.Join(uriAdmin, "/login")
|
||||
c.Lang = ko.String("app.lang")
|
||||
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
|
||||
c.MediaUpload.Provider = ko.String("upload.provider")
|
||||
|
@ -420,9 +433,33 @@ func initConstants() *constants {
|
|||
c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled")
|
||||
c.BouncePostmarkEnabled = ko.Bool("bounce.postmark.enabled")
|
||||
|
||||
c.HasLegacyUser = ko.Exists("app.admin_username") || ko.Exists("app.admin_password")
|
||||
|
||||
b := md5.Sum([]byte(time.Now().String()))
|
||||
c.AssetVersion = fmt.Sprintf("%x", b)[0:10]
|
||||
|
||||
pm, err := fs.Read("/permissions.json")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading permissions file: %v", err)
|
||||
}
|
||||
c.PermissionsRaw = pm
|
||||
|
||||
// Make a lookup map of permissions.
|
||||
permGroups := []struct {
|
||||
Group string `json:"group"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}{}
|
||||
if err := json.Unmarshal(pm, &permGroups); err != nil {
|
||||
lo.Fatalf("error loading permissions file: %v", err)
|
||||
}
|
||||
|
||||
c.Permissions = map[string]struct{}{}
|
||||
for _, group := range permGroups {
|
||||
for _, g := range group.Permissions {
|
||||
c.Permissions[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
|
@ -900,3 +937,72 @@ func initTplFuncs(i *i18n.I18n, cs *constants) template.FuncMap {
|
|||
|
||||
return funcs
|
||||
}
|
||||
|
||||
func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth {
|
||||
var oidcCfg auth.OIDCConfig
|
||||
|
||||
if ko.Bool("security.oidc.enabled") {
|
||||
oidcCfg = auth.OIDCConfig{
|
||||
Enabled: true,
|
||||
ProviderURL: ko.String("security.oidc.provider_url"),
|
||||
ClientID: ko.String("security.oidc.client_id"),
|
||||
ClientSecret: ko.String("security.oidc.client_secret"),
|
||||
RedirectURL: fmt.Sprintf("%s/auth/oidc", strings.TrimRight(ko.String("app.root_url"), "/")),
|
||||
}
|
||||
}
|
||||
|
||||
// Session manager callbacks for getting and setting cookies.
|
||||
cb := &auth.Callbacks{
|
||||
GetCookie: func(name string, r interface{}) (*http.Cookie, error) {
|
||||
c := r.(echo.Context)
|
||||
cookie, err := c.Cookie(name)
|
||||
return cookie, err
|
||||
},
|
||||
SetCookie: func(cookie *http.Cookie, w interface{}) error {
|
||||
c := w.(echo.Context)
|
||||
c.SetCookie(cookie)
|
||||
return nil
|
||||
},
|
||||
GetUser: func(id int) (models.User, error) {
|
||||
return co.GetUser(id, "", "")
|
||||
},
|
||||
}
|
||||
|
||||
a, err := auth.New(auth.Config{
|
||||
OIDC: oidcCfg,
|
||||
}, db, cb, lo)
|
||||
if err != nil {
|
||||
lo.Fatalf("error initializing auth: %v", err)
|
||||
}
|
||||
|
||||
// Cache all API users in-memory for token auth.
|
||||
if err := cacheAPIUsers(co, a); err != nil {
|
||||
lo.Fatalf("error loading API users: %v", err)
|
||||
}
|
||||
|
||||
// If the legacy username+password is set in the TOML file, use that as an API
|
||||
// access token in the auth module to preserve backwards compatibility for existing
|
||||
// API integrations. The presence of these values show a red banner on the admin UI
|
||||
// prompting the creation of new API credentials and the removal of values from
|
||||
// the TOML config.
|
||||
var (
|
||||
username = ko.String("app.admin_username")
|
||||
password = ko.String("app.admin_password")
|
||||
)
|
||||
if len(username) > 2 && len(password) > 6 {
|
||||
u := models.User{
|
||||
Username: username,
|
||||
Password: null.String{Valid: true, String: password},
|
||||
PasswordLogin: true,
|
||||
HasPassword: true,
|
||||
Status: models.UserStatusEnabled,
|
||||
Type: models.UserTypeAPI,
|
||||
}
|
||||
u.UserRole.ID = auth.SuperAdminRoleID
|
||||
a.CacheAPIUser(u)
|
||||
|
||||
lo.Println(`WARNING: Remove the admin_username and admin_password fields from the TOML configuration file. If you are using APIs, create and use new credentials. Users are now managed via the Admin -> Settings -> Users dashboard.`)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
|
159
cmd/install.go
159
cmd/install.go
|
@ -4,18 +4,17 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// install runs the first time setup of creating and
|
||||
// migrating the database and creating the super user.
|
||||
// install runs the first time setup of setting up the database.
|
||||
func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) {
|
||||
qMap := readQueries(queryFilePath, db, fs)
|
||||
|
||||
|
@ -63,6 +62,102 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
q := prepareQueries(qMap, db, ko)
|
||||
|
||||
// Sample list.
|
||||
defList, optinList := installLists(q)
|
||||
|
||||
// Sample subscribers.
|
||||
installSubs(defList, optinList, q)
|
||||
|
||||
// Templates.
|
||||
campTplID, archiveTplID := installTemplates(q)
|
||||
|
||||
// Sample campaign.
|
||||
installCampaign(campTplID, archiveTplID, q)
|
||||
|
||||
// Super admin role.
|
||||
user, password := installUser(q)
|
||||
|
||||
lo.Printf("setup complete")
|
||||
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
|
||||
|
||||
if user != "" {
|
||||
fmt.Printf("\n\033[31mIMPORTANT! CHANGE PASSWORD AFTER LOGGING IN\033[0m\nusername: \033[32m%s\033[0m and password: \033[32m%s\033[0m\n\n", user, password)
|
||||
}
|
||||
}
|
||||
|
||||
// installSchema executes the SQL schema and creates the necessary tables and types.
|
||||
func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
q, err := fs.Read("/schema.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(q)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the current migration version.
|
||||
return recordMigrationVersion(curVer, db)
|
||||
}
|
||||
|
||||
func installUser(q *models.Queries) (string, string) {
|
||||
consts := initConstants()
|
||||
|
||||
// Super admin role.
|
||||
perms := []string{}
|
||||
for p := range consts.Permissions {
|
||||
perms = append(perms, p)
|
||||
}
|
||||
|
||||
if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil {
|
||||
lo.Fatalf("error creating super admin role: %v", err)
|
||||
}
|
||||
|
||||
// Create super admin.
|
||||
var (
|
||||
user = os.Getenv("LISTMONK_ADMIN_USER")
|
||||
password = os.Getenv("LISTMONK_ADMIN_PASSWORD")
|
||||
typ = "env"
|
||||
)
|
||||
|
||||
if user != "" {
|
||||
// If the env vars are set, use those values
|
||||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars")
|
||||
}
|
||||
} else if ko.Exists("app.admin_username") {
|
||||
// Legacy admin/password are set in the config or env var. Use those.
|
||||
user = ko.String("app.admin_username")
|
||||
password = ko.String("app.admin_password")
|
||||
|
||||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars")
|
||||
}
|
||||
typ = "legacy config"
|
||||
} else {
|
||||
// None are set. Auto-generate.
|
||||
user = "admin"
|
||||
if p, err := utils.GenerateRandomString(12); err != nil {
|
||||
lo.Fatal("error generating admin password")
|
||||
} else {
|
||||
password = p
|
||||
}
|
||||
typ = "auto-generated"
|
||||
}
|
||||
|
||||
lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)
|
||||
|
||||
if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, nil, "enabled"); err != nil {
|
||||
lo.Fatalf("error creating superadmin user: %v", err)
|
||||
}
|
||||
|
||||
if typ == "auto-generated" {
|
||||
return user, password
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func installLists(q *models.Queries) (int, int) {
|
||||
var (
|
||||
defList int
|
||||
optinList int
|
||||
|
@ -88,13 +183,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating list: %v", err)
|
||||
}
|
||||
|
||||
return defList, optinList
|
||||
}
|
||||
|
||||
func installSubs(defListID, optinListID int, q *models.Queries) {
|
||||
// Sample subscriber.
|
||||
if _, err := q.UpsertSubscriber.Exec(
|
||||
uuid.Must(uuid.NewV4()),
|
||||
"john@example.com",
|
||||
"John Doe",
|
||||
`{"type": "known", "good": true, "city": "Bengaluru"}`,
|
||||
pq.Int64Array{int64(defList)},
|
||||
pq.Int64Array{int64(defListID)},
|
||||
models.SubscriptionStatusUnconfirmed,
|
||||
true); err != nil {
|
||||
lo.Fatalf("Error creating subscriber: %v", err)
|
||||
|
@ -104,12 +203,14 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
"anon@example.com",
|
||||
"Anon Doe",
|
||||
`{"type": "unknown", "good": true, "city": "Bengaluru"}`,
|
||||
pq.Int64Array{int64(optinList)},
|
||||
pq.Int64Array{int64(optinListID)},
|
||||
models.SubscriptionStatusUnconfirmed,
|
||||
true); err != nil {
|
||||
lo.Fatalf("error creating subscriber: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installTemplates(q *models.Queries) (int, int) {
|
||||
// Default campaign template.
|
||||
campTpl, err := fs.Get("/static/email-templates/default.tpl")
|
||||
if err != nil {
|
||||
|
@ -135,6 +236,20 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating default campaign template: %v", err)
|
||||
}
|
||||
|
||||
// Sample tx template.
|
||||
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating sample transactional template: %v", err)
|
||||
}
|
||||
|
||||
return campTplID, archiveTplID
|
||||
}
|
||||
|
||||
func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
|
||||
// Sample campaign.
|
||||
if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()),
|
||||
models.CampaignTypeRegular,
|
||||
|
@ -166,33 +281,6 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating sample campaign: %v", err)
|
||||
}
|
||||
|
||||
// Sample tx template.
|
||||
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating sample transactional template: %v", err)
|
||||
}
|
||||
|
||||
lo.Printf("setup complete")
|
||||
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
|
||||
}
|
||||
|
||||
// installSchema executes the SQL schema and creates the necessary tables and types.
|
||||
func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
q, err := fs.Read("/schema.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(q)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the current migration version.
|
||||
return recordMigrationVersion(curVer, db)
|
||||
}
|
||||
|
||||
// recordMigrationVersion inserts the given version (of DB migration) into the
|
||||
|
@ -217,13 +305,6 @@ func newConfigFile(path string) error {
|
|||
return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err)
|
||||
}
|
||||
|
||||
// Generate a random admin password.
|
||||
pwd, err := generateRandomString(16)
|
||||
if err == nil {
|
||||
b = regexp.MustCompile(`admin_password\s+?=\s+?(.*)`).
|
||||
ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd)))
|
||||
}
|
||||
|
||||
return os.WriteFile(path, b, 0644)
|
||||
}
|
||||
|
||||
|
|
93
cmd/lists.go
93
cmd/lists.go
|
@ -5,15 +5,17 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
|
||||
// handleGetLists retrieves lists with additional metadata like subscriber counts.
|
||||
func handleGetLists(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
query = strings.TrimSpace(c.FormValue("query"))
|
||||
tags = c.QueryParams()["tag"]
|
||||
|
@ -22,28 +24,23 @@ func handleGetLists(c echo.Context) error {
|
|||
optin = c.FormValue("optin")
|
||||
order = c.FormValue("order")
|
||||
minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
|
||||
listID, _ = strconv.Atoi(c.Param("id"))
|
||||
|
||||
out models.PageResults
|
||||
)
|
||||
|
||||
// Fetch one list.
|
||||
single := false
|
||||
if listID > 0 {
|
||||
single = true
|
||||
}
|
||||
|
||||
if single {
|
||||
out, err := app.core.GetList(listID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
var (
|
||||
permittedIDs []int
|
||||
getAll = false
|
||||
)
|
||||
if _, ok := user.PermissionsMap["lists:get_all"]; ok {
|
||||
getAll = true
|
||||
} else {
|
||||
permittedIDs = user.GetListIDs
|
||||
}
|
||||
|
||||
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
|
||||
if !single && minimal {
|
||||
res, err := app.core.GetLists("")
|
||||
if minimal {
|
||||
res, err := app.core.GetLists("", getAll, permittedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -61,20 +58,11 @@ func handleGetLists(c echo.Context) error {
|
|||
}
|
||||
|
||||
// Full list query.
|
||||
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, pg.Offset, pg.Limit)
|
||||
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, getAll, permittedIDs, pg.Offset, pg.Limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if single && len(res) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
|
||||
}
|
||||
|
||||
if single {
|
||||
return c.JSON(http.StatusOK, okResp{res[0]})
|
||||
}
|
||||
|
||||
out.Query = query
|
||||
out.Results = res
|
||||
out.Total = total
|
||||
|
@ -84,6 +72,21 @@ func handleGetLists(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleGetList retrieves a single list by id.
|
||||
func handleGetList(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
listID, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
out, err := app.core.GetList(listID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleCreateList handles list creation.
|
||||
func handleCreateList(c echo.Context) error {
|
||||
var (
|
||||
|
@ -160,3 +163,37 @@ func handleDeleteLists(c echo.Context) error {
|
|||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// listPerm is a middleware for wrapping /list/* API calls that take a
|
||||
// list :id param for validating the list ID against the user's list perms.
|
||||
func listPerm(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
// Define permissions based on HTTP read/write.
|
||||
var (
|
||||
permAll = models.PermListManageAll
|
||||
perm = models.PermListManage
|
||||
)
|
||||
if c.Request().Method == http.MethodGet {
|
||||
permAll = models.PermListGetAll
|
||||
perm = models.PermListGet
|
||||
}
|
||||
|
||||
// Check if the user has permissions for all lists or the specific list.
|
||||
if _, ok := user.PermissionsMap[permAll]; ok {
|
||||
return next(c)
|
||||
}
|
||||
if id > 0 {
|
||||
if _, ok := user.ListPermissionsMap[id][perm]; ok {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.permissionDenied", "name", "list"))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/internal/bounce"
|
||||
"github.com/knadh/listmonk/internal/buflog"
|
||||
"github.com/knadh/listmonk/internal/captcha"
|
||||
|
@ -44,6 +45,7 @@ type App struct {
|
|||
manager *manager.Manager
|
||||
importer *subimporter.Importer
|
||||
messengers map[string]manager.Messenger
|
||||
auth *auth.Auth
|
||||
media media.Store
|
||||
i18n *i18n.I18n
|
||||
bounce *bounce.Manager
|
||||
|
@ -210,6 +212,7 @@ func main() {
|
|||
app.queries = queries
|
||||
app.manager = initCampaignManager(app.queries, app.constants, app)
|
||||
app.importer = initImporter(app.queries, db, app.core, app)
|
||||
app.auth = initAuth(db.DB, ko, app.core)
|
||||
app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants)
|
||||
initTxTemplates(app.manager, app)
|
||||
|
||||
|
|
|
@ -115,7 +115,7 @@ func handleGetPublicLists(c echo.Context) error {
|
|||
)
|
||||
|
||||
// Get all public lists.
|
||||
lists, err := app.core.GetLists(models.ListTypePublic)
|
||||
lists, err := app.core.GetLists(models.ListTypePublic, true, nil)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
||||
}
|
||||
|
@ -418,7 +418,7 @@ func handleSubscriptionFormPage(c echo.Context) error {
|
|||
}
|
||||
|
||||
// Get all public lists.
|
||||
lists, err := app.core.GetLists(models.ListTypePublic)
|
||||
lists, err := app.core.GetLists(models.ListTypePublic, true, nil)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists")))
|
||||
|
|
216
cmd/roles.go
Normal file
216
cmd/roles.go
Normal file
|
@ -0,0 +1,216 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleGetUserRoles retrieves roles.
|
||||
func handleGetUserRoles(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
// Get all roles.
|
||||
out, err := app.core.GetRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleGeListRoles retrieves roles.
|
||||
func handleGeListRoles(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
// Get all roles.
|
||||
out, err := app.core.GetListRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleCreateUserRole handles role creation.
|
||||
func handleCreateUserRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
r = models.Role{}
|
||||
)
|
||||
|
||||
if err := c.Bind(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateUserRole(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := app.core.CreateRole(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleCreateListRole handles role creation.
|
||||
func handleCreateListRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
r = models.ListRole{}
|
||||
)
|
||||
|
||||
if err := c.Bind(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateListRole(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := app.core.CreateListRole(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleUpdateUserRole handles role modification.
|
||||
func handleUpdateUserRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
if id < 2 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Incoming params.
|
||||
var r models.Role
|
||||
if err := c.Bind(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateUserRole(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate.
|
||||
r.Name.String = strings.TrimSpace(r.Name.String)
|
||||
|
||||
out, err := app.core.UpdateUserRole(id, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache the API token for validating API queries without hitting the DB every time.
|
||||
if err := cacheAPIUsers(app.core, app.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleUpdateListRole handles role modification.
|
||||
func handleUpdateListRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
if id < 2 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Incoming params.
|
||||
var r models.ListRole
|
||||
if err := c.Bind(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateListRole(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate.
|
||||
r.Name.String = strings.TrimSpace(r.Name.String)
|
||||
|
||||
out, err := app.core.UpdateListRole(id, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache the API token for validating API queries without hitting the DB every time.
|
||||
if err := cacheAPIUsers(app.core, app.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleDeleteRole handles role deletion.
|
||||
func handleDeleteRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
if err := app.core.DeleteRole(int(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache the API token for validating API queries without hitting the DB every time.
|
||||
if err := cacheAPIUsers(app.core, app.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
func validateUserRole(r models.Role, app *App) error {
|
||||
// Validate fields.
|
||||
if !strHasLen(r.Name.String, 1, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
|
||||
}
|
||||
|
||||
for _, p := range r.Permissions {
|
||||
if _, ok := app.constants.Permissions[p]; !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("permission: %s", p)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListRole(r models.ListRole, app *App) error {
|
||||
// Validate fields.
|
||||
if !strHasLen(r.Name.String, 1, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
|
||||
}
|
||||
|
||||
for _, l := range r.Lists {
|
||||
for _, p := range l.Permissions {
|
||||
if p != "list:get" && p != "list:manage" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("list permission: %s", p)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -70,6 +70,7 @@ func handleGetSettings(c echo.Context) error {
|
|||
s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey))
|
||||
s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey))
|
||||
s.SecurityCaptchaSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptchaSecret))
|
||||
s.OIDC.ClientSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.OIDC.ClientSecret))
|
||||
s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password))
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{s})
|
||||
|
@ -201,6 +202,9 @@ func handleUpdateSettings(c echo.Context) error {
|
|||
if set.SecurityCaptchaSecret == "" {
|
||||
set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret
|
||||
}
|
||||
if set.OIDC.ClientSecret == "" {
|
||||
set.OIDC.ClientSecret = cur.OIDC.ClientSecret
|
||||
}
|
||||
|
||||
for n, v := range set.UploadExtensions {
|
||||
set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "."))
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/internal/subimporter"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -67,12 +68,17 @@ func handleGetSubscriber(c echo.Context) error {
|
|||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
if err := hasSubPerm(user, []int{id}, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := app.core.GetSubscriber(id, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -84,8 +90,9 @@ func handleGetSubscriber(c echo.Context) error {
|
|||
// handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression.
|
||||
func handleQuerySubscribers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
// The "WHERE ?" bit.
|
||||
query = sanitizeSQLExp(c.FormValue("query"))
|
||||
|
@ -95,10 +102,10 @@ func handleQuerySubscribers(c echo.Context) error {
|
|||
out models.PageResults
|
||||
)
|
||||
|
||||
// Limit the subscribers to specific lists?
|
||||
listIDs, err := getQueryInts("list_id", c.QueryParams())
|
||||
// Filter list IDs by permission.
|
||||
listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
return err
|
||||
}
|
||||
|
||||
res, total, err := app.core.QuerySubscribers(query, listIDs, subStatus, order, orderBy, pg.Offset, pg.Limit)
|
||||
|
@ -118,16 +125,17 @@ func handleQuerySubscribers(c echo.Context) error {
|
|||
// handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression.
|
||||
func handleExportSubscribers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
|
||||
// The "WHERE ?" bit.
|
||||
query = sanitizeSQLExp(c.FormValue("query"))
|
||||
)
|
||||
|
||||
// Limit the subscribers to specific lists?
|
||||
listIDs, err := getQueryInts("list_id", c.QueryParams())
|
||||
// Filter list IDs by permission.
|
||||
listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
return err
|
||||
}
|
||||
|
||||
// Export only specific subscriber IDs?
|
||||
|
@ -186,7 +194,9 @@ loop:
|
|||
// handleCreateSubscriber handles the creation of a new subscriber.
|
||||
func handleCreateSubscriber(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
|
||||
req subimporter.SubReq
|
||||
)
|
||||
|
||||
|
@ -201,8 +211,11 @@ func handleCreateSubscriber(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// Filter lists against the current user's permitted lists.
|
||||
listIDs := user.FilterListsByPerm(req.Lists, false, true)
|
||||
|
||||
// Insert the subscriber into the DB.
|
||||
sub, _, err := app.core.InsertSubscriber(req.Subscriber, req.Lists, req.ListUUIDs, req.PreconfirmSubs)
|
||||
sub, _, err := app.core.InsertSubscriber(req.Subscriber, listIDs, nil, req.PreconfirmSubs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -213,7 +226,9 @@ func handleCreateSubscriber(c echo.Context) error {
|
|||
// handleUpdateSubscriber handles modification of a subscriber.
|
||||
func handleUpdateSubscriber(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
req struct {
|
||||
models.Subscriber
|
||||
|
@ -241,7 +256,10 @@ func handleUpdateSubscriber(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
|
||||
}
|
||||
|
||||
out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true)
|
||||
// Filter lists against the current user's permitted lists.
|
||||
listIDs := user.FilterListsByPerm(req.Lists, false, true)
|
||||
|
||||
out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, listIDs, nil, req.PreconfirmSubs, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -317,7 +335,9 @@ func handleBlocklistSubscribers(c echo.Context) error {
|
|||
// It takes either an ID in the URI, or a list of IDs in the request body.
|
||||
func handleManageSubscriberLists(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
|
||||
pID = c.Param("id")
|
||||
subIDs []int
|
||||
)
|
||||
|
@ -346,15 +366,18 @@ func handleManageSubscriberLists(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
|
||||
}
|
||||
|
||||
// Filter lists against the current user's permitted lists.
|
||||
listIDs := user.FilterListsByPerm(req.TargetListIDs, false, true)
|
||||
|
||||
// Action.
|
||||
var err error
|
||||
switch req.Action {
|
||||
case "add":
|
||||
err = app.core.AddSubscriptions(subIDs, req.TargetListIDs, req.Status)
|
||||
err = app.core.AddSubscriptions(subIDs, listIDs, req.Status)
|
||||
case "remove":
|
||||
err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs)
|
||||
err = app.core.DeleteSubscriptions(subIDs, listIDs)
|
||||
case "unsubscribe":
|
||||
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil)
|
||||
err = app.core.UnsubscribeLists(subIDs, listIDs, nil)
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
|
||||
}
|
||||
|
@ -445,7 +468,9 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
|
|||
// from one or more lists based on an arbitrary SQL expression.
|
||||
func handleManageSubscriberListsByQuery(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
|
||||
req subQueryReq
|
||||
)
|
||||
|
||||
|
@ -457,15 +482,19 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
|
|||
app.i18n.T("subscribers.errorNoListsGiven"))
|
||||
}
|
||||
|
||||
// Filter lists against the current user's permitted lists.
|
||||
sourceListIDs := user.FilterListsByPerm(req.ListIDs, false, true)
|
||||
targetListIDs := user.FilterListsByPerm(req.TargetListIDs, false, true)
|
||||
|
||||
// Action.
|
||||
var err error
|
||||
switch req.Action {
|
||||
case "add":
|
||||
err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.Status)
|
||||
err = app.core.AddSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.Status)
|
||||
case "remove":
|
||||
err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
|
||||
err = app.core.DeleteSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs)
|
||||
case "unsubscribe":
|
||||
err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
|
||||
err = app.core.UnsubscribeListsByQuery(req.Query, sourceListIDs, targetListIDs)
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
|
||||
}
|
||||
|
@ -631,3 +660,50 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
|
|||
return len(lists), nil
|
||||
}
|
||||
}
|
||||
|
||||
// hasSubPerm checks whether the current user has permission to access the given list
|
||||
// of subscriber IDs.
|
||||
func hasSubPerm(u models.User, subIDs []int, app *App) error {
|
||||
if u.UserRoleID == auth.SuperAdminRoleID {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := u.PermissionsMap[models.PermSubscribersGetAll]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
res, err := app.core.HasSubscriberLists(subIDs, u.GetListIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for id, has := range res {
|
||||
if !has {
|
||||
return echo.NewHTTPError(http.StatusForbidden, app.i18n.Ts("globals.messages.permissionDenied", "name", fmt.Sprintf("subscriber: %d", id)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterListQeryByPerm(qp url.Values, user models.User, app *App) ([]int, error) {
|
||||
var listIDs []int
|
||||
|
||||
// If there are incoming list query params, filter them by permission.
|
||||
if qp.Has("list_id") {
|
||||
ids, err := getQueryInts("list_id", qp)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
listIDs = user.FilterListsByPerm(ids, true, true)
|
||||
} else {
|
||||
// There are no incoming params. If the user doesn't have permission to get all subscribers,
|
||||
// filter by the lists they have access to.
|
||||
if _, ok := user.PermissionsMap[models.PermSubscribersGetAll]; !ok {
|
||||
listIDs = user.GetListIDs
|
||||
}
|
||||
}
|
||||
|
||||
return listIDs, nil
|
||||
}
|
||||
|
|
|
@ -10,18 +10,25 @@ import (
|
|||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const updateCheckURL = "https://api.github.com/repos/knadh/listmonk/releases/latest"
|
||||
const updateCheckURL = "https://update.listmonk.app/update.json"
|
||||
|
||||
type remoteUpdateResp struct {
|
||||
Version string `json:"tag_name"`
|
||||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// AppUpdate contains information of a new update available to the app that
|
||||
// is sent to the frontend.
|
||||
type AppUpdate struct {
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
Update struct {
|
||||
ReleaseVersion string `json:"release_version"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// This is computed and set locally based on the local version.
|
||||
IsNew bool `json:"is_new"`
|
||||
} `json:"update"`
|
||||
Messages []struct {
|
||||
Date string `json:"date"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Priority string `json:"priority"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
var reSemver = regexp.MustCompile(`-(.*)`)
|
||||
|
@ -32,48 +39,56 @@ var reSemver = regexp.MustCompile(`-(.*)`)
|
|||
func checkUpdates(curVersion string, interval time.Duration, app *App) {
|
||||
// Strip -* suffix.
|
||||
curVersion = reSemver.ReplaceAllString(curVersion, "")
|
||||
time.Sleep(time.Second * 1)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
fnCheck := func() {
|
||||
resp, err := http.Get(updateCheckURL)
|
||||
if err != nil {
|
||||
app.log.Printf("error checking for remote update: %v", err)
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
app.log.Printf("non 200 response on remote update check: %d", resp.StatusCode)
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
app.log.Printf("error reading remote update payload: %v", err)
|
||||
continue
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
var up remoteUpdateResp
|
||||
if err := json.Unmarshal(b, &up); err != nil {
|
||||
var out AppUpdate
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
app.log.Printf("error unmarshalling remote update payload: %v", err)
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
// There is an update. Set it on the global app state.
|
||||
if semver.IsValid(up.Version) {
|
||||
v := reSemver.ReplaceAllString(up.Version, "")
|
||||
if semver.IsValid(out.Update.ReleaseVersion) {
|
||||
v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "")
|
||||
if semver.Compare(v, curVersion) > 0 {
|
||||
app.Lock()
|
||||
app.update = &AppUpdate{
|
||||
Version: up.Version,
|
||||
URL: up.URL,
|
||||
}
|
||||
app.Unlock()
|
||||
|
||||
app.log.Printf("new update %s found", up.Version)
|
||||
out.Update.IsNew = true
|
||||
app.log.Printf("new update %s found", out.Update.ReleaseVersion)
|
||||
}
|
||||
}
|
||||
|
||||
app.Lock()
|
||||
app.update = &out
|
||||
app.Unlock()
|
||||
}
|
||||
|
||||
// Give a 15 minute buffer after app start in case the admin wants to disable
|
||||
// update checks entirely and not make a request to upstream.
|
||||
time.Sleep(time.Minute * 15)
|
||||
fnCheck()
|
||||
|
||||
// Thereafter, check every $interval.
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
fnCheck()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ var migList = []migFunc{
|
|||
{"v2.4.0", migrations.V2_4_0},
|
||||
{"v2.5.0", migrations.V2_5_0},
|
||||
{"v3.0.0", migrations.V3_0_0},
|
||||
{"v4.0.0", migrations.V4_0_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
|
288
cmd/users.go
Normal file
288
cmd/users.go
Normal file
|
@ -0,0 +1,288 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/internal/core"
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
var (
|
||||
reUsername = regexp.MustCompile("^[a-zA-Z0-9_\\-\\.]+$")
|
||||
)
|
||||
|
||||
// handleGetUsers retrieves users.
|
||||
func handleGetUsers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
userID, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
// Fetch one.
|
||||
single := false
|
||||
if userID > 0 {
|
||||
single = true
|
||||
}
|
||||
|
||||
if single {
|
||||
out, err := app.core.GetUser(userID, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out.Password = null.String{}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// Get all users.
|
||||
out, err := app.core.GetUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for n := range out {
|
||||
out[n].Password = null.String{}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleCreateUser handles user creation.
|
||||
func handleCreateUser(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
u = models.User{}
|
||||
)
|
||||
|
||||
if err := c.Bind(&u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.Username = strings.TrimSpace(u.Username)
|
||||
u.Name = strings.TrimSpace(u.Name)
|
||||
email := strings.TrimSpace(u.Email.String)
|
||||
|
||||
// Validate fields.
|
||||
if !strHasLen(u.Username, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if !reUsername.MatchString(u.Username) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if u.Type != models.UserTypeAPI {
|
||||
if !utils.ValidateEmail(email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
||||
}
|
||||
if u.PasswordLogin {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
}
|
||||
|
||||
u.Email = null.String{String: email, Valid: true}
|
||||
}
|
||||
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
|
||||
// Create the user in the database.
|
||||
user, err := app.core.CreateUser(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Type != models.UserTypeAPI {
|
||||
user.Password = null.String{}
|
||||
}
|
||||
|
||||
// Cache the API token for validating API queries without hitting the DB every time.
|
||||
if err := cacheAPIUsers(app.core, app.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{user})
|
||||
}
|
||||
|
||||
// handleUpdateUser handles user modification.
|
||||
func handleUpdateUser(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Incoming params.
|
||||
var u models.User
|
||||
if err := c.Bind(&u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate.
|
||||
u.Username = strings.TrimSpace(u.Username)
|
||||
u.Name = strings.TrimSpace(u.Name)
|
||||
email := strings.TrimSpace(u.Email.String)
|
||||
|
||||
// Validate fields.
|
||||
if !strHasLen(u.Username, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if !reUsername.MatchString(u.Username) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
|
||||
if u.Type != models.UserTypeAPI {
|
||||
if !utils.ValidateEmail(email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
||||
}
|
||||
if u.PasswordLogin && u.Password.String != "" {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
|
||||
if u.Password.String != "" {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
} else {
|
||||
// Get the existing user for password validation.
|
||||
user, err := app.core.GetUser(id, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If password login is enabled, but there's no password in the DB and there's no incoming
|
||||
// password, throw an error.
|
||||
if !user.HasPassword {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.Email = null.String{String: email, Valid: true}
|
||||
}
|
||||
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
|
||||
// Update the user in the DB.
|
||||
user, err := app.core.UpdateUser(id, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear the pasword before sending outside.
|
||||
user.Password = null.String{}
|
||||
|
||||
// Cache the API token for validating API queries without hitting the DB every time.
|
||||
if err := cacheAPIUsers(app.core, app.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{user})
|
||||
}
|
||||
|
||||
// handleDeleteUsers handles user deletion, either a single one (ID in the URI), or a list.
|
||||
func handleDeleteUsers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
ids []int
|
||||
)
|
||||
|
||||
if id < 1 && len(ids) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
if id > 0 {
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
|
||||
if err := app.core.DeleteUsers(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache the API token for validating API queries without hitting the DB every time.
|
||||
if err := cacheAPIUsers(app.core, app.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleGetUserProfile fetches the uesr profile for the currently logged in user.
|
||||
func handleGetUserProfile(c echo.Context) error {
|
||||
var (
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
)
|
||||
user.Password.String = ""
|
||||
user.Password.Valid = false
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{user})
|
||||
}
|
||||
|
||||
// handleUpdateUserProfile update's the current user's profile.
|
||||
func handleUpdateUserProfile(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
)
|
||||
|
||||
u := models.User{}
|
||||
if err := c.Bind(&u); err != nil {
|
||||
return err
|
||||
}
|
||||
u.PasswordLogin = user.PasswordLogin
|
||||
u.Name = strings.TrimSpace(u.Name)
|
||||
email := strings.TrimSpace(u.Email.String)
|
||||
|
||||
// Validate fields.
|
||||
if user.PasswordLogin {
|
||||
if !utils.ValidateEmail(email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
||||
}
|
||||
u.Email = null.String{String: email, Valid: true}
|
||||
}
|
||||
|
||||
if u.PasswordLogin && u.Password.String != "" {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateUserProfile(user.ID, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Password = null.String{}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
func cacheAPIUsers(co *core.Core, a *auth.Auth) error {
|
||||
allUsers, err := co.GetUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiUsers := make([]models.User, 0, len(allUsers))
|
||||
for _, u := range allUsers {
|
||||
if u.Type == models.UserTypeAPI && u.Status == models.UserStatusEnabled {
|
||||
apiUsers = append(apiUsers, u)
|
||||
}
|
||||
}
|
||||
|
||||
a.CacheAPIUsers(apiUsers)
|
||||
return nil
|
||||
}
|
|
@ -5,13 +5,6 @@
|
|||
# port, use port 80 (this will require running with elevated permissions).
|
||||
address = "localhost:9000"
|
||||
|
||||
# BasicAuth authentication for the admin dashboard. This will eventually
|
||||
# be replaced with a better multi-user, role-based authentication system.
|
||||
# IMPORTANT: Leave both values empty to disable authentication on admin
|
||||
# only where an external authentication is already setup.
|
||||
admin_username = "listmonk"
|
||||
admin_password = "listmonk"
|
||||
|
||||
# Database.
|
||||
[db]
|
||||
host = "localhost"
|
||||
|
|
|
@ -15,7 +15,7 @@ x-app-defaults: &app-defaults
|
|||
- TZ=Etc/UTC
|
||||
|
||||
x-db-defaults: &db-defaults
|
||||
image: postgres:13-alpine
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "9432:5432"
|
||||
networks:
|
||||
|
|
|
@ -21,7 +21,7 @@ Retrieve lists.
|
|||
|:---------|:---------|:---------|:-----------------------------------------------------------------|
|
||||
| query | string | | string for list name search. |
|
||||
| status | []string | | Status to filter lists. Repeat in the query for multiple values. |
|
||||
| tags | []string | | Tags to filter lists. Repeat in the query for multiple values. |
|
||||
| tag | []string | | Tags to filter lists. Repeat in the query for multiple values. |
|
||||
| order_by | string | | Sort field. Options: name, status, created_at, updated_at. |
|
||||
| order | string | | Sorting order. Options: ASC, DESC. |
|
||||
| page | number | | Page number for pagination. |
|
||||
|
|
|
@ -13,8 +13,6 @@ Example:
|
|||
| **Environment variable** | Example value |
|
||||
| ------------------------------ | -------------- |
|
||||
| `LISTMONK_app__address` | "0.0.0.0:9000" |
|
||||
| `LISTMONK_app__admin_username` | listmonk |
|
||||
| `LISTMONK_app__admin_password` | listmonk |
|
||||
| `LISTMONK_db__host` | db |
|
||||
| `LISTMONK_db__port` | 9432 |
|
||||
| `LISTMONK_db__user` | listmonk |
|
||||
|
@ -141,8 +139,8 @@ with any Timezone listed [here](https://en.wikipedia.org/wiki/List_of_tz_databas
|
|||
### Retries
|
||||
The `Settings -> SMTP -> Retries` denotes the number of times a message that fails at the moment of sending is retried silently using different connections from the SMTP pool. The messages that fail even after retries are the ones that are logged as errors and ignored.
|
||||
|
||||
### Blocked Ports
|
||||
Some server hosts block SMTP ports (25, 465) so you have to get request to unblock them i.e. [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server).
|
||||
## SMTP ports
|
||||
Some server hosts block outgoing SMTP ports (25, 465). You may have to contact your host to unblock them before being able to send e-mails. Eg: [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server).
|
||||
|
||||
|
||||
## Performance
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[![listmonk](images/logo.svg)](https://listmonk.app)
|
||||
|
||||
listmonk is a self-hosted, high performance mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database.
|
||||
listmonk is a self-hosted, high performance one-way mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database.
|
||||
|
||||
[![listmonk screenshot](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app)
|
||||
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
# Installation
|
||||
|
||||
listmonk requires Postgres ⩾ 12.
|
||||
listmonk requires Postgres ⩾ 12
|
||||
|
||||
See the "[Tutorials](#tutorials)" section at the bottom for detailed guides.
|
||||
!!! Admin
|
||||
listmonk generates and prints admin credentials to the terminal during installation. This can be copied to login to the admin dashboard and later changed. To choose a custom username and password during installation,
|
||||
set the environment variables `LISTMONK_ADMIN_USER` and `LISTMONK_ADMIN_PASSWORD` during installation.
|
||||
|
||||
## Binary
|
||||
- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. `amd64` is the main one. It works for Intel and x86 CPUs.
|
||||
- `./listmonk --new-config` to generate config.toml. Then, edit the file.
|
||||
- `./listmonk --install` to install the tables in the Postgres DB.
|
||||
- Run `./listmonk` and visit `http://localhost:9000`.
|
||||
1. Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. `amd64` is the main one. It works for Intel and x86 CPUs.
|
||||
1. `./listmonk --new-config` to generate config.toml. Edit the file.
|
||||
1. `./listmonk --install` to install the tables in the Postgres DB. Copy the admin username and password from the terminal output (these can be changed from the admin UI later). To choose a custom username and password during installation, run: `LISTMONK_ADMIN_USER=myuser LISTMONK_ADMIN_PASSWORD=xxxxx ./listmonk --install`
|
||||
1. Run `./listmonk` and visit `http://localhost:9000`.
|
||||
|
||||
|
||||
## Docker
|
||||
|
@ -16,7 +18,7 @@ See the "[Tutorials](#tutorials)" section at the bottom for detailed guides.
|
|||
The latest image is available on DockerHub at `listmonk/listmonk:latest`
|
||||
|
||||
!!! note
|
||||
Listmonk's docs and scripts use `docker compose`, which is compatible with the latest version of docker. If you installed docker and docker-compose from your Linux distribution, you probably have an older version and will need to use the `docker-compose` command instead, or you'll need to update docker manually. [More info](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11#docker-update).
|
||||
listmonk's docs and scripts use `docker compose`, which is compatible with the latest version of docker. If you installed docker and docker-compose from your Linux distribution, you probably have an older version and will need to use the `docker-compose` command instead, or you'll need to update docker manually. [More info](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11#docker-update).
|
||||
|
||||
Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with `docker compose` as follows:
|
||||
|
||||
|
@ -61,7 +63,7 @@ The above shell script performs the following actions:
|
|||
|
||||
#### Manual Docker install
|
||||
|
||||
The following workflow is recommended to setup `listmonk` manually using `docker compose`. You are encouraged to customise the contents of `docker-compose.yml` to your needs. The overall setup looks like:
|
||||
The following workflow is recommended to setup `listmonk` manually using `docker compose`. You are encouraged to customise the contents of [`docker-compose.yml`](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to your needs. The overall setup looks like:
|
||||
|
||||
- `docker compose up db` to run the Postgres DB.
|
||||
- `docker compose run --rm app ./listmonk --install` to setup the DB (or `--upgrade` to upgrade an existing DB).
|
||||
|
@ -94,8 +96,6 @@ Here's a sample `config.toml` you can use:
|
|||
```toml
|
||||
[app]
|
||||
address = "0.0.0.0:9000"
|
||||
admin_username = "listmonk"
|
||||
admin_password = "listmonk"
|
||||
|
||||
# Database.
|
||||
[db]
|
||||
|
@ -177,9 +177,9 @@ $ helm upgrade \
|
|||
|
||||
## Tutorials
|
||||
|
||||
* [Informal step-by-step on how to get started with Listmonk using *Railway*](https://github.com/knadh/listmonk/issues/120#issuecomment-1421838533)
|
||||
* [Informal step-by-step on how to get started with listmonk using *Railway*](https://github.com/knadh/listmonk/issues/120#issuecomment-1421838533)
|
||||
* [Step-by-step tutorial for installation and all basic functions. *Amazon EC2, SES, docker & binary*](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11)
|
||||
* [Step-by-step guide on how to install and set up Listmonk on *AWS Lightsail with docker* (rameerez)](https://github.com/knadh/listmonk/issues/1208)
|
||||
* [Step-by-step guide on how to install and set up listmonk on *AWS Lightsail with docker* (rameerez)](https://github.com/knadh/listmonk/issues/1208)
|
||||
* [Quick setup on any cloud server using *docker and caddy*](https://github.com/samyogdhital/listmonk-caddy-reverse-proxy)
|
||||
* [*Binary* install on Ubuntu 22.04 as a service](https://mumaritc.hashnode.dev/how-to-install-listmonk-using-binary-on-ubuntu-2204)
|
||||
* [*Binary* install on Ubuntu 18.04 as a service (Apache & Plesk)](https://devgypsy.com/post/2020-08-18-installing-listmonk-newsletter-manager/)
|
||||
|
|
|
@ -58,3 +58,14 @@ x-app-defaults: &app-defaults
|
|||
4. Restart:
|
||||
`sudo docker compose up -d app db nginx certbot`
|
||||
|
||||
|
||||
## Upgrading to v4.x.x
|
||||
v4 is a major upgrade from prior versions with significant changes to certain important features and behaviour. It is the first version to have multi-user support and full fledged user management. Prior versions only had a simple BasicAuth for both admin login (browser prompt) and API calls, with the username and password defined in the TOML configuration file.
|
||||
|
||||
It is safe to upgrade an older installation with `--upgrade`, but there are a few important things to keep in mind. The upgrade automatically imports the `admin_username` and `admin_password` defined in the TOML configuration into the new user management system.
|
||||
|
||||
1. **New login UI**: Once you upgrade an older installation, the admin dashboard will no longer show the native browser prompt for login. Instead, a new login UI rendered by listmonk is displayed at the URI `/admin/login`.
|
||||
|
||||
1. **API credentials**: If you are using APIs to interact with listmonk, after logging in, go to Settings -> Users and create a new API user with the necessary permissions. Change existing API integrations to use these credentials instead of the old username and password defined in the legacy TOML configuration file or environment variables.
|
||||
|
||||
1. **Credentials in TOML file or old environment variables**: The admin dashboard shows a warning until the `admin_username` and `admin_password` fields are removed from the configuration file or old environment variables. In v4.x.x, these are irrelevant as user credentials are stored in the database and managed from the admin UI. IMPORTANT: if you are using APIs to interact with listmonk, follow the previous step before removing the legacy credentials.
|
||||
|
|
|
@ -44,7 +44,7 @@ nav:
|
|||
- "Installation": installation.md
|
||||
- "Configuration": configuration.md
|
||||
- "Upgrade": upgrade.md
|
||||
- "Using Listmonk":
|
||||
- "Using listmonk":
|
||||
- "Concepts": concepts.md
|
||||
- "Templating": templating.md
|
||||
- "Querying and segmenting subscribers": querying-and-segmentation.md
|
||||
|
|
|
@ -95,7 +95,7 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/inst
|
|||
<img class="box" src="static/images/lists.png" alt="Screenshot of list management feature" />
|
||||
</div>
|
||||
<p>
|
||||
Manage millions of subscribers across many single and double opt-in lists
|
||||
Manage millions of subscribers across many single and double opt-in one-way mailing lists
|
||||
with custom JSON attributes for each subscriber.
|
||||
Query and segment subscribers with SQL expressions.
|
||||
</p>
|
||||
|
|
1
frontend/.eslintrc.js
vendored
1
frontend/.eslintrc.js
vendored
|
@ -21,6 +21,7 @@ module.exports = {
|
|||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/html-closing-bracket-newline': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/max-len': ['error', {
|
||||
code: 200,
|
||||
template: 200,
|
||||
|
|
|
@ -594,6 +594,26 @@
|
|||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "8f28d948aa6379b1a69d2a090e7531d4",
|
||||
"css": "warning-empty",
|
||||
"code": 59431,
|
||||
"src": "typicons"
|
||||
},
|
||||
{
|
||||
"uid": "77025195d19e048302e8943e2da4cc75",
|
||||
"css": "account-outline",
|
||||
"code": 983059,
|
||||
"src": "custom_icons",
|
||||
"selected": true,
|
||||
"svg": {
|
||||
"path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"account-outline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
|
||||
"css": "vector-square",
|
||||
|
@ -832,20 +852,6 @@
|
|||
"account-off"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "77025195d19e048302e8943e2da4cc75",
|
||||
"css": "account-outline",
|
||||
"code": 983059,
|
||||
"src": "custom_icons",
|
||||
"selected": false,
|
||||
"svg": {
|
||||
"path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"account-outline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "571120b7ff63feb71df85710d019302c",
|
||||
"css": "account-plus",
|
||||
|
|
|
@ -12,9 +12,29 @@
|
|||
<template #end>
|
||||
<navigation v-if="isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
|
||||
@toggleGroup="toggleGroup" @doLogout="doLogout" />
|
||||
<b-navbar-item v-else tag="div">
|
||||
<a href="#" @click.prevent="doLogout">{{ $t('users.logout') }}</a>
|
||||
</b-navbar-item>
|
||||
|
||||
<b-navbar-dropdown class="user" tag="div" right v-else>
|
||||
<template v-if="profile.username" #label>
|
||||
<span class="user-avatar">
|
||||
<img v-if="profile.avatar" :src="profile.avatar" alt="" />
|
||||
<span v-else>{{ profile.username[0].toUpperCase() }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<b-navbar-item class="user-name" tag="router-link" to="/user/profile">
|
||||
<strong>{{ profile.username }}</strong>
|
||||
<div class="is-size-7">{{ profile.name }}</div>
|
||||
</b-navbar-item>
|
||||
|
||||
<b-navbar-item href="#">
|
||||
<router-link to="/user/profile">
|
||||
<b-icon icon="account-outline" /> {{ $t('users.profile') }}
|
||||
</router-link>
|
||||
</b-navbar-item>
|
||||
<b-navbar-item href="#">
|
||||
<a href="#" @click.prevent="doLogout"><b-icon icon="logout-variant" /> {{ $t('users.logout') }}</a>
|
||||
</b-navbar-item>
|
||||
</b-navbar-dropdown>
|
||||
</template>
|
||||
</b-navbar>
|
||||
|
||||
|
@ -33,7 +53,8 @@
|
|||
|
||||
<!-- body //-->
|
||||
<div class="main">
|
||||
<div class="global-notices" v-if="serverConfig.needs_restart || serverConfig.update">
|
||||
<div class="global-notices"
|
||||
v-if="serverConfig.needs_restart || serverConfig.update || serverConfig.has_legacy_user">
|
||||
<div v-if="serverConfig.needs_restart" class="notification is-danger">
|
||||
{{ $t('settings.needsRestart') }}
|
||||
—
|
||||
|
@ -42,9 +63,35 @@
|
|||
{{ $t('settings.restart') }}
|
||||
</b-button>
|
||||
</div>
|
||||
<div v-if="serverConfig.update" class="notification is-success">
|
||||
{{ $t('settings.updateAvailable', { version: serverConfig.update.version }) }}
|
||||
<a :href="serverConfig.update.url" target="_blank" rel="noopener noreferer">View</a>
|
||||
|
||||
<template v-if="serverConfig.update">
|
||||
<div v-if="serverConfig.update.update.is_new" class="notification is-success">
|
||||
{{ $t('settings.updateAvailable', {
|
||||
version: `${serverConfig.update.update.release_version}
|
||||
(${$utils.getDate(serverConfig.update.update.release_date).format('DD MMM YY')})`,
|
||||
}) }}
|
||||
<a :href="serverConfig.update.update.url" target="_blank" rel="noopener noreferer">View</a>
|
||||
</div>
|
||||
|
||||
<template v-if="serverConfig.update.messages && serverConfig.update.messages.length > 0">
|
||||
<div v-for="m in serverConfig.update.messages" class="notification"
|
||||
:class="{ [m.priority === 'high' ? 'is-danger' : 'is-info']: true }" :key="m.title">
|
||||
<h3 class="is-size-5" v-if="m.title"><strong>{{ m.title }}</strong></h3>
|
||||
<p v-if="m.description">{{ m.description }}</p>
|
||||
<a v-if="m.url" :href="m.url" target="_blank" rel="noopener noreferer">View</a>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="serverConfig.has_legacy_user" class="notification is-danger">
|
||||
<b-icon icon="warning-empty" />
|
||||
Remove the <code>admin_username</code> and <code>admin_password</code> fields from the TOML
|
||||
configuration file or environment variables. If you are using APIs, create and use new API credentials
|
||||
before removing the them. Visit
|
||||
<router-link :to="{ name: 'users' }">
|
||||
Admin -> Settings -> Users
|
||||
</router-link> dashboard. <a href="https://listmonk.app/docs/upgrade/#upgrading-to-v4xx" target="_blank"
|
||||
rel="noopener noreferer">Learn more.</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -114,17 +161,9 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
doLogout() {
|
||||
const http = new XMLHttpRequest();
|
||||
|
||||
const u = uris.root.substr(-1) === '/' ? uris.root : `${uris.root}/`;
|
||||
http.open('get', `${u}api/logout`, false, 'logout_non_user', 'logout_non_user');
|
||||
http.onload = () => {
|
||||
this.$api.logout().then(() => {
|
||||
document.location.href = uris.root;
|
||||
};
|
||||
http.onerror = () => {
|
||||
document.location.href = uris.root;
|
||||
};
|
||||
http.send();
|
||||
});
|
||||
},
|
||||
|
||||
listenEvents() {
|
||||
|
@ -147,7 +186,7 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['serverConfig']),
|
||||
...mapState(['serverConfig', 'profile']),
|
||||
|
||||
version() {
|
||||
return import.meta.env.VUE_APP_VERSION;
|
||||
|
|
|
@ -422,9 +422,7 @@ export const getLang = async (lang) => http.get(
|
|||
{ loading: models.lang, camelCase: false },
|
||||
);
|
||||
|
||||
export const logout = async () => http.get('/api/logout', {
|
||||
auth: { username: 'wrong', password: 'wrong' },
|
||||
});
|
||||
export const logout = async () => http.post('/api/logout');
|
||||
|
||||
export const deleteGCCampaignAnalytics = async (typ, beforeDate) => http.delete(
|
||||
`/api/maintenance/analytics/${typ}`,
|
||||
|
@ -440,3 +438,92 @@ export const deleteGCSubscriptions = async (beforeDate) => http.delete(
|
|||
'/api/maintenance/subscriptions/unconfirmed',
|
||||
{ loading: models.maintenance, params: { before_date: beforeDate } },
|
||||
);
|
||||
|
||||
// Users.
|
||||
export const getUsers = () => http.get(
|
||||
'/api/users',
|
||||
{
|
||||
loading: models.users,
|
||||
store: models.users,
|
||||
},
|
||||
);
|
||||
|
||||
export const queryUsers = () => http.get(
|
||||
'/api/users',
|
||||
{
|
||||
loading: models.users,
|
||||
store: models.users,
|
||||
},
|
||||
);
|
||||
|
||||
export const getUser = async (id) => http.get(
|
||||
`/api/users/${id}`,
|
||||
{ loading: models.users },
|
||||
);
|
||||
|
||||
export const createUser = (data) => http.post(
|
||||
'/api/users',
|
||||
data,
|
||||
{ loading: models.users },
|
||||
);
|
||||
|
||||
export const updateUser = (data) => http.put(
|
||||
`/api/users/${data.id}`,
|
||||
data,
|
||||
{ loading: models.users },
|
||||
);
|
||||
|
||||
export const deleteUser = (id) => http.delete(
|
||||
`/api/users/${id}`,
|
||||
{ loading: models.users },
|
||||
);
|
||||
|
||||
export const getUserProfile = () => http.get(
|
||||
'/api/profile',
|
||||
{ loading: models.users, store: models.profile },
|
||||
);
|
||||
|
||||
export const updateUserProfile = (data) => http.put(
|
||||
'/api/profile',
|
||||
data,
|
||||
{ loading: models.users, store: models.profile },
|
||||
);
|
||||
|
||||
export const getUserRoles = async () => http.get(
|
||||
'/api/roles/users',
|
||||
{ loading: models.userRoles, store: models.userRoles },
|
||||
);
|
||||
|
||||
export const getListRoles = async () => http.get(
|
||||
'/api/roles/lists',
|
||||
{ loading: models.listRoles, store: models.listRoles },
|
||||
);
|
||||
|
||||
export const createUserRole = (data) => http.post(
|
||||
'/api/roles/users',
|
||||
data,
|
||||
{ loading: models.userRoles },
|
||||
);
|
||||
|
||||
export const createListRole = (data) => http.post(
|
||||
'/api/roles/lists',
|
||||
data,
|
||||
{ loading: models.listRoles },
|
||||
);
|
||||
|
||||
export const updateUserRole = (data) => http.put(
|
||||
`/api/roles/users/${data.id}`,
|
||||
data,
|
||||
{ loading: models.userRoles },
|
||||
);
|
||||
|
||||
export const updateListRole = (data) => http.put(
|
||||
`/api/roles/lists/${data.id}`,
|
||||
data,
|
||||
{ loading: models.userRoles },
|
||||
);
|
||||
|
||||
export const deleteRole = (id) => http.delete(
|
||||
`/api/roles/${id}`,
|
||||
{ loading: models.userRoles },
|
||||
);
|
||||
|
|
39
frontend/src/assets/icons/fontello.css
vendored
39
frontend/src/assets/icons/fontello.css
vendored
|
@ -40,6 +40,41 @@
|
|||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
[class^="mdi-"]:before, [class*=" mdi-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: never;
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: .2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
/* Animation center compensation - margins should be symmetric */
|
||||
/* remove if not needed */
|
||||
margin-left: .2em;
|
||||
|
||||
/* you can be more comfortable with increased icons size */
|
||||
/* font-size: 120%; */
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Uncomment for 3D effect */
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
|
||||
.mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */
|
||||
.mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */
|
||||
|
@ -80,6 +115,8 @@
|
|||
.mdi-chart-bar:before { content: '\e824'; } /* '' */
|
||||
.mdi-email-bounce:before { content: '\e825'; } /* '' */
|
||||
.mdi-speedometer:before { content: '\e826'; } /* '' */
|
||||
.mdi-warning-empty:before { content: '\e827'; } /* '' */
|
||||
.mdi-account-outline:before { content: ''; } /* '\f0013' */
|
||||
.mdi-code:before { content: ''; } /* '\f0169' */
|
||||
.mdi-logout-variant:before { content: ''; } /* '\f05fd' */
|
||||
.mdi-wrench-outline:before { content: ''; } /* '\f0be0' */
|
||||
.mdi-code:before { content: ''; } /* '\f0169' */
|
||||
|
|
Binary file not shown.
|
@ -24,7 +24,7 @@ $body-size: 15px;
|
|||
$background: $white-bis;
|
||||
$body-background-color: $white-bis;
|
||||
$primary: #0055d4;
|
||||
$green: #0db35e;
|
||||
$green: #36995b;
|
||||
$turquoise: $green;
|
||||
$red: #FF5722;
|
||||
|
||||
|
@ -88,6 +88,10 @@ section {
|
|||
&.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
&.section-mini {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner.is-tiny {
|
||||
|
@ -131,6 +135,41 @@ section {
|
|||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.navbar-item.user {
|
||||
.navbar-link:not(.is-arrowless)::after {
|
||||
margin-top: -0.6rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: block;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
img {
|
||||
display: inline-block;
|
||||
border-radius: 100%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
max-height: none;
|
||||
}
|
||||
span {
|
||||
background-color: #ddd;
|
||||
border-radius: 100%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-text {
|
||||
color: inherit;
|
||||
.icon {
|
||||
|
@ -145,6 +184,11 @@ section {
|
|||
}
|
||||
}
|
||||
|
||||
.spaced-links a {
|
||||
margin-right: 15px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Two column sidebar+body layout */
|
||||
#app {
|
||||
min-height: 100%;
|
||||
|
@ -228,7 +272,7 @@ body.is-noscroll {
|
|||
}
|
||||
.notification {
|
||||
padding: 10px 15px;
|
||||
border-left: 5px solid #eee;
|
||||
border-left: 10px solid #eee;
|
||||
|
||||
&.is-danger {
|
||||
background: $white-ter;
|
||||
|
@ -240,6 +284,11 @@ body.is-noscroll {
|
|||
color: $black;
|
||||
border-left-color: $green;
|
||||
}
|
||||
&.is-info {
|
||||
background: $white-ter;
|
||||
border-left-color: $primary;
|
||||
color: $grey-dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* WYSIWYG / HTML code editor */
|
||||
|
@ -545,51 +594,42 @@ body.is-noscroll {
|
|||
|
||||
/* Tags */
|
||||
.tag {
|
||||
min-width: 85px;
|
||||
|
||||
border-radius: 30px !important;
|
||||
border: 0;
|
||||
padding: 0 20px !important;
|
||||
|
||||
&.is-small {
|
||||
font-size: 0.65rem;
|
||||
background: $white-ter;
|
||||
border: 1px solid $white-ter;
|
||||
// border: 1px solid $white-ter;
|
||||
padding: 3px 5px;
|
||||
min-width: auto !important;
|
||||
}
|
||||
|
||||
|
||||
&:not(body) {
|
||||
background-color: #eee;
|
||||
font-size: 0.85em;
|
||||
$color: $grey-lighter;
|
||||
border: 1px solid $color;
|
||||
box-shadow: 1px 1px 0 $color;
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
&.private, &.scheduled, &.paused, &.tx {
|
||||
&.private, &.scheduled, &.paused, &.tx, &.api {
|
||||
$color: #ed7b00;
|
||||
color: $color;
|
||||
background: #fff7e6;
|
||||
border: 1px solid lighten($color, 37%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 37%);
|
||||
background: lighten($color, 47);
|
||||
}
|
||||
&.public, &.running, &.list, &.campaign {
|
||||
&.public, &.running, &.list, &.campaign, &.user, &.primary {
|
||||
$color: $primary;
|
||||
color: lighten($color, 20%);;
|
||||
color: lighten($color, 20%);
|
||||
background: #e6f7ff;
|
||||
border: 1px solid lighten($color, 42%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 42%);
|
||||
}
|
||||
&.finished, &.enabled, &.status-confirmed {
|
||||
$color: $green;
|
||||
color: $color;
|
||||
background: #f6ffed;
|
||||
border: 1px solid lighten($color, 45%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 45%);
|
||||
color: $green;
|
||||
background: #dcfce7;
|
||||
}
|
||||
&.blocklisted, &.cancelled, &.status-unsubscribed {
|
||||
$color: $red;
|
||||
color: $color;
|
||||
background: #fff1f0;
|
||||
border: 1px solid lighten($color, 30%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 30%);
|
||||
}
|
||||
|
||||
sup {
|
||||
|
@ -693,6 +733,10 @@ section.lists {
|
|||
.toggle-advanced {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.blocklisted {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.b-table.subscriptions {
|
||||
|
@ -856,10 +900,6 @@ section.analytics {
|
|||
height: auto;
|
||||
min-height: 350px;
|
||||
}
|
||||
.smtp-shortcuts a {
|
||||
margin-right: 15px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
|
@ -891,6 +931,38 @@ section.analytics {
|
|||
}
|
||||
}
|
||||
|
||||
/* Users */
|
||||
section.users {
|
||||
td .tag {
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
.user-api-token .copy-text {
|
||||
background: rgba($green, .1);
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
padding: 15px;
|
||||
font-size: 1.2rem;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
.permissions-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
label {
|
||||
flex: 1 1 45%;
|
||||
max-width: 45%;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
th.role-toggle-select a {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* C3 charting lib */
|
||||
.c3 {
|
||||
.c3-text.c3-empty {
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
<b-taglist>
|
||||
<b-tag v-for="l in selectedItems" :key="l.id" :class="l.subscriptionStatus" :closable="!$props.disabled"
|
||||
:data-id="l.id" @close="removeList(l.id)" class="list">
|
||||
{{ l.name }} <sup v-if="l.optin === 'double'">{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}</sup>
|
||||
{{ l.name }}
|
||||
<sup v-if="l.optin === 'double' && l.subscriptionStatus">
|
||||
{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}
|
||||
</sup>
|
||||
</b-tag>
|
||||
</b-taglist>
|
||||
</div>
|
||||
|
|
|
@ -12,40 +12,56 @@
|
|||
icon="newspaper-variant-outline" :label="$t('menu.forms')" />
|
||||
</b-menu-item><!-- lists -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.subscribers" :active="activeGroup.subscribers" data-cy="subscribers"
|
||||
@update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
|
||||
<b-menu-item v-if="$can('subscribers:*')" :expanded="activeGroup.subscribers" :active="activeGroup.subscribers"
|
||||
data-cy="subscribers" @update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
|
||||
:label="$t('globals.terms.subscribers')">
|
||||
<b-menu-item :to="{ name: 'subscribers' }" tag="router-link" :active="activeItem.subscribers"
|
||||
data-cy="all-subscribers" icon="account-multiple" :label="$t('menu.allSubscribers')" />
|
||||
<b-menu-item :to="{ name: 'import' }" tag="router-link" :active="activeItem.import" data-cy="import"
|
||||
icon="file-upload-outline" :label="$t('menu.import')" />
|
||||
<b-menu-item :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces" data-cy="bounces"
|
||||
icon="email-bounce" :label="$t('globals.terms.bounces')" />
|
||||
<b-menu-item v-if="$can('subscribers:get_all', 'subscribers:get')" :to="{ name: 'subscribers' }" tag="router-link"
|
||||
:active="activeItem.subscribers" data-cy="all-subscribers" icon="account-multiple"
|
||||
:label="$t('menu.allSubscribers')" />
|
||||
<b-menu-item v-if="$can('subscribers:import')" :to="{ name: 'import' }" tag="router-link"
|
||||
:active="activeItem.import" data-cy="import" icon="file-upload-outline" :label="$t('menu.import')" />
|
||||
<b-menu-item v-if="$can('bounces:get')" :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces"
|
||||
data-cy="bounces" icon="email-bounce" :label="$t('globals.terms.bounces')" />
|
||||
</b-menu-item><!-- subscribers -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.campaigns" :active="activeGroup.campaigns" data-cy="campaigns"
|
||||
@update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
|
||||
<b-menu-item v-if="$can('campaigns:*')" :expanded="activeGroup.campaigns" :active="activeGroup.campaigns"
|
||||
data-cy="campaigns" @update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
|
||||
:label="$t('globals.terms.campaigns')">
|
||||
<b-menu-item :to="{ name: 'campaigns' }" tag="router-link" :active="activeItem.campaigns" data-cy="all-campaigns"
|
||||
icon="rocket-launch-outline" :label="$t('menu.allCampaigns')" />
|
||||
<b-menu-item :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" :active="activeItem.campaign"
|
||||
data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
|
||||
<b-menu-item :to="{ name: 'media' }" tag="router-link" :active="activeItem.media" data-cy="media"
|
||||
icon="image-outline" :label="$t('menu.media')" />
|
||||
<b-menu-item :to="{ name: 'templates' }" tag="router-link" :active="activeItem.templates" data-cy="templates"
|
||||
icon="file-image-outline" :label="$t('globals.terms.templates')" />
|
||||
<b-menu-item :to="{ name: 'campaignAnalytics' }" tag="router-link" :active="activeItem.campaignAnalytics"
|
||||
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" />
|
||||
<b-menu-item v-if="$can('campaigns:get')" :to="{ name: 'campaigns' }" tag="router-link"
|
||||
:active="activeItem.campaigns" data-cy="all-campaigns" icon="rocket-launch-outline"
|
||||
:label="$t('menu.allCampaigns')" />
|
||||
<b-menu-item v-if="$can('campaigns:manage')" :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link"
|
||||
:active="activeItem.campaign" data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
|
||||
<b-menu-item v-if="$can('media:*')" :to="{ name: 'media' }" tag="router-link" :active="activeItem.media"
|
||||
data-cy="media" icon="image-outline" :label="$t('menu.media')" />
|
||||
<b-menu-item v-if="$can('templates:get')" :to="{ name: 'templates' }" tag="router-link"
|
||||
:active="activeItem.templates" data-cy="templates" icon="file-image-outline"
|
||||
:label="$t('globals.terms.templates')" />
|
||||
<b-menu-item v-if="$can('campaigns:get_analytics')" :to="{ name: 'campaignAnalytics' }" tag="router-link"
|
||||
:active="activeItem.campaignAnalytics" data-cy="analytics" icon="chart-bar"
|
||||
:label="$t('globals.terms.analytics')" />
|
||||
</b-menu-item><!-- campaigns -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings" data-cy="settings"
|
||||
@update:active="(state) => toggleGroup('settings', state)" icon="cog-outline" :label="$t('menu.settings')">
|
||||
<b-menu-item :to="{ name: 'settings' }" tag="router-link" :active="activeItem.settings" data-cy="all-settings"
|
||||
icon="cog-outline" :label="$t('menu.settings')" />
|
||||
<b-menu-item :to="{ name: 'maintenance' }" tag="router-link" :active="activeItem.maintenance" data-cy="maintenance"
|
||||
icon="wrench-outline" :label="$t('menu.maintenance')" />
|
||||
<b-menu-item :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs" data-cy="logs"
|
||||
icon="newspaper-variant-outline" :label="$t('menu.logs')" />
|
||||
<b-menu-item v-if="$can('users:*', 'roles:*')" :expanded="activeGroup.users" :active="activeGroup.users"
|
||||
data-cy="users" @update:active="(state) => toggleGroup('users', state)" icon="account-multiple"
|
||||
:label="$t('globals.terms.users')">
|
||||
<b-menu-item v-if="$can('users:get')" :to="{ name: 'users' }" tag="router-link" :active="activeItem.users"
|
||||
data-cy="users" icon="account-multiple" :label="$t('globals.terms.users')" />
|
||||
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'userRoles' }" tag="router-link" :active="activeItem.userRoles"
|
||||
data-cy="userRoles" icon="newspaper-variant-outline" :label="$t('users.userRoles')" />
|
||||
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'listRoles' }" tag="router-link" :active="activeItem.listRoles"
|
||||
data-cy="listRoles" icon="format-list-bulleted-square" :label="$t('users.listRoles')" />
|
||||
</b-menu-item><!-- users -->
|
||||
|
||||
<b-menu-item v-if="$can('settings:*')" :expanded="activeGroup.settings" :active="activeGroup.settings"
|
||||
data-cy="settings" @update:active="(state) => toggleGroup('settings', state)" icon="cog-outline"
|
||||
:label="$t('menu.settings')">
|
||||
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'settings' }" tag="router-link"
|
||||
:active="activeItem.settings" data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')" />
|
||||
<b-menu-item v-if="$can('settings:maintain')" :to="{ name: 'maintenance' }" tag="router-link"
|
||||
:active="activeItem.maintenance" data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" />
|
||||
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs"
|
||||
data-cy="logs" icon="format-list-bulleted-square" :label="$t('menu.logs')" />
|
||||
</b-menu-item><!-- settings -->
|
||||
|
||||
<b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')" @click.prevent="doLogout" />
|
||||
|
@ -53,6 +69,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'Navigation',
|
||||
|
||||
|
@ -72,6 +90,10 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['profile']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// A hack to close the open accordion burger menu items on click.
|
||||
// Buefy does not have a way to do this.
|
||||
|
|
|
@ -8,6 +8,10 @@ export const models = Object.freeze({
|
|||
templates: 'templates',
|
||||
media: 'media',
|
||||
bounces: 'bounces',
|
||||
users: 'users',
|
||||
profile: 'profile',
|
||||
userRoles: 'userRoles',
|
||||
listRoles: 'listRoles',
|
||||
settings: 'settings',
|
||||
logs: 'logs',
|
||||
maintenance: 'maintenance',
|
||||
|
|
|
@ -31,28 +31,45 @@ router.afterEach((to) => {
|
|||
});
|
||||
});
|
||||
|
||||
function initConfig(app) {
|
||||
// Load server side config and language before mounting the app.
|
||||
api.getServerConfig().then((data) => {
|
||||
api.getLang(data.lang).then((lang) => {
|
||||
i18n.locale = data.lang;
|
||||
i18n.setLocaleMessage(i18n.locale, lang);
|
||||
async function initConfig(app) {
|
||||
// Load logged in user profile, server side config, and the language file before mounting the app.
|
||||
const [profile, cfg] = await Promise.all([api.getUserProfile(), api.getServerConfig()]);
|
||||
|
||||
Vue.prototype.$utils = new Utils(i18n);
|
||||
Vue.prototype.$api = api;
|
||||
const lang = await api.getLang(cfg.lang);
|
||||
i18n.locale = cfg.lang;
|
||||
i18n.setLocaleMessage(i18n.locale, lang);
|
||||
|
||||
// Set the page title after i18n has loaded.
|
||||
const to = router.history.current;
|
||||
const t = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
|
||||
document.title = `${t} listmonk`;
|
||||
Vue.prototype.$utils = new Utils(i18n);
|
||||
Vue.prototype.$api = api;
|
||||
|
||||
if (app) {
|
||||
app.$mount('#app');
|
||||
// $can('permission:name') is used in the UI to chekc whether the logged in user
|
||||
// has a certain permission to toggle visibility of UI objects and UI functionality.
|
||||
Vue.prototype.$can = (...perms) => {
|
||||
if (profile.userRole.id === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the perm ends with a wildcard, check whether at least one permission
|
||||
// in the group is present. Eg: campaigns:* will return true if at least
|
||||
// one of campaigns:get, campaigns:manage etc. are present.
|
||||
return perms.some((perm) => {
|
||||
if (perm.endsWith('*')) {
|
||||
const group = `${perm.split(':')[0]}:`;
|
||||
return profile.userRole.permissions.some((p) => p.startsWith(group));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
api.getSettings();
|
||||
return profile.userRole.permissions.includes(perm);
|
||||
});
|
||||
};
|
||||
|
||||
// Set the page title after i18n has loaded.
|
||||
const to = router.history.current;
|
||||
const title = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
|
||||
document.title = `${title} listmonk`;
|
||||
|
||||
if (app) {
|
||||
app.$mount('#app');
|
||||
}
|
||||
}
|
||||
|
||||
const v = new Vue({
|
||||
|
|
|
@ -95,6 +95,12 @@ const routes = [
|
|||
meta: { title: 'globals.terms.campaign', group: 'campaigns' },
|
||||
component: () => import('../views/Campaign.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user/profile',
|
||||
name: 'userProfile',
|
||||
meta: { title: 'users.profile', group: 'settings' },
|
||||
component: () => import('../views/UserProfile.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
|
@ -107,6 +113,24 @@ const routes = [
|
|||
meta: { title: 'logs.title', group: 'settings' },
|
||||
component: () => import('../views/Logs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
meta: { title: 'globals.terms.users', group: 'users' },
|
||||
component: () => import('../views/Users.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users/roles/users',
|
||||
name: 'userRoles',
|
||||
meta: { title: 'users.userRoles', group: 'users' },
|
||||
component: () => import('../views/Roles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users/roles/lists',
|
||||
name: 'listRoles',
|
||||
meta: { title: 'users.listRoles', group: 'users' },
|
||||
component: () => import('../views/Roles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings/maintenance',
|
||||
name: 'maintenance',
|
||||
|
|
|
@ -41,6 +41,10 @@ export default new Vuex.Store({
|
|||
[models.campaigns]: (state) => state[models.campaigns],
|
||||
[models.media]: (state) => state[models.media],
|
||||
[models.templates]: (state) => state[models.templates],
|
||||
[models.users]: (state) => state[models.users],
|
||||
[models.profile]: (state) => state[models.profile],
|
||||
[models.userRoles]: (state) => state[models.userRoles],
|
||||
[models.listRoles]: (state) => state[models.listRoles],
|
||||
[models.settings]: (state) => state[models.settings],
|
||||
[models.serverConfig]: (state) => state[models.serverConfig],
|
||||
[models.logs]: (state) => state[models.logs],
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="buttons">
|
||||
<div v-if="$can('campaigns:manage')" class="buttons">
|
||||
<b-field grouped v-if="isEditing && canEdit">
|
||||
<b-field expanded>
|
||||
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
|
||||
|
@ -113,7 +113,8 @@
|
|||
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
|
||||
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit"
|
||||
:placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
|
||||
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime" horizontal-time-picker />
|
||||
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime"
|
||||
horizontal-time-picker />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -140,7 +141,7 @@
|
|||
</b-field>
|
||||
</form>
|
||||
</div>
|
||||
<div class="column is-4 is-offset-1">
|
||||
<div v-if="$can('campaigns:manage')" class="column is-4 is-offset-1">
|
||||
<br />
|
||||
<div class="box">
|
||||
<h3 class="title is-size-6">
|
||||
|
@ -175,14 +176,15 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border" expanded
|
||||
data-cy="media">
|
||||
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border"
|
||||
expanded data-cy="media">
|
||||
<b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline" ref="media" field="filename"
|
||||
@focus="onOpenAttach" :disabled="!canEdit" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<a href="https://listmonk.app/docs/templating/#template-expressions" target="_blank" rel="noopener noreferer">
|
||||
<a href="https://listmonk.app/docs/templating/#template-expressions" target="_blank"
|
||||
rel="noopener noreferer">
|
||||
<b-icon icon="code" /> {{ $t('campaigns.templatingRef') }}</a>
|
||||
<span v-if="canEdit && form.content.contentType !== 'plain'" class="is-size-6 has-text-grey ml-6">
|
||||
<a v-if="form.altbody === null" href="#" @click.prevent="onAddAltBody">
|
||||
|
@ -212,7 +214,7 @@
|
|||
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
|
||||
<a :href="`${serverConfig.root_url}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
|
||||
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
|
||||
<b-icon icon="link-variant" />
|
||||
</a>
|
||||
|
@ -245,8 +247,8 @@
|
|||
</div>
|
||||
|
||||
<div class="column has-text-right">
|
||||
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary" href="#"
|
||||
@click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
|
||||
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary"
|
||||
href="#" @click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
|
||||
</div>
|
||||
</div>
|
||||
<b-field>
|
||||
|
@ -596,7 +598,7 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['settings', 'loading', 'lists', 'templates']),
|
||||
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
|
||||
|
||||
canEdit() {
|
||||
return this.isNew
|
||||
|
@ -624,7 +626,7 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
messengers() {
|
||||
return ['email', ...this.settings.messengers.map((m) => m.name)];
|
||||
return ['email', ...this.serverConfig.messengers.map((m) => m.name)];
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -646,7 +648,7 @@ export default Vue.extend({
|
|||
window.onbeforeunload = () => this.isUnsaved() || null;
|
||||
|
||||
// Fill default form fields.
|
||||
this.form.fromEmail = this.settings['app.from_email'];
|
||||
this.form.fromEmail = this.serverConfig.from_email;
|
||||
|
||||
// New campaign.
|
||||
const { id } = this.$route.params;
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field expanded>
|
||||
<b-field v-if="$can('campaigns:manage')" expanded>
|
||||
<b-button expanded :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" class="btn-new"
|
||||
type="is-primary" icon-left="plus" data-cy="btn-new">
|
||||
{{ $t('globals.buttons.new') }}
|
||||
|
@ -170,51 +170,56 @@
|
|||
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
|
||||
<div>
|
||||
<!-- start / pause / resume / scheduled -->
|
||||
<a v-if="canStart(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start"
|
||||
:aria-label="$t('campaigns.start')">
|
||||
<b-tooltip :label="$t('campaigns.start')" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-if="canPause(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
|
||||
:aria-label="$t('campaigns.pause')">
|
||||
<b-tooltip :label="$t('campaigns.pause')" type="is-dark">
|
||||
<b-icon icon="pause-circle-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-if="canResume(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-resume"
|
||||
:aria-label="$t('campaigns.send')">
|
||||
<b-tooltip :label="$t('campaigns.send')" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-if="canSchedule(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'), () => changeCampaignStatus(props.row, 'scheduled'))"
|
||||
data-cy="btn-schedule" :aria-label="$t('campaigns.schedule')">
|
||||
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
|
||||
<b-icon icon="clock-start" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<template v-if="$can('campaigns:manage')">
|
||||
<a v-if="canStart(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
|
||||
data-cy="btn-start" :aria-label="$t('campaigns.start')">
|
||||
<b-tooltip :label="$t('campaigns.start')" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<!-- placeholder for finished campaigns -->
|
||||
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled
|
||||
aria-label=" ">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</a>
|
||||
<a v-if="canPause(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
|
||||
:aria-label="$t('campaigns.pause')">
|
||||
<b-tooltip :label="$t('campaigns.pause')" type="is-dark">
|
||||
<b-icon icon="pause-circle-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<a v-if="canCancel(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))"
|
||||
data-cy="btn-cancel" :aria-label="$t('globals.buttons.cancel')">
|
||||
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
|
||||
<a v-if="canResume(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
|
||||
data-cy="btn-resume" :aria-label="$t('campaigns.send')">
|
||||
<b-tooltip :label="$t('campaigns.send')" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<a v-if="canSchedule(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'), () => changeCampaignStatus(props.row, 'scheduled'))"
|
||||
data-cy="btn-schedule" :aria-label="$t('campaigns.schedule')">
|
||||
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
|
||||
<b-icon icon="clock-start" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<!-- placeholder for finished campaigns -->
|
||||
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled
|
||||
aria-label=" ">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</a>
|
||||
|
||||
<a v-if="canCancel(props.row)" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))"
|
||||
data-cy="btn-cancel" :aria-label="$t('globals.buttons.cancel')">
|
||||
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
|
||||
<b-icon icon="cancel" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-else href="#" data-disabled aria-label=" ">
|
||||
<b-icon icon="cancel" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-else href="#" data-disabled aria-label=" ">
|
||||
<b-icon icon="cancel" size="is-small" />
|
||||
</a>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<a href="#" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview"
|
||||
:aria-label="$t('campaigns.preview')">
|
||||
|
@ -222,7 +227,7 @@
|
|||
<b-icon icon="file-find-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
|
||||
<a v-if="$can('campaigns:manage')" href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
|
||||
{
|
||||
placeholder: $t('globals.fields.name'),
|
||||
value: $t('campaigns.copyOf', { name: props.row.name }),
|
||||
|
@ -232,12 +237,13 @@
|
|||
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<router-link :to="{ name: 'campaignAnalytics', query: { id: props.row.id } }">
|
||||
<router-link v-if="$can('campaigns:get_analytics')"
|
||||
:to="{ name: 'campaignAnalytics', query: { id: props.row.id } }">
|
||||
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
|
||||
<b-icon icon="chart-bar" size="is-small" />
|
||||
</b-tooltip>
|
||||
</router-link>
|
||||
<a href="#"
|
||||
<a v-if="$can('campaigns:manage')" href="#"
|
||||
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))"
|
||||
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
|
||||
<b-button v-if="canManage" native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
|
@ -123,7 +123,16 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading']),
|
||||
...mapState(['loading', 'profile']),
|
||||
|
||||
canManage() {
|
||||
if (this.$can('lists:manage_all')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const list = this.profile.userRole.lists.find((l) => l.id === this.$props.data.id);
|
||||
return list && list.permissions.includes('list:manage');
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field expanded>
|
||||
<b-field v-if="$can('lists:manage_all')" expanded>
|
||||
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
|
||||
{{ $t('globals.buttons.new') }}
|
||||
</b-button>
|
||||
|
@ -25,7 +25,8 @@
|
|||
<form @submit.prevent="getLists">
|
||||
<div>
|
||||
<b-field>
|
||||
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query" data-cy="query" />
|
||||
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
|
||||
data-cy="query" />
|
||||
<p class="controls">
|
||||
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
|
||||
</p>
|
||||
|
@ -78,10 +79,15 @@
|
|||
|
||||
<b-table-column v-slot="props" field="subscriber_count" :label="$t('globals.terms.subscribers')"
|
||||
header-class="cy-subscribers" numeric sortable centered>
|
||||
<router-link :to="`/subscribers/lists/${props.row.id}`">
|
||||
<template v-if="$can('subscribers:get_all', 'subscribers:get')">
|
||||
<router-link :to="`/subscribers/lists/${props.row.id}`">
|
||||
{{ $utils.formatNumber(props.row.subscriberCount) }}
|
||||
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $utils.formatNumber(props.row.subscriberCount) }}
|
||||
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="subscriber_counts" header-class="cy-subscribers" width="10%">
|
||||
|
@ -106,26 +112,28 @@
|
|||
|
||||
<b-table-column v-slot="props" cell-class="actions" align="right">
|
||||
<div>
|
||||
<router-link :to="`/campaigns/new?list_id=${props.row.id}`" data-cy="btn-campaign">
|
||||
<router-link v-if="$can('campaigns:manage')" :to="`/campaigns/new?list_id=${props.row.id}`"
|
||||
data-cy="btn-campaign">
|
||||
<b-tooltip :label="$t('lists.sendCampaign')" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</router-link>
|
||||
|
||||
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
|
||||
<a v-if="$can('lists:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
|
||||
:aria-label="$t('globals.buttons.edit')">
|
||||
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<router-link :to="{ name: 'import', query: { list_id: props.row.id } }" data-cy="btn-import">
|
||||
<router-link v-if="$can('lists:import')" :to="{ name: 'import', query: { list_id: props.row.id } }"
|
||||
data-cy="btn-import">
|
||||
<b-tooltip :label="$t('import.title')" type="is-dark">
|
||||
<b-icon icon="file-upload-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</router-link>
|
||||
|
||||
<a href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete"
|
||||
<a v-if="$can('lists:manage')" href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete"
|
||||
:aria-label="$t('globals.buttons.delete')">
|
||||
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
|
|
280
frontend/src/views/RoleForm.vue
Normal file
280
frontend/src/views/RoleForm.vue
Normal file
|
@ -0,0 +1,280 @@
|
|||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<p v-if="isEditing" class="has-text-grey-light is-size-7">
|
||||
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
|
||||
</p>
|
||||
<h4 v-if="isEditing">
|
||||
{{ data.name }}
|
||||
</h4>
|
||||
<h4 v-else>
|
||||
{{ type === 'user' ? $t('users.newUserRole') : $t('users.newListRole') }}
|
||||
</h4>
|
||||
</header>
|
||||
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input autofocus :disabled="disabled" :maxlength="200" v-model="form.name" name="name" ref="focus"
|
||||
required />
|
||||
</b-field>
|
||||
|
||||
<div v-if="type === 'list'" class="box">
|
||||
<h5>{{ $t('users.listPerms') }}</h5>
|
||||
<div class="mb-5">
|
||||
<div class="columns">
|
||||
<div class="column is-9">
|
||||
<b-select :placeholder="$tc('globals.terms.list')" v-model="form.curList" name="list"
|
||||
:disabled="disabled || filteredLists.length < 1" expanded class="mb-3">
|
||||
<template v-for="l in filteredLists">
|
||||
<option :value="l.id" :key="l.id">
|
||||
{{ l.name }}
|
||||
</option>
|
||||
</template>
|
||||
</b-select>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-button @click="onAddListPerm" :disabled="!form.curList" class="is-primary" expanded>
|
||||
{{ $t('globals.buttons.add') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="form.lists.length > 0 && (form.permissions['lists:get_all'] || form.permissions['lists:manage_all'])"
|
||||
class="is-size-6 has-text-danger">
|
||||
<b-icon icon="warning-empty" />
|
||||
{{ $t('users.listPermsWarning') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<b-table :data="form.lists">
|
||||
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list')">
|
||||
<router-link :to="`/lists/${props.row.id}`" target="_blank">
|
||||
{{ props.row.name }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="permissions" :label="$t('users.perms')" width="40%">
|
||||
<b-checkbox v-model="props.row.permissions" native-value="list:get">
|
||||
{{ $t('globals.buttons.view') }}
|
||||
</b-checkbox>
|
||||
<b-checkbox v-model="props.row.permissions" native-value="list:manage">
|
||||
{{ $t('globals.buttons.manage') }}
|
||||
</b-checkbox>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" width="10%">
|
||||
<a href="#" @click.prevent="onDeleteListPerm(props.row.id)" data-cy="btn-delete"
|
||||
:aria-label="$t('globals.buttons.delete')">
|
||||
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
</div>
|
||||
|
||||
<template v-if="type === 'user'">
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<h5 class="mb-0">
|
||||
{{ $t('users.perms') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="column has-text-right" v-if="!disabled">
|
||||
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-table :data="serverConfig.permissions">
|
||||
<b-table-column v-slot="props" field="group" :label="$t('users.roleGroup')">
|
||||
{{ $tc(`globals.terms.${props.row.group}`) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="permissions" label="Permissions">
|
||||
<div v-for="p in props.row.permissions" :key="p">
|
||||
<b-checkbox v-model="form.permissions" :native-value="p" :disabled="disabled">
|
||||
{{ p }}
|
||||
</b-checkbox>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
</template>
|
||||
<a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer">
|
||||
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button v-if="!disabled" native-type="submit" type="is-primary" :loading="loading.roles" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import CopyText from '../components/CopyText.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'RoleForm',
|
||||
|
||||
components: {
|
||||
CopyText,
|
||||
},
|
||||
|
||||
props: {
|
||||
data: { type: Object, default: () => ({}) },
|
||||
isEditing: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'user' },
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values.
|
||||
form: {
|
||||
curList: null,
|
||||
lists: [],
|
||||
name: null,
|
||||
permissions: {},
|
||||
},
|
||||
hasToggle: false,
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onAddListPerm() {
|
||||
const list = this.lists.results.find((l) => l.id === this.form.curList);
|
||||
this.form.lists.push({ id: list.id, name: list.name, permissions: ['list:get', 'list:manage'] });
|
||||
|
||||
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
|
||||
},
|
||||
|
||||
onDeleteListPerm(id) {
|
||||
this.form.lists = this.form.lists.filter((p) => p.id !== id);
|
||||
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
if (this.isEditing) {
|
||||
this.updateRole();
|
||||
return;
|
||||
}
|
||||
|
||||
this.createRole();
|
||||
},
|
||||
|
||||
onToggleSelect() {
|
||||
if (this.hasToggle) {
|
||||
this.form.permissions = [];
|
||||
} else {
|
||||
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
|
||||
item.permissions.forEach((p) => {
|
||||
acc.push(p);
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
this.hasToggle = !this.hasToggle;
|
||||
},
|
||||
|
||||
createRole() {
|
||||
let fn;
|
||||
const form = { name: this.form.name };
|
||||
|
||||
if (this.$props.type === 'user') {
|
||||
fn = this.$api.createUserRole;
|
||||
form.permissions = this.form.permissions;
|
||||
} else {
|
||||
fn = this.$api.createListRole;
|
||||
form.lists = this.form.lists.reduce((acc, item) => {
|
||||
acc.push({ id: item.id, permissions: item.permissions });
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
fn(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
|
||||
this.$parent.close();
|
||||
});
|
||||
},
|
||||
|
||||
updateRole() {
|
||||
let fn;
|
||||
const form = { id: this.$props.data.id, name: this.form.name };
|
||||
|
||||
if (this.$props.type === 'user') {
|
||||
fn = this.$api.updateUserRole;
|
||||
form.permissions = this.form.permissions;
|
||||
} else {
|
||||
fn = this.$api.updateListRole;
|
||||
form.lists = this.form.lists.reduce((acc, item) => {
|
||||
acc.push({ id: item.id, permissions: item.permissions });
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
fn(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
|
||||
this.$parent.close();
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading', 'serverConfig', 'lists']),
|
||||
|
||||
// Return the list of unselected lists.
|
||||
filteredLists() {
|
||||
if (!this.lists.results || this.type !== 'list') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subIDs = this.form.lists.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
|
||||
return this.lists.results.filter((l) => (!(l.id in subIDs)));
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.isEditing) {
|
||||
this.form = { ...this.form, ...this.$props.data };
|
||||
|
||||
// It's the superadmin role. Disable the form.
|
||||
if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
|
||||
this.disabled = true;
|
||||
}
|
||||
} else {
|
||||
const skip = ['admin', 'users'];
|
||||
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
|
||||
if (skip.includes(item.group)) {
|
||||
return acc;
|
||||
}
|
||||
item.permissions.forEach((p) => {
|
||||
if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) {
|
||||
acc.push(p);
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.filteredLists.length > 0) {
|
||||
this.form.curList = this.filteredLists[0].id;
|
||||
}
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
193
frontend/src/views/Roles.vue
Normal file
193
frontend/src/views/Roles.vue
Normal file
|
@ -0,0 +1,193 @@
|
|||
<template>
|
||||
<section class="roles">
|
||||
<header class="columns page-header">
|
||||
<div class="column is-10">
|
||||
<h1 class="title is-4">
|
||||
{{ $t(isUser ? 'users.userRoles' : 'users.listRoles') }}
|
||||
<span v-if="!isNaN(roles.length)">({{ roles.length }})</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field v-if="$can('users:manage')" expanded>
|
||||
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm('user')"
|
||||
data-cy="btn-new">
|
||||
{{ $t('globals.buttons.new') }}
|
||||
</b-button>
|
||||
</b-field>
|
||||
</div>
|
||||
</header>
|
||||
<b-table :data="roles" :loading="isLoading()" hoverable>
|
||||
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
|
||||
<a href="#" @click.prevent="showEditForm(props.row, 'user')">
|
||||
<b-tag v-if="props.row.id === 1" class="enabled">
|
||||
{{ props.row.name }}
|
||||
</b-tag>
|
||||
<template v-else>{{ props.row.name }}</template>
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
|
||||
header-class="cy-created_at" sortable>
|
||||
{{ $utils.niceDate(props.row.createdAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
|
||||
header-class="cy-updated_at" sortable>
|
||||
{{ $utils.niceDate(props.row.updatedAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" cell-class="actions has-text-right">
|
||||
<template v-if="$can('roles:manage')">
|
||||
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
|
||||
{
|
||||
placeholder: $t('globals.fields.name'),
|
||||
value: $t('campaigns.copyOf', { name: props.row.name }),
|
||||
},
|
||||
(name) => onCloneRole(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
|
||||
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
|
||||
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<template v-if="props.row.id !== 1">
|
||||
<a href="#" @click.prevent="showEditForm(props.row, 'user')" data-cy="btn-edit"
|
||||
:aria-label="$t('globals.buttons.edit')">
|
||||
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="onDeleteRole(props.row)" data-cy="btn-delete"
|
||||
:aria-label="$t('globals.buttons.delete')">
|
||||
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
</b-table-column>
|
||||
|
||||
<template #empty v-if="!isLoading()">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<!-- Add / edit form modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="700" @close="onFormClose">
|
||||
<role-form :data="curItem" :type="curType" :is-editing="isEditing" @finished="formFinished" />
|
||||
</b-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
import RoleForm from './RoleForm.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
RoleForm,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
curItem: null,
|
||||
curType: null,
|
||||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
isLoading() {
|
||||
return this.curType === 'user' ? this.loading.userRoles : this.loading.listRoles;
|
||||
},
|
||||
|
||||
fetchRoles() {
|
||||
if (this.isUser) {
|
||||
this.$api.getUserRoles();
|
||||
} else {
|
||||
this.$api.getListRoles();
|
||||
}
|
||||
},
|
||||
|
||||
// Show the edit form.
|
||||
showEditForm(item) {
|
||||
this.curItem = item;
|
||||
this.curType = this.isUser ? 'user' : 'list';
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = true;
|
||||
},
|
||||
|
||||
// Show the new form.
|
||||
showNewForm() {
|
||||
this.isEditing = false;
|
||||
this.isFormVisible = true;
|
||||
},
|
||||
|
||||
formFinished() {
|
||||
this.fetchRoles();
|
||||
},
|
||||
|
||||
onFormClose() {
|
||||
if (this.$route.params.id) {
|
||||
this.$router.push({ name: 'users' });
|
||||
}
|
||||
},
|
||||
|
||||
onCloneRole(name, item) {
|
||||
const form = { name };
|
||||
let fn;
|
||||
if (this.isUser) {
|
||||
fn = this.$api.createUserRole;
|
||||
form.permissions = item.permissions;
|
||||
} else {
|
||||
fn = this.$api.createListRole;
|
||||
form.lists = item.lists;
|
||||
}
|
||||
|
||||
fn(form).then(() => {
|
||||
this.fetchRoles();
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name }));
|
||||
});
|
||||
},
|
||||
|
||||
onDeleteRole(item) {
|
||||
this.$utils.confirm(
|
||||
this.$t('globals.messages.confirm'),
|
||||
() => {
|
||||
this.$api.deleteRole(item.id).then(() => {
|
||||
this.fetchRoles();
|
||||
|
||||
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading', 'userRoles', 'listRoles']),
|
||||
|
||||
isUser() {
|
||||
return this.curType === 'user';
|
||||
},
|
||||
|
||||
isList() {
|
||||
return this.curType === 'list';
|
||||
},
|
||||
|
||||
roles() {
|
||||
return this.isUser ? this.userRoles : this.listRoles;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.curType = this.$route.name === 'userRoles' ? 'user' : 'list';
|
||||
this.fetchRoles();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -10,7 +10,7 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field expanded>
|
||||
<b-field v-if="$can('settings:manage')" expanded>
|
||||
<b-button expanded :disabled="!hasFormChanged" type="is-primary" icon-left="content-save-outline"
|
||||
native-type="submit" class="isSaveEnabled" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
|
@ -160,6 +160,12 @@ export default Vue.extend({
|
|||
hasDummy = 'captcha';
|
||||
}
|
||||
|
||||
if (this.isDummy(form['security.oidc.client_secret'])) {
|
||||
form['security.oidc.client_secret'] = '';
|
||||
} else if (this.hasDummy(form['security.oidc.client_secret'])) {
|
||||
hasDummy = 'oidc';
|
||||
}
|
||||
|
||||
if (this.isDummy(form['bounce.postmark'].password)) {
|
||||
form['bounce.postmark'].password = '';
|
||||
} else if (this.hasDummy(form['bounce.postmark'].password)) {
|
||||
|
|
|
@ -33,7 +33,8 @@
|
|||
<div class="column is-4">
|
||||
<b-field :label="$t('globals.fields.status')" label-position="on-border"
|
||||
:message="$t('subscribers.blocklistedHelp')">
|
||||
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required expanded>
|
||||
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required
|
||||
expanded>
|
||||
<option value="enabled">
|
||||
{{ $t('subscribers.status.enabled') }}
|
||||
</option>
|
||||
|
@ -55,7 +56,7 @@
|
|||
</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-5 has-text-right" v-if="isEditing">
|
||||
<div v-if="$can('subscribers:manage') && isEditing" class="column is-5 has-text-right">
|
||||
<a href="#" @click.prevent="sendOptinConfirmation" :class="{ 'is-disabled': !hasOptinList }">
|
||||
<b-icon icon="email-outline" size="is-small" />
|
||||
{{ $t('subscribers.sendOptinConfirm') }}</a>
|
||||
|
@ -146,7 +147,8 @@
|
|||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button native-type="submit" type="is-primary" :loading="loading.subscribers">
|
||||
<b-button v-if="$can('subscribers:manage')" native-type="submit" type="is-primary"
|
||||
:loading="loading.subscribers">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field expanded>
|
||||
<b-field v-if="$can('subscribers:manage')" expanded>
|
||||
<b-button expanded type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new" class="btn-new">
|
||||
{{ $t('globals.buttons.new') }}
|
||||
</b-button>
|
||||
|
@ -42,7 +42,8 @@
|
|||
data-cy="query" />
|
||||
<span class="is-size-6 has-text-grey">
|
||||
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
|
||||
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{{ $t('globals.buttons.learnMore') }}.
|
||||
</a>
|
||||
</span>
|
||||
|
@ -69,8 +70,8 @@
|
|||
</section><!-- control -->
|
||||
|
||||
<br />
|
||||
<b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck" @check="onTableCheck"
|
||||
:checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
|
||||
<b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck"
|
||||
@check="onTableCheck" :checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
|
||||
@page-change="onPageChange" :current-page="queryParams.page" :per-page="subscribers.perPage"
|
||||
:total="subscribers.total" hoverable checkable backend-sorting @sort="onSort">
|
||||
<template #top-left>
|
||||
|
@ -102,19 +103,14 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')" header-class="cy-status"
|
||||
:td-attrs="$utils.tdID" sortable>
|
||||
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
|
||||
<b-tag :class="props.row.status">
|
||||
{{ $t(`subscribers.status.${props.row.status}`) }}
|
||||
</b-tag>
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" header-class="cy-email" sortable>
|
||||
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
|
||||
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
:class="{ 'blocklisted': props.row.status === 'blocklisted' }">
|
||||
{{ props.row.email }}
|
||||
</a>
|
||||
<b-tag v-if="props.row.status !== 'enabled'" :class="props.row.status">
|
||||
{{ $t(`subscribers.status.${props.row.status}`) }}
|
||||
</b-tag>
|
||||
<b-taglist>
|
||||
<template v-for="l in props.row.lists">
|
||||
<router-link :to="`/subscribers/lists/${l.id}`" :key="l.id" style="padding-right:0.5em;">
|
||||
|
@ -130,7 +126,8 @@
|
|||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable>
|
||||
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
|
||||
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
:class="{ 'blocklisted': props.row.status === 'blocklisted' }">
|
||||
{{ props.row.name }}
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
@ -157,14 +154,14 @@
|
|||
<b-icon icon="cloud-download-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
|
||||
:aria-label="$t('globals.buttons.edit')">
|
||||
<a v-if="$can('subscribers:manage')" :href="`/subscribers/${props.row.id}`"
|
||||
@click.prevent="showEditForm(props.row)" data-cy="btn-edit" :aria-label="$t('globals.buttons.edit')">
|
||||
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="#" @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete"
|
||||
:aria-label="$t('globals.buttons.delete')">
|
||||
<a v-if="$can('subscribers:manage')" href="#" @click.prevent="deleteSubscriber(props.row)"
|
||||
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
|
||||
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
|
|
242
frontend/src/views/UserForm.vue
Normal file
242
frontend/src/views/UserForm.vue
Normal file
|
@ -0,0 +1,242 @@
|
|||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<p v-if="isEditing" class="has-text-grey-light is-size-7">
|
||||
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
|
||||
</p>
|
||||
<h4 v-if="isEditing">
|
||||
{{ data.name }}
|
||||
</h4>
|
||||
<h4 v-else>
|
||||
{{ $t('users.newUser') }}
|
||||
</h4>
|
||||
</header>
|
||||
<section expanded class="modal-card-body">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.type')" label-position="on-border">
|
||||
<b-select v-model="form.type" name="status" required expanded :disabled="isEditing">
|
||||
<option value="user">
|
||||
{{ $t('users.type.user') }}
|
||||
</option>
|
||||
<option value="api">
|
||||
{{ $t('users.type.api') }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('globals.fields.status')" label-position="on-border">
|
||||
<b-select v-model="form.status" name="status" required expanded>
|
||||
<option value="enabled">
|
||||
{{ $t('users.status.enabled') }}
|
||||
</option>
|
||||
<option value="disabled">
|
||||
{{ $t('users.status.disabled') }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-field :label="$t('users.username')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.username" name="username" ref="focus" autofocus
|
||||
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
|
||||
pattern="[a-zA-Z0-9_\-\.]+$" />
|
||||
</b-field>
|
||||
|
||||
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')" required />
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
|
||||
</b-field>
|
||||
|
||||
<template v-if="form.type !== 'api'">
|
||||
<div class="box">
|
||||
<b-field>
|
||||
<b-checkbox v-model="form.passwordLogin" :native-value="true">
|
||||
{{ $t('users.passwordEnable') }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.password')" label-position="on-border">
|
||||
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
|
||||
type="password" name="password" :placeholder="$t('users.password')"
|
||||
:required="form.passwordLogin && !isEditing" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
|
||||
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
|
||||
type="password" name="password2" :required="form.passwordLogin && !isEditing && form.password" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h5>{{ $tc('users.roles') }}</h5>
|
||||
<div class="box">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$tc('users.userRole')" label-position="on-border">
|
||||
<b-select v-model="form.userRoleId" name="role" required expanded>
|
||||
<option v-for="r in userRoles" :value="r.id" :key="r.id">
|
||||
{{ r.name }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<b-field :label="$tc('users.listRole', 0)" label-position="on-border">
|
||||
<b-select v-model="form.listRoleId" name="role" expanded>
|
||||
<option value="">— {{ $t("globals.terms.none") }} —</option>
|
||||
<option v-for="r in listRoles" :value="r.id" :key="r.id">
|
||||
{{ r.name }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="apiToken" class="user-api-token">
|
||||
<p>{{ $t('users.apiOneTimeToken') }}</p>
|
||||
<copy-text :text="apiToken" />
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button v-if="$can('users:manage') && !apiToken" native-type="submit" type="is-primary"
|
||||
:loading="loading.lists" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import CopyText from '../components/CopyText.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'UserForm',
|
||||
|
||||
components: {
|
||||
CopyText,
|
||||
},
|
||||
|
||||
props: {
|
||||
data: { type: Object, default: () => ({}) },
|
||||
isEditing: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values.
|
||||
form: {
|
||||
username: '',
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
passwordLogin: false,
|
||||
type: 'user',
|
||||
status: 'enabled',
|
||||
},
|
||||
apiToken: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit() {
|
||||
if (!this.form.passwordLogin) {
|
||||
this.form.password = null;
|
||||
this.form.password2 = null;
|
||||
}
|
||||
|
||||
if (this.isEditing) {
|
||||
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
|
||||
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateUser();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password !== this.form.password2) {
|
||||
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
|
||||
return;
|
||||
}
|
||||
|
||||
this.createUser();
|
||||
},
|
||||
|
||||
createUser() {
|
||||
const form = {
|
||||
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
|
||||
};
|
||||
this.$api.createUser(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
|
||||
|
||||
// If the user is an API user, show the one-time token.
|
||||
if (form.type === 'api') {
|
||||
this.apiToken = data.password;
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
});
|
||||
},
|
||||
|
||||
updateUser() {
|
||||
const form = {
|
||||
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
|
||||
};
|
||||
this.$api.updateUser({ id: this.data.id, ...form }).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
|
||||
});
|
||||
},
|
||||
|
||||
hasType(t) {
|
||||
// If the user being edited is API, then the only valid field is API.
|
||||
// Otherwise, all fields are valid except API.
|
||||
return !this.$props.isEditing || (this.form.type === 'api' ? t === 'api' : t !== 'api');
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading', 'userRoles', 'listRoles']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.form = { ...this.form, ...this.$props.data };
|
||||
if (this.$props.data.userRole) {
|
||||
this.form.userRoleId = this.$props.data.userRole.id;
|
||||
}
|
||||
|
||||
this.form.listRoleId = this.$props.data.listRole ? this.$props.data.listRole.id : '';
|
||||
|
||||
this.$api.getUserRoles();
|
||||
this.$api.getListRoles();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
95
frontend/src/views/UserProfile.vue
Normal file
95
frontend/src/views/UserProfile.vue
Normal file
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<section class="user-profile section-mini">
|
||||
<b-loading v-if="loading.users" :active="loading.users" :is-full-page="false" />
|
||||
|
||||
<h1 class="title">
|
||||
@{{ data.username }}
|
||||
</h1>
|
||||
<b-tag v-if="data.userRole">{{ data.userRole.name }}</b-tag>
|
||||
|
||||
<br /><br /><br />
|
||||
<form @submit.prevent="onSubmit">
|
||||
<b-field v-if="data.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')"
|
||||
:disabled="!data.passwordLogin" required autofocus />
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
|
||||
</b-field>
|
||||
|
||||
<div v-if="data.passwordLogin" class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.password')" label-position="on-border">
|
||||
<b-input minlength="8" :maxlength="200" v-model="form.password" type="password" name="password"
|
||||
:placeholder="$t('users.password')" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
|
||||
<b-input minlength="8" :maxlength="200" v-model="form.password2" type="password" name="password2" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-field expanded>
|
||||
<b-button type="is-primary" icon-left="content-save-outline" native-type="submit" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'UserProfile',
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {},
|
||||
data: {},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit() {
|
||||
const params = {
|
||||
name: this.form.name,
|
||||
email: this.form.email,
|
||||
};
|
||||
|
||||
if (this.data.passwordLogin && this.form.password) {
|
||||
if (this.form.password !== this.form.password2) {
|
||||
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
|
||||
return;
|
||||
}
|
||||
|
||||
params.password = this.form.password;
|
||||
params.password2 = this.form.password2;
|
||||
}
|
||||
|
||||
this.$api.updateUserProfile(params).then(() => {
|
||||
this.form.password = '';
|
||||
this.form.password2 = '';
|
||||
this.$utils.toast(this.$t('globals.messages.updated', { name: this.data.username }));
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$api.getUserProfile().then((data) => {
|
||||
this.data = { ...data };
|
||||
this.form = { name: data.name, email: data.email };
|
||||
});
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading']),
|
||||
},
|
||||
|
||||
});
|
||||
</script>
|
235
frontend/src/views/Users.vue
Normal file
235
frontend/src/views/Users.vue
Normal file
|
@ -0,0 +1,235 @@
|
|||
<template>
|
||||
<section class="users">
|
||||
<header class="columns page-header">
|
||||
<div class="column is-10">
|
||||
<h1 class="title is-4">
|
||||
{{ $t('globals.terms.users') }}
|
||||
<span v-if="!isNaN(users.length)">({{ users.length }})</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field v-if="$can('users:manage')" expanded>
|
||||
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
|
||||
{{ $t('globals.buttons.new') }}
|
||||
</b-button>
|
||||
</b-field>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-table :data="users" :loading="loading.users" hoverable checkable :checked-rows.sync="checked"
|
||||
default-sort="createdAt" backend-sorting @sort="onSort" @check-all="onTableCheck" @check="onTableCheck">
|
||||
<template #top-left>
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<form @submit.prevent="getUsers">
|
||||
<div>
|
||||
<b-field>
|
||||
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
|
||||
data-cy="query" />
|
||||
<p class="controls">
|
||||
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
|
||||
</p>
|
||||
</b-field>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<b-table-column v-slot="props" field="username" :label="$t('users.username')" header-class="cy-username" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
|
||||
{{ props.row.username }}
|
||||
</a>
|
||||
<b-tag v-if="props.row.status === 'disabled'">
|
||||
{{ $t(`users.status.${props.row.status}`) }}
|
||||
</b-tag>
|
||||
<div class="has-text-grey is-size-7">
|
||||
{{ props.row.name }}
|
||||
<b-tag v-if="props.row.type === 'api'" class="is-small api">
|
||||
<b-icon icon="code" />
|
||||
{{ $t(`users.type.${props.row.type}`) }}
|
||||
</b-tag>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="status" :label="$tc('users.role')" header-class="cy-status" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<router-link :to="{ name: 'userRoles' }">
|
||||
<b-tag :class="props.row.userRole.id === 1 ? 'enabled' : 'primary'">
|
||||
<b-icon icon="account-outline" />
|
||||
{{ props.row.userRole.name }}
|
||||
</b-tag>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'listRoles' }">
|
||||
<b-tag v-if="props.row.listRole">
|
||||
<b-icon icon="newspaper-variant-outline" />
|
||||
{{ props.row.listRole.name }}
|
||||
</b-tag>
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="name" :label="$t('subscribers.email')" header-class="cy-name" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<div>
|
||||
<a v-if="props.row.email" :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
|
||||
{{ props.row.email }}
|
||||
</a>
|
||||
<template v-else>
|
||||
—
|
||||
</template>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
|
||||
header-class="cy-created_at" sortable>
|
||||
{{ $utils.niceDate(props.row.createdAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
|
||||
header-class="cy-updated_at" sortable>
|
||||
{{ $utils.niceDate(props.row.updatedAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="last_login" :label="$t('users.lastLogin')" header-class="cy-updated_at"
|
||||
sortable>
|
||||
{{ props.row.loggedinAt ? $utils.niceDate(props.row.loggedinAt, true) : '—' }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" cell-class="actions" align="right">
|
||||
<div>
|
||||
<a v-if="$can('users:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
|
||||
:aria-label="$t('globals.buttons.edit')">
|
||||
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<a v-if="$can('users:manage')" href="#" @click.prevent="deleteUser(props.row)" data-cy="btn-delete"
|
||||
:aria-label="$t('globals.buttons.delete')">
|
||||
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<template #empty v-if="!loading.users">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<!-- Add / edit form modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600" @close="onFormClose">
|
||||
<user-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
|
||||
</b-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
import UserForm from './UserForm.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
UserForm,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
curItem: null,
|
||||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
users: [],
|
||||
checked: [],
|
||||
queryParams: {
|
||||
page: 1,
|
||||
query: '',
|
||||
orderBy: 'id',
|
||||
order: 'asc',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSort(field, direction) {
|
||||
this.queryParams.orderBy = field;
|
||||
this.queryParams.order = direction;
|
||||
this.getUsers();
|
||||
},
|
||||
|
||||
onTableCheck() {
|
||||
// Disable bulk.all selection if there are no rows checked in the table.
|
||||
if (this.bulk.checked.length !== this.subscribers.total) {
|
||||
this.bulk.all = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Show the edit form.
|
||||
showEditForm(item) {
|
||||
this.curItem = item;
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = true;
|
||||
},
|
||||
|
||||
// Show the new form.
|
||||
showNewForm() {
|
||||
this.curItem = {};
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = false;
|
||||
},
|
||||
|
||||
formFinished() {
|
||||
this.getUsers();
|
||||
},
|
||||
|
||||
onFormClose() {
|
||||
if (this.$route.params.id) {
|
||||
this.$router.push({ name: 'users' });
|
||||
}
|
||||
},
|
||||
|
||||
getUsers() {
|
||||
this.$api.queryUsers({
|
||||
query: this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' '),
|
||||
order_by: this.queryParams.orderBy,
|
||||
order: this.queryParams.order,
|
||||
}).then((resp) => {
|
||||
this.users = resp;
|
||||
});
|
||||
},
|
||||
|
||||
deleteUser(item) {
|
||||
this.$utils.confirm(
|
||||
this.$t('globals.messages.confirm'),
|
||||
() => {
|
||||
this.$api.deleteUser(item.id).then(() => {
|
||||
this.getUsers();
|
||||
|
||||
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading', 'settings']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.$route.params.id) {
|
||||
this.$api.getUser(parseInt(this.$route.params.id, 10)).then((data) => {
|
||||
this.showEditForm(data);
|
||||
});
|
||||
} else {
|
||||
this.getUsers();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,5 +1,44 @@
|
|||
<template>
|
||||
<div class="items">
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<b-field :label="$t('settings.security.enableOIDC')" :message="$t('settings.security.OIDCHelp')">
|
||||
<b-switch v-model="data['security.oidc']['enabled']" name="security.oidc" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-8">
|
||||
<b-field :label="$t('settings.security.OIDCURL')" label-position="on-border">
|
||||
<div>
|
||||
<b-input v-model="data['security.oidc']['provider_url']" name="oidc.provider_url"
|
||||
placeholder="https://login.yoursite.com" :disabled="!data['security.oidc']['enabled']" :maxlength="300"
|
||||
required type="url" pattern="https?://.*" />
|
||||
|
||||
<div class="spaced-links is-size-7 mt-2" :class="{ 'disabled': !data['security.oidc']['enabled'] }">
|
||||
<a href="#" @click.prevent="() => setProvider(n, 'google')">Google</a>
|
||||
<a href="#" @click.prevent="() => setProvider(n, 'github')">GitHub</a>
|
||||
<a href="#" @click.prevent="() => setProvider(n, 'microsoft')">Microsoft</a>
|
||||
<a href="#" @click.prevent="() => setProvider(n, 'apple')">Apple</a>
|
||||
</div>
|
||||
</div>
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('settings.security.OIDCClientID')" label-position="on-border">
|
||||
<b-input v-model="data['security.oidc']['client_id']" name="oidc.client_id" ref="client_id"
|
||||
:disabled="!data['security.oidc']['enabled']" :maxlength="200" required />
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('settings.security.OIDCClientSecret')" label-position="on-border">
|
||||
<b-input v-model="data['security.oidc']['client_secret']" name="oidc.client_secret" type="password"
|
||||
:disabled="!data['security.oidc']['enabled']" :maxlength="200" required />
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('settings.security.OIDCRedirectURL')">
|
||||
<code><copy-text :text="`${serverConfig.root_url}/auth/oidc`" /></code>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<b-field :label="$t('settings.security.enableCaptcha')" :message="$t('settings.security.enableCaptchaHelp')">
|
||||
|
@ -9,8 +48,8 @@
|
|||
<div class="column is-8">
|
||||
<b-field :label="$t('settings.security.captchaKey')" label-position="on-border"
|
||||
:message="$t('settings.security.captchaKeyHelp')">
|
||||
<b-input v-model="data['security.captcha_key']" name="captcha_key" :disabled="!data['security.enable_captcha']"
|
||||
:maxlength="200" required />
|
||||
<b-input v-model="data['security.captcha_key']" name="captcha_key"
|
||||
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
|
||||
</b-field>
|
||||
<b-field :label="$t('settings.security.captchaSecret')" label-position="on-border">
|
||||
<b-input v-model="data['security.captcha_secret']" name="captcha_secret" type="password"
|
||||
|
@ -23,14 +62,49 @@
|
|||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import CopyText from '../../components/CopyText.vue';
|
||||
|
||||
const OIDC_PROVIDERS = {
|
||||
google: 'https://accounts.google.com',
|
||||
github: 'https://token.actions.githubusercontent.com',
|
||||
microsoft: 'https://login.microsoftonline.com/{TENANT_HERE}/v2.0',
|
||||
apple: 'https://appleid.apple.com',
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
CopyText,
|
||||
},
|
||||
|
||||
props: {
|
||||
form: {
|
||||
type: Object, default: () => { },
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['serverConfig']),
|
||||
|
||||
version() {
|
||||
return import.meta.env.VUE_APP_VERSION;
|
||||
},
|
||||
|
||||
isMobile() {
|
||||
return this.windowWidth <= 768;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
setProvider(n, provider) {
|
||||
this.$set(this.data['security.oidc'], 'provider_url', OIDC_PROVIDERS[provider]);
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.client_id.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
data: this.form,
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
</b-field>
|
||||
</div>
|
||||
</div><!-- auth -->
|
||||
<div class="smtp-shortcuts is-size-7">
|
||||
<div class="spaced-links is-size-7">
|
||||
<a href="#" @click.prevent="() => fillSettings(n, 'gmail')">Gmail</a>
|
||||
<a href="#" @click.prevent="() => fillSettings(n, 'ses')">Amazon SES</a>
|
||||
<a href="#" @click.prevent="() => fillSettings(n, 'mailgun')">Mailgun</a>
|
||||
|
|
3
frontend/vite.config.js
vendored
3
frontend/vite.config.js
vendored
|
@ -28,6 +28,9 @@ export default defineConfig(({ _, mode }) => {
|
|||
'^/(api|webhooks|subscription|public|health)': {
|
||||
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
|
||||
},
|
||||
'^/admin/login': {
|
||||
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
|
||||
},
|
||||
'^/(admin\/custom\.(css|js))': {
|
||||
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
|
||||
},
|
||||
|
|
9
go.mod
9
go.mod
|
@ -4,6 +4,7 @@ go 1.20
|
|||
|
||||
require (
|
||||
github.com/Masterminds/sprig/v3 v3.2.3
|
||||
github.com/coreos/go-oidc/v3 v3.9.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-message v0.16.0
|
||||
github.com/gdgvda/cron v0.2.0
|
||||
|
@ -31,7 +32,10 @@ require (
|
|||
github.com/spf13/pflag v1.0.5
|
||||
github.com/yuin/goldmark v1.6.0
|
||||
github.com/zerodha/easyjson v1.0.0
|
||||
github.com/zerodha/simplesessions/stores/postgres/v3 v3.0.0
|
||||
github.com/zerodha/simplesessions/v3 v3.0.0
|
||||
golang.org/x/mod v0.17.0
|
||||
golang.org/x/oauth2 v0.13.0
|
||||
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
|
||||
)
|
||||
|
||||
|
@ -40,7 +44,9 @@ require (
|
|||
github.com/Masterminds/semver/v3 v3.2.0 // indirect
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/huandu/xstrings v1.4.0 // indirect
|
||||
github.com/imdario/mergo v0.3.14 // indirect
|
||||
|
@ -63,6 +69,9 @@ require (
|
|||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.8
|
||||
|
|
36
go.sum
36
go.sum
|
@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y
|
|||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
||||
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
|
||||
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
@ -20,6 +22,8 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
|
|||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gdgvda/cron v0.2.0 h1:oX8qdLZq4tC5StnCsZsTNs2BIzaRjcjmPZ4o+BArKX4=
|
||||
github.com/gdgvda/cron v0.2.0/go.mod h1:VEwidZXB255kESB5DcUGRWTYZS8KkOBYD1YBn8Wiyx8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
|
||||
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
|
@ -28,7 +32,13 @@ github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV
|
|||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
|
@ -124,10 +134,11 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
|
@ -137,7 +148,12 @@ github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
|
|||
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zerodha/easyjson v1.0.0 h1:3u1lvS8C+8ntnb4lXHc7ZzfQ8txUdzBAH5t9AwF7bUs=
|
||||
github.com/zerodha/easyjson v1.0.0/go.mod h1:mA8d8Xs8Yp4Q95ppRb4dRGROERgKSLQIK9Y7iuC5mog=
|
||||
github.com/zerodha/simplesessions/stores/postgres/v3 v3.0.0 h1:50BNRW/VYOgCf5v6vbhKMT40sFA+yZ7xUrdM/vbI1G8=
|
||||
github.com/zerodha/simplesessions/stores/postgres/v3 v3.0.0/go.mod h1:PifZh0lGfmx4sN3+YvDCjkIDrTzZoILL9jkczV1SsiA=
|
||||
github.com/zerodha/simplesessions/v3 v3.0.0 h1:seHwxVNnlCbp5nG8GFxSsRUdiHnfb39QdEW3J536O9Y=
|
||||
github.com/zerodha/simplesessions/v3 v3.0.0/go.mod h1:lAK+CJmZRlbvfq+OnkB8Iyf6LWgjzvUuWYKX1XA51P0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
|
@ -148,6 +164,7 @@ golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E
|
|||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
|
@ -155,9 +172,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
|
||||
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
@ -176,6 +196,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
|
@ -185,13 +206,20 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
|
||||
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
47
i18n/en.json
47
i18n/en.json
|
@ -130,6 +130,7 @@
|
|||
"forms.title": "Forms",
|
||||
"globals.buttons.add": "Add",
|
||||
"globals.buttons.addNew": "Add new",
|
||||
"globals.buttons.toggleSelect": "Toggle selection",
|
||||
"globals.buttons.back": "Back",
|
||||
"globals.buttons.cancel": "Cancel",
|
||||
"globals.buttons.clear": "Clear",
|
||||
|
@ -151,6 +152,7 @@
|
|||
"globals.buttons.save": "Save",
|
||||
"globals.buttons.saveChanges": "Save changes",
|
||||
"globals.buttons.view": "View",
|
||||
"globals.buttons.manage": "Manage",
|
||||
"globals.days.0": "Sun",
|
||||
"globals.days.1": "Sun",
|
||||
"globals.days.2": "Mon",
|
||||
|
@ -178,6 +180,7 @@
|
|||
"globals.messages.errorCreating": "Error creating {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Error deleting {name}: {error}",
|
||||
"globals.messages.errorFetching": "Error fetching {name}: {error}",
|
||||
"globals.messages.permissionDenied": "Permission denied: {name}",
|
||||
"globals.messages.errorInvalidIDs": "One or more IDs are invalid: {error}",
|
||||
"globals.messages.errorUUID": "Error generating UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Error updating {name}: {error}",
|
||||
|
@ -230,6 +233,8 @@
|
|||
"globals.terms.tag": "Tag | Tags",
|
||||
"globals.terms.tags": "Tags",
|
||||
"globals.terms.template": "Template | Templates",
|
||||
"globals.terms.users": "Users",
|
||||
"globals.terms.user": "User | Users",
|
||||
"globals.terms.templates": "Templates",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
|
@ -355,6 +360,13 @@
|
|||
"public.unsubbedInfo": "You have unsubscribed successfully.",
|
||||
"public.unsubbedTitle": "Unsubscribed",
|
||||
"public.unsubscribeTitle": "Unsubscribe from mailing list",
|
||||
"settings.security.enableOIDC": "Enable OIDC SSO",
|
||||
"settings.security.OIDCHelp": "Enable OpenID Connect OAuth2 login via an OAuth provider.",
|
||||
"settings.security.OIDCWarning": "When OIDC is enabled, default password login is disabled. Invalid config can lock you out.",
|
||||
"settings.security.OIDCURL": "Provider URL",
|
||||
"settings.security.OIDCClientID": "Client ID",
|
||||
"settings.security.OIDCClientSecret": "Client secret",
|
||||
"settings.security.OIDCRedirectURL": "Redirect URL for oAuth provider",
|
||||
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
|
||||
"settings.appearance.adminName": "Admin",
|
||||
"settings.appearance.customCSS": "Custom CSS",
|
||||
|
@ -587,5 +599,38 @@
|
|||
"templates.rawHTML": "Raw HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
"users.role": "Role | Roles",
|
||||
"users.roles": "Roles",
|
||||
"users.userRole": "User role | User roles",
|
||||
"users.userRoles": "User roles",
|
||||
"users.listRole": "List roles | List role",
|
||||
"users.listRoles": "List roles",
|
||||
"users.newUserRole": "New user role",
|
||||
"users.newListRole": "New list role",
|
||||
"users.listPerms": "List permissions",
|
||||
"users.listPermsWarning": "lists:get_all or lists:manage_all are enabled which overrides per-list permissions",
|
||||
"users.perms": "Permissions",
|
||||
"users.roleGroup": "Group",
|
||||
"users.loginOIDC": "Login with {name}",
|
||||
"users.logout": "Logout",
|
||||
"users.profile": "Profile",
|
||||
"users.lastLogin": "Last login",
|
||||
"users.newUser": "New user",
|
||||
"users.type": "Type",
|
||||
"users.type.user": "User",
|
||||
"users.type.super": "Super Admin",
|
||||
"users.type.api": "API",
|
||||
"users.status.enabled": "Enabled",
|
||||
"users.status.disabled": "Disabled",
|
||||
"users.username": "Username",
|
||||
"users.usernameHelp": "Used with password login",
|
||||
"users.password": "Password",
|
||||
"users.invalidLogin": "Invalid login or password",
|
||||
"users.invalidRequest": "Invalid auth request",
|
||||
"users.passwordRepeat": "Repeat password",
|
||||
"users.passwordEnable": "Enable password login",
|
||||
"users.passwordMismatch": "Passwords don't match",
|
||||
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
|
||||
"users.needSuper": "User(s) couldn't updated. There has to be at least one active Super Admin user.",
|
||||
"users.cantDeleteRole": "Cannot delete role that is in use."
|
||||
}
|
||||
|
|
372
internal/auth/auth.go
Normal file
372
internal/auth/auth.go
Normal file
|
@ -0,0 +1,372 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/zerodha/simplesessions/stores/postgres/v3"
|
||||
"github.com/zerodha/simplesessions/v3"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
// UserKey is the key on which the User profile is set on echo handlers.
|
||||
UserKey = "auth_user"
|
||||
SessionKey = "auth_session"
|
||||
SuperAdminRoleID = 1
|
||||
)
|
||||
|
||||
const (
|
||||
sessTypeNative = "native"
|
||||
sessTypeOIDC = "oidc"
|
||||
)
|
||||
|
||||
type OIDCclaim struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Sub string `json:"sub"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ProviderURL string `json:"provider_url"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
type BasicAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
OIDC OIDCConfig
|
||||
BasicAuth BasicAuthConfig
|
||||
}
|
||||
|
||||
// Callbacks takes two callback functions required by simplesessions.
|
||||
type Callbacks struct {
|
||||
SetCookie func(cookie *http.Cookie, w interface{}) error
|
||||
GetCookie func(name string, r interface{}) (*http.Cookie, error)
|
||||
GetUser func(id int) (models.User, error)
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
apiUsers map[string]models.User
|
||||
sync.RWMutex
|
||||
|
||||
cfg Config
|
||||
oauthCfg oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
sess *simplesessions.Manager
|
||||
sessStore *postgres.Store
|
||||
cb *Callbacks
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) {
|
||||
a := &Auth{
|
||||
cfg: cfg,
|
||||
cb: cb,
|
||||
log: lo,
|
||||
|
||||
apiUsers: map[string]models.User{},
|
||||
}
|
||||
|
||||
// Initialize OIDC.
|
||||
if cfg.OIDC.Enabled {
|
||||
provider, err := oidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL)
|
||||
if err != nil {
|
||||
lo.Printf("error initializing OIDC OAuth provider: %v", err)
|
||||
} else {
|
||||
|
||||
a.verifier = provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.OIDC.ClientID,
|
||||
})
|
||||
|
||||
a.oauthCfg = oauth2.Config{
|
||||
ClientID: cfg.OIDC.ClientID,
|
||||
ClientSecret: cfg.OIDC.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: cfg.OIDC.RedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize session manager.
|
||||
a.sess = simplesessions.New(simplesessions.Options{
|
||||
EnableAutoCreate: false,
|
||||
SessionIDLength: 64,
|
||||
Cookie: simplesessions.CookieOptions{
|
||||
IsHTTPOnly: true,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
},
|
||||
})
|
||||
st, err := postgres.New(postgres.Opt{}, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.sessStore = st
|
||||
a.sess.UseStore(st)
|
||||
a.sess.SetCookieHooks(cb.GetCookie, cb.SetCookie)
|
||||
|
||||
// Prune dead sessions from the DB periodically.
|
||||
go func() {
|
||||
if err := st.Prune(); err != nil {
|
||||
lo.Printf("error pruning login sessions: %v", err)
|
||||
}
|
||||
time.Sleep(time.Hour * 12)
|
||||
}()
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CacheAPIUsers caches API users for authenticating requests. It wipes
|
||||
// the existing cache every time and is meant for syncing all API users
|
||||
// in the database in one shot.
|
||||
func (o *Auth) CacheAPIUsers(users []models.User) {
|
||||
o.Lock()
|
||||
o.apiUsers = map[string]models.User{}
|
||||
|
||||
for _, u := range users {
|
||||
o.apiUsers[u.Username] = u
|
||||
}
|
||||
o.Unlock()
|
||||
}
|
||||
|
||||
// CacheAPIUser caches an API user for authenticating requests.
|
||||
func (o *Auth) CacheAPIUser(u models.User) {
|
||||
o.Lock()
|
||||
o.apiUsers[u.Username] = u
|
||||
o.Unlock()
|
||||
}
|
||||
|
||||
// GetAPIToken validates an API user+token.
|
||||
func (o *Auth) GetAPIToken(user string, token string) (models.User, bool) {
|
||||
o.RLock()
|
||||
t, ok := o.apiUsers[user]
|
||||
o.RUnlock()
|
||||
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(token)) != 1 {
|
||||
return models.User{}, false
|
||||
}
|
||||
|
||||
return t, true
|
||||
}
|
||||
|
||||
// GetOIDCAuthURL returns the OIDC provider's auth URL to redirect to.
|
||||
func (o *Auth) GetOIDCAuthURL(state, nonce string) string {
|
||||
return o.oauthCfg.AuthCodeURL(state, oidc.Nonce(nonce))
|
||||
}
|
||||
|
||||
// ExchangeOIDCToken takes an OIDC authorization code (recieved via redirect from the OIDC provider),
|
||||
// validates it, and returns an OIDC token for subsequent auth.
|
||||
func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, OIDCclaim, error) {
|
||||
tk, err := o.oauthCfg.Exchange(context.TODO(), code)
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err))
|
||||
}
|
||||
|
||||
rawIDTk, ok := tk.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.")
|
||||
}
|
||||
|
||||
idTk, err := o.verifier.Verify(context.TODO(), rawIDTk)
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err))
|
||||
}
|
||||
|
||||
if idTk.Nonce != nonce {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match")
|
||||
}
|
||||
|
||||
var claims OIDCclaim
|
||||
if err := idTk.Claims(&claims); err != nil {
|
||||
return "", OIDCclaim{}, errors.New("error getting user from OIDC")
|
||||
}
|
||||
|
||||
return rawIDTk, claims, nil
|
||||
}
|
||||
|
||||
// Middleware is the HTTP middleware used for wrapping HTTP handlers registered on the echo router.
|
||||
// It authorizes token (BasicAuth/token) based and cookie based sessions and on successful auth,
|
||||
// sets the authenticated User{} on the echo context on the key UserKey. On failure, it sets an Error{}
|
||||
// instead on the same key.
|
||||
func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// It's an `Authorization` header request.
|
||||
hdr := strings.TrimSpace(c.Request().Header.Get("Authorization"))
|
||||
|
||||
// If cookie is set, ignore BasicAuth. This is to preserve backwards compatibility
|
||||
// in v3 -> v4 upgrade where the user browser sessions would still have old
|
||||
// BasicAuth credentials, which no longer work in the new system which expects
|
||||
// session cookies instead, which causes a redirect loop despite loggin in and session
|
||||
// cookies being set.
|
||||
//
|
||||
// TODO: This should be removed in a future version.
|
||||
if c := strings.TrimSpace(c.Request().Header.Get("Cookie")); strings.Contains(c, "session=") {
|
||||
hdr = ""
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
key, token, err := parseAuthHeader(hdr)
|
||||
if err != nil {
|
||||
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, err.Error()))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Validate the token.
|
||||
user, ok := o.GetAPIToken(key, token)
|
||||
if !ok {
|
||||
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid API credentials"))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Set the user details on the handler context.
|
||||
c.Set(UserKey, user)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Is it a cookie based session?
|
||||
sess, user, err := o.validateSession(c)
|
||||
if err != nil {
|
||||
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Set the user details on the handler context.
|
||||
c.Set(UserKey, user)
|
||||
c.Set(SessionKey, sess)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
u, ok := c.Get(UserKey).(models.User)
|
||||
if !ok {
|
||||
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// If the current user is a Super Admin user, do no checks.
|
||||
if u.UserRole.ID == SuperAdminRoleID {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if the current handler's permission is in the user's permission map.
|
||||
var (
|
||||
has = false
|
||||
perm = ""
|
||||
)
|
||||
for _, perm = range perms {
|
||||
if _, ok := u.PermissionsMap[perm]; ok {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !has {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %s", perm))
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveSession creates and sets a session (post successful login/auth).
|
||||
func (o *Auth) SaveSession(u models.User, oidcToken string, c echo.Context) error {
|
||||
sess, err := o.sess.NewSession(c, c)
|
||||
if err != nil {
|
||||
o.log.Printf("error creating login session: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
|
||||
}
|
||||
|
||||
if err := sess.SetMulti(map[string]interface{}{"user_id": u.ID, "oidc_token": oidcToken}); err != nil {
|
||||
o.log.Printf("error setting login session: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, models.User, error) {
|
||||
// Cookie session.
|
||||
sess, err := o.sess.Acquire(nil, c, c)
|
||||
if err != nil {
|
||||
return nil, models.User{}, echo.NewHTTPError(http.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
// Get the session variables.
|
||||
vars, err := sess.GetMulti("user_id", "oidc_token")
|
||||
if err != nil {
|
||||
return nil, models.User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Validate the user ID in the session.
|
||||
userID, err := o.sessStore.Int(vars["user_id"], nil)
|
||||
if err != nil || userID < 1 {
|
||||
o.log.Printf("error fetching session user ID: %v", err)
|
||||
return nil, models.User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Fetch user details from the database.
|
||||
user, err := o.cb.GetUser(userID)
|
||||
if err != nil {
|
||||
o.log.Printf("error fetching session user: %v", err)
|
||||
}
|
||||
|
||||
return sess, user, err
|
||||
}
|
||||
|
||||
// parseAuthHeader parses the Authorization header and returns the api_key and access_token.
|
||||
func parseAuthHeader(h string) (string, string, error) {
|
||||
const authBasic = "Basic"
|
||||
const authToken = "token"
|
||||
|
||||
var (
|
||||
pair []string
|
||||
delim = ":"
|
||||
)
|
||||
|
||||
if strings.HasPrefix(h, authToken) {
|
||||
// token api_key:access_token.
|
||||
pair = strings.SplitN(strings.Trim(h[len(authToken):], " "), delim, 2)
|
||||
} else if strings.HasPrefix(h, authBasic) {
|
||||
// HTTP BasicAuth. This is supported for backwards compatibility.
|
||||
payload, err := base64.StdEncoding.DecodeString(string(strings.Trim(h[len(authBasic):], " ")))
|
||||
if err != nil {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "invalid Base64 value in Basic Authorization header")
|
||||
}
|
||||
pair = strings.SplitN(string(payload), delim, 2)
|
||||
} else {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "unknown Authorization scheme")
|
||||
}
|
||||
|
||||
if len(pair) < 2 {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "api_key:token missing")
|
||||
}
|
||||
|
||||
if len(pair[0]) == 0 || len(pair[1]) == 0 {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "empty `api_key` or `token`")
|
||||
}
|
||||
|
||||
return pair[0], pair[1], nil
|
||||
}
|
|
@ -10,10 +10,10 @@ import (
|
|||
)
|
||||
|
||||
// GetLists gets all lists optionally filtered by type.
|
||||
func (c *Core) GetLists(typ string) ([]models.List, error) {
|
||||
func (c *Core) GetLists(typ string, getAll bool, permittedIDs []int) ([]models.List, error) {
|
||||
out := []models.List{}
|
||||
|
||||
if err := c.q.GetLists.Select(&out, typ, "id"); err != nil {
|
||||
if err := c.q.GetLists.Select(&out, typ, "id", getAll, pq.Array(permittedIDs)); err != nil {
|
||||
c.log.Printf("error fetching lists: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
|
@ -36,7 +36,7 @@ func (c *Core) GetLists(typ string) ([]models.List, error) {
|
|||
|
||||
// QueryLists gets multiple lists based on multiple query params. Along with the paginated and sliced
|
||||
// results, the total number of lists in the DB is returned.
|
||||
func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy, order string, offset, limit int) ([]models.List, int, error) {
|
||||
func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy, order string, getAll bool, permittedIDs []int, offset, limit int) ([]models.List, int, error) {
|
||||
_ = c.refreshCache(matListSubStats, false)
|
||||
|
||||
if tags == nil {
|
||||
|
@ -47,7 +47,7 @@ func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy,
|
|||
out = []models.List{}
|
||||
queryStr, stmt = makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists, listQuerySortFields)
|
||||
)
|
||||
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, pq.StringArray(tags), offset, limit); err != nil {
|
||||
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, pq.StringArray(tags), getAll, pq.Array(permittedIDs), offset, limit); err != nil {
|
||||
c.log.Printf("error fetching lists: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
|
@ -82,7 +82,7 @@ func (c *Core) GetList(id int, uuid string) (models.List, error) {
|
|||
|
||||
var res []models.List
|
||||
queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists, nil)
|
||||
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", pq.StringArray{}, 0, 1); err != nil {
|
||||
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", pq.StringArray{}, true, nil, 0, 1); err != nil {
|
||||
c.log.Printf("error fetching lists: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
|
|
163
internal/core/roles.go
Normal file
163
internal/core/roles.go
Normal file
|
@ -0,0 +1,163 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetRoles retrieves all roles.
|
||||
func (c *Core) GetRoles() ([]models.Role, error) {
|
||||
out := []models.Role{}
|
||||
if err := c.q.GetUserRoles.Select(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetListRoles retrieves all list roles.
|
||||
func (c *Core) GetListRoles() ([]models.ListRole, error) {
|
||||
out := []models.ListRole{}
|
||||
if err := c.q.GetListRoles.Select(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Unmarshall the nested list permissions, if any.
|
||||
for n, r := range out {
|
||||
if r.ListsRaw == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil {
|
||||
c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateRole creates a new role.
|
||||
func (c *Core) CreateRole(r models.Role) (models.Role, error) {
|
||||
var out models.Role
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, models.RoleTypeUser, pq.Array(r.Permissions)); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateListRole creates a new list role.
|
||||
func (c *Core) CreateListRole(r models.ListRole) (models.ListRole, error) {
|
||||
var out models.ListRole
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, models.RoleTypeList, pq.Array([]string{})); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertListPermissions upserts permission for a role.
|
||||
func (c *Core) UpsertListPermissions(roleID int, lp []models.ListPermission) error {
|
||||
var (
|
||||
listIDs = make([]int, 0, len(lp))
|
||||
listPerms = make([][]string, 0, len(lp))
|
||||
)
|
||||
for _, p := range lp {
|
||||
if len(p.Permissions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
listIDs = append(listIDs, p.ID)
|
||||
|
||||
// For the Postgres array unnesting query to work, all permissions arrays should
|
||||
// have equal number of entries. Add "" in case there's only one of either list:get or list:manage
|
||||
perms := make([]string, 2)
|
||||
copy(perms[:], p.Permissions[:])
|
||||
listPerms = append(listPerms, perms)
|
||||
}
|
||||
|
||||
if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteListPermission deletes a list permission entry from a role.
|
||||
func (c *Core) DeleteListPermission(roleID, listID int) error {
|
||||
if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserRole updates a given role.
|
||||
func (c *Core) UpdateUserRole(id int, r models.Role) (models.Role, error) {
|
||||
var out models.Role
|
||||
|
||||
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array(r.Permissions)); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.userRole}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if out.ID == 0 {
|
||||
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.userRole}"))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateListRole updates a given role.
|
||||
func (c *Core) UpdateListRole(id int, r models.ListRole) (models.ListRole, error) {
|
||||
var out models.ListRole
|
||||
|
||||
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array([]string{})); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.listRole}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if out.ID == 0 {
|
||||
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.listRole}"))
|
||||
}
|
||||
|
||||
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.listRole}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a given role.
|
||||
func (c *Core) DeleteRole(id int) error {
|
||||
if _, err := c.q.DeleteRole.Exec(id); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -43,6 +43,27 @@ func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, err
|
|||
return out[0], nil
|
||||
}
|
||||
|
||||
// HasSubscriberLists checks if the given subscribers have at least one of the given lists.
|
||||
func (c *Core) HasSubscriberLists(subIDs []int, listIDs []int) (map[int]bool, error) {
|
||||
res := []struct {
|
||||
SubID int `db:"subscriber_id"`
|
||||
Has bool `db:"has"`
|
||||
}{}
|
||||
|
||||
if err := c.q.HasSubscriberLists.Select(&res, pq.Array(subIDs), pq.Array(listIDs)); err != nil {
|
||||
c.log.Printf("error fetching subscriber: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out := make(map[int]bool, len(res))
|
||||
for _, r := range res {
|
||||
out[r.SubID] = r.Has
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSubscribersByEmail fetches a subscriber by one of the given params.
|
||||
func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscribers, error) {
|
||||
var out models.Subscribers
|
||||
|
|
214
internal/core/users.go
Normal file
214
internal/core/users.go
Normal file
|
@ -0,0 +1,214 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// GetUsers retrieves all users.
|
||||
func (c *Core) GetUsers() ([]models.User, error) {
|
||||
out, err := c.getUsers(0, "", "")
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetUser retrieves a specific user based on any one given identifier.
|
||||
func (c *Core) GetUser(id int, username, email string) (models.User, error) {
|
||||
out, err := c.getUsers(id, username, strings.ToLower(email))
|
||||
if err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (c *Core) CreateUser(u models.User) (models.User, error) {
|
||||
var id int
|
||||
|
||||
// If it's an API user, generate a random token for password
|
||||
// and set the e-mail to default.
|
||||
if u.Type == models.UserTypeAPI {
|
||||
// Generate a random admin password.
|
||||
tk, err := utils.GenerateRandomString(32)
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
u.Email = null.String{String: u.Username + "@api", Valid: true}
|
||||
u.PasswordLogin = false
|
||||
u.Password = null.String{String: tk, Valid: true}
|
||||
}
|
||||
|
||||
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, u.ListRoleID, u.Status); err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Hide the password field in the response except for when the user type is an API token,
|
||||
// where the frontend shows the token on the UI just once.
|
||||
if u.Type != models.UserTypeAPI {
|
||||
u.Password = null.String{Valid: false}
|
||||
}
|
||||
|
||||
out, err := c.GetUser(id, "", "")
|
||||
return out, err
|
||||
}
|
||||
|
||||
// UpdateUser updates a given user.
|
||||
func (c *Core) UpdateUser(id int, u models.User) (models.User, error) {
|
||||
listRoleID := 0
|
||||
if u.ListRoleID == nil {
|
||||
listRoleID = -1
|
||||
} else {
|
||||
listRoleID = *u.ListRoleID
|
||||
}
|
||||
|
||||
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, listRoleID, u.Status)
|
||||
if err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
|
||||
}
|
||||
|
||||
out, err := c.GetUser(id, "", "")
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
// UpdateUserProfile updates the basic fields of a given uesr (name, email, password).
|
||||
func (c *Core) UpdateUserProfile(id int, u models.User) (models.User, error) {
|
||||
res, err := c.q.UpdateUserProfile.Exec(id, u.Name, u.Email, u.PasswordLogin, u.Password)
|
||||
if err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
|
||||
}
|
||||
|
||||
return c.GetUser(id, "", "")
|
||||
}
|
||||
|
||||
// UpdateUserLogin updates a user's record post-login.
|
||||
func (c *Core) UpdateUserLogin(id int, avatar string) error {
|
||||
if _, err := c.q.UpdateUserLogin.Exec(id, avatar); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUsers deletes a given user.
|
||||
func (c *Core) DeleteUsers(ids []int) error {
|
||||
res, err := c.q.DeleteUsers.Exec(pq.Array(ids))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if num, err := res.RowsAffected(); err != nil || num == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginUser attempts to log the given user_id in by matching the password.
|
||||
func (c *Core) LoginUser(username, password string) (models.User, error) {
|
||||
var out models.User
|
||||
if err := c.q.LoginUser.Get(&out, username, password); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return out, echo.NewHTTPError(http.StatusForbidden,
|
||||
c.i18n.T("users.invalidLogin"))
|
||||
}
|
||||
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Core) getUsers(id int, username, email string) ([]models.User, error) {
|
||||
out := []models.User{}
|
||||
if err := c.q.GetUsers.Select(&out, id, username, email); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
for n, u := range out {
|
||||
u := u
|
||||
|
||||
if u.Password.String != "" {
|
||||
u.HasPassword = true
|
||||
u.PasswordLogin = true
|
||||
}
|
||||
|
||||
if u.Type == models.UserTypeAPI {
|
||||
u.Email = null.String{}
|
||||
}
|
||||
|
||||
u.UserRole.ID = u.UserRoleID
|
||||
u.UserRole.Name = u.UserRoleName
|
||||
u.UserRole.Permissions = u.UserRolePerms
|
||||
u.UserRoleID = 0
|
||||
|
||||
// Prepare lookup maps.
|
||||
u.ListPermissionsMap = make(map[int]map[string]struct{})
|
||||
u.PermissionsMap = make(map[string]struct{})
|
||||
for _, p := range u.UserRolePerms {
|
||||
u.PermissionsMap[p] = struct{}{}
|
||||
}
|
||||
|
||||
if u.ListRoleID != nil {
|
||||
// Unmarshall the raw list perms map.
|
||||
var listPerms []models.ListPermission
|
||||
if u.ListsPermsRaw != nil {
|
||||
if err := json.Unmarshal(*u.ListsPermsRaw, &listPerms); err != nil {
|
||||
c.log.Printf("error unmarshalling list permissions for role %d: %v", u.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
u.ListRole = &models.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
|
||||
|
||||
for _, p := range listPerms {
|
||||
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
|
||||
|
||||
for _, perm := range p.Permissions {
|
||||
u.ListPermissionsMap[p.ID][perm] = struct{}{}
|
||||
|
||||
// List IDs with get / manage permissions.
|
||||
if perm == "list:get" {
|
||||
u.GetListIDs = append(u.GetListIDs, p.ID)
|
||||
}
|
||||
if perm == "list:manage" {
|
||||
u.ManageListIDs = append(u.ManageListIDs, p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out[n] = u
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
151
internal/migrations/v4.0.0.go
Normal file
151
internal/migrations/v4.0.0.go
Normal file
|
@ -0,0 +1,151 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// V4_0_0 performs the DB migrations.
|
||||
func V4_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
|
||||
CREATE TYPE user_type AS ENUM ('user', 'api');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
|
||||
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
||||
CREATE TYPE role_type AS ENUM ('user', 'list');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type role_type NOT NULL DEFAULT 'user',
|
||||
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
name TEXT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS roles_idx ON roles (parent_id, list_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS roles_name_idx ON roles (type, name) WHERE name IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_login BOOLEAN NOT NULL DEFAULT false,
|
||||
password TEXT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar TEXT NULL,
|
||||
type user_type NOT NULL DEFAULT 'user',
|
||||
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
|
||||
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
status user_status NOT NULL DEFAULT 'disabled',
|
||||
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
data JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}') ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert superuser role.
|
||||
pmRaw, err := fs.Read("/permissions.json")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading permissions file: %v", err)
|
||||
}
|
||||
permGroups := []struct {
|
||||
Group string `json:"group"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}{}
|
||||
if err := json.Unmarshal(pmRaw, &permGroups); err != nil {
|
||||
lo.Fatalf("error loading permissions file: %v", err)
|
||||
}
|
||||
|
||||
perms := []string{}
|
||||
for _, group := range permGroups {
|
||||
for _, p := range group.Permissions {
|
||||
perms = append(perms, p)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO roles (type, name, permissions) VALUES('user', 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create super admin.
|
||||
var (
|
||||
user = os.Getenv("LISTMONK_ADMIN_USER")
|
||||
password = os.Getenv("LISTMONK_ADMIN_PASSWORD")
|
||||
typ = "env"
|
||||
)
|
||||
|
||||
if user != "" {
|
||||
// If the env vars are set, use those values
|
||||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars")
|
||||
}
|
||||
} else if ko.Exists("app.admin_username") {
|
||||
// Legacy admin/password are set in the config or env var. Use those.
|
||||
user = ko.String("app.admin_username")
|
||||
password = ko.String("app.admin_password")
|
||||
|
||||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars")
|
||||
}
|
||||
typ = "legacy config"
|
||||
} else {
|
||||
// None are set. Auto-generate.
|
||||
user = "admin"
|
||||
if p, err := utils.GenerateRandomString(12); err != nil {
|
||||
lo.Fatal("error generating admin password")
|
||||
} else {
|
||||
password = p
|
||||
}
|
||||
typ = "auto-generated"
|
||||
}
|
||||
|
||||
lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'user', 1, 'enabled') ON CONFLICT DO NOTHING;
|
||||
`, user, password, user+"@listmonk", user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if typ == "auto-generated" {
|
||||
fmt.Printf("\n\033[31mIMPORTANT! CHANGE PASSWORD AFTER LOGGING IN\033[0m\nusername: \033[32m%s\033[0m and password: \033[32m%s\033[0m\n\n", user, password)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
53
internal/utils/utils.go
Normal file
53
internal/utils/utils.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateEmail validates whether the given string is a correctly formed e-mail address.
|
||||
func ValidateEmail(email string) bool {
|
||||
// Since `mail.ParseAddress` parses an email address which can also contain an optional name component,
|
||||
// here we check if incoming email string is same as the parsed email.Address. So this eliminates
|
||||
// any valid email address with name and also valid address with empty name like `<abc@example.com>`.
|
||||
em, err := mail.ParseAddress(email)
|
||||
if err != nil || em.Address != email {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically random, alphanumeric string of length n.
|
||||
func GenerateRandomString(n int) (string, error) {
|
||||
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
var bytes = make([]byte, n)
|
||||
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for k, v := range bytes {
|
||||
bytes[k] = dictionary[v%byte(len(dictionary))]
|
||||
}
|
||||
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// SanitizeURI takes a URL or URI, removes the domain from it, returns only the URI.
|
||||
// This is used for cleaning "next" redirect URLs/URIs to prevent open redirects.
|
||||
func SanitizeURI(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
return "/"
|
||||
}
|
||||
|
||||
p, err := url.Parse(u)
|
||||
if err != nil || strings.Contains(p.Path, "..") {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return path.Clean(p.Path)
|
||||
}
|
130
models/models.go
130
models/models.go
|
@ -56,11 +56,15 @@ const (
|
|||
ListOptinDouble = "double"
|
||||
|
||||
// User.
|
||||
UserTypeSuperadmin = "superadmin"
|
||||
UserTypeUser = "user"
|
||||
UserTypeAPI = "api"
|
||||
UserStatusEnabled = "enabled"
|
||||
UserStatusDisabled = "disabled"
|
||||
|
||||
// Role.
|
||||
RoleTypeUser = "user"
|
||||
RoleTypeList = "list"
|
||||
|
||||
// BaseTpl is the name of the base template.
|
||||
BaseTpl = "base"
|
||||
|
||||
|
@ -148,11 +152,82 @@ type Base struct {
|
|||
type User struct {
|
||||
Base
|
||||
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"-"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Username string `db:"username" json:"username"`
|
||||
|
||||
// For API users, this is the plaintext API token.
|
||||
Password null.String `db:"password" json:"password,omitempty"`
|
||||
PasswordLogin bool `db:"password_login" json:"password_login"`
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Avatar null.String `db:"avatar" json:"avatar"`
|
||||
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
|
||||
|
||||
// Role struct {
|
||||
// ID int `db:"-" json:"id"`
|
||||
// Name string `db:"-" json:"name"`
|
||||
// Permissions []string `db:"-" json:"permissions"`
|
||||
// Lists []ListPermission `db:"-" json:"lists"`
|
||||
// } `db:"-" json:"role"`
|
||||
|
||||
// Filled post-retrieval.
|
||||
UserRole struct {
|
||||
ID int `db:"-" json:"id"`
|
||||
Name string `db:"-" json:"name"`
|
||||
Permissions []string `db:"-" json:"permissions"`
|
||||
} `db:"-" json:"user_role"`
|
||||
|
||||
ListRole *ListRolePermissions `db:"-" json:"list_role"`
|
||||
|
||||
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
|
||||
UserRoleName string `db:"user_role_name" json:"-"`
|
||||
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
|
||||
ListRoleName null.String `db:"list_role_name" json:"-"`
|
||||
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
|
||||
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
|
||||
|
||||
PermissionsMap map[string]struct{} `db:"-" json:"-"`
|
||||
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
|
||||
GetListIDs []int `db:"-" json:"-"`
|
||||
ManageListIDs []int `db:"-" json:"-"`
|
||||
HasPassword bool `db:"-" json:"-"`
|
||||
}
|
||||
|
||||
type ListPermission struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Permissions pq.StringArray `json:"permissions"`
|
||||
}
|
||||
|
||||
type ListRolePermissions struct {
|
||||
ID int `db:"-" json:"id"`
|
||||
Name string `db:"-" json:"name"`
|
||||
Lists []ListPermission `db:"-" json:"lists"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
Base
|
||||
|
||||
Type string `db:"type" json:"type"`
|
||||
Name null.String `db:"name" json:"name"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
||||
|
||||
ListID null.Int `db:"list_id" json:"-"`
|
||||
ParentID null.Int `db:"parent_id" json:"-"`
|
||||
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
|
||||
Lists []ListPermission `db:"-" json:"lists"`
|
||||
}
|
||||
|
||||
type ListRole struct {
|
||||
Base
|
||||
|
||||
Name null.String `db:"name" json:"name"`
|
||||
|
||||
ListID null.Int `db:"list_id" json:"-"`
|
||||
ParentID null.Int `db:"parent_id" json:"-"`
|
||||
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
|
||||
Lists []ListPermission `db:"-" json:"lists"`
|
||||
}
|
||||
|
||||
// Subscriber represents an e-mail subscriber.
|
||||
|
@ -266,6 +341,7 @@ type Campaign struct {
|
|||
// List of media (attachment) IDs obtained from the next-campaign query
|
||||
// while sending a campaign.
|
||||
MediaIDs pq.Int64Array `json:"-" db:"media_id"`
|
||||
|
||||
// Fetched bodies of the attachments.
|
||||
Attachments []Attachment `json:"-" db:"-"`
|
||||
|
||||
|
@ -738,3 +814,45 @@ func (h Headers) Value() (driver.Value, error) {
|
|||
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
func (u *User) HasPerm(perm string) bool {
|
||||
_, ok := u.PermissionsMap[perm]
|
||||
return ok
|
||||
}
|
||||
|
||||
// FilterListsByPerm returns list IDs filtered by either of the given perms.
|
||||
func (u *User) FilterListsByPerm(listIDs []int, get, manage bool) []int {
|
||||
// If the user has full list management permission,
|
||||
// no further checks are required.
|
||||
if get {
|
||||
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
|
||||
return listIDs
|
||||
}
|
||||
}
|
||||
if manage {
|
||||
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
|
||||
return listIDs
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]int, 0, len(listIDs))
|
||||
|
||||
// Go through every list ID.
|
||||
for _, id := range listIDs {
|
||||
// Check if it exists in the map.
|
||||
if l, ok := u.ListPermissionsMap[id]; ok {
|
||||
// Check if any of the given permission exists for it.
|
||||
if get {
|
||||
if _, ok := l[PermListGet]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
} else if manage {
|
||||
if _, ok := l[PermListManage]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ type Queries struct {
|
|||
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
|
||||
UpsertBlocklistSubscriber *sqlx.Stmt `query:"upsert-blocklist-subscriber"`
|
||||
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
|
||||
HasSubscriberLists *sqlx.Stmt `query:"has-subscriber-list"`
|
||||
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
|
||||
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
|
||||
GetSubscriptions *sqlx.Stmt `query:"get-subscriptions"`
|
||||
|
@ -107,6 +108,23 @@ type Queries struct {
|
|||
DeleteBounces *sqlx.Stmt `query:"delete-bounces"`
|
||||
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
|
||||
GetDBInfo string `query:"get-db-info"`
|
||||
|
||||
CreateUser *sqlx.Stmt `query:"create-user"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
UpdateUserProfile *sqlx.Stmt `query:"update-user-profile"`
|
||||
UpdateUserLogin *sqlx.Stmt `query:"update-user-login"`
|
||||
DeleteUsers *sqlx.Stmt `query:"delete-users"`
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
|
||||
LoginUser *sqlx.Stmt `query:"login-user"`
|
||||
|
||||
CreateRole *sqlx.Stmt `query:"create-role"`
|
||||
GetUserRoles *sqlx.Stmt `query:"get-user-roles"`
|
||||
GetListRoles *sqlx.Stmt `query:"get-list-roles"`
|
||||
UpdateRole *sqlx.Stmt `query:"update-role"`
|
||||
DeleteRole *sqlx.Stmt `query:"delete-role"`
|
||||
UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`
|
||||
DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"`
|
||||
}
|
||||
|
||||
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions
|
||||
|
|
|
@ -40,6 +40,13 @@ type Settings struct {
|
|||
SecurityCaptchaKey string `json:"security.captcha_key"`
|
||||
SecurityCaptchaSecret string `json:"security.captcha_secret"`
|
||||
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ProviderURL string `json:"provider_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
} `json:"security.oidc"`
|
||||
|
||||
UploadProvider string `json:"upload.provider"`
|
||||
UploadExtensions []string `json:"upload.extensions"`
|
||||
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
|
||||
|
|
75
permissions.json
Normal file
75
permissions.json
Normal file
|
@ -0,0 +1,75 @@
|
|||
[
|
||||
{
|
||||
"group": "lists",
|
||||
"permissions":
|
||||
[
|
||||
"lists:get_all",
|
||||
"lists:manage_all"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "subscribers",
|
||||
"permissions":
|
||||
[
|
||||
"subscribers:get",
|
||||
"subscribers:get_all",
|
||||
"subscribers:manage",
|
||||
"subscribers:import",
|
||||
"subscribers:sql_query",
|
||||
"tx:send"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "campaigns",
|
||||
"permissions":
|
||||
[
|
||||
"campaigns:get",
|
||||
"campaigns:get_analytics",
|
||||
"campaigns:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "bounces",
|
||||
"permissions":
|
||||
[
|
||||
"bounces:get",
|
||||
"bounces:manage",
|
||||
"webhooks:post_bounce"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "media",
|
||||
"permissions":
|
||||
[
|
||||
"media:get",
|
||||
"media:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "templates",
|
||||
"permissions":
|
||||
[
|
||||
"templates:get",
|
||||
"templates:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "users",
|
||||
"permissions":
|
||||
[
|
||||
"users:get",
|
||||
"users:manage",
|
||||
"roles:get",
|
||||
"roles:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "settings",
|
||||
"permissions":
|
||||
[
|
||||
"settings:get",
|
||||
"settings:manage",
|
||||
"settings:maintain"
|
||||
]
|
||||
}
|
||||
]
|
212
queries.sql
212
queries.sql
|
@ -1,4 +1,3 @@
|
|||
|
||||
-- subscribers
|
||||
-- name: get-subscriber
|
||||
-- Get a single subscriber by id or UUID or email.
|
||||
|
@ -9,6 +8,16 @@ SELECT * FROM subscribers WHERE
|
|||
WHEN $3 != '' THEN email = $3
|
||||
END;
|
||||
|
||||
-- name: has-subscriber-list
|
||||
-- Used for checking access permission by list.
|
||||
SELECT s.id AS subscriber_id,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM subscriber_lists sl WHERE sl.subscriber_id = s.id AND sl.list_id = ANY($2))
|
||||
THEN TRUE
|
||||
ELSE FALSE
|
||||
END AS has
|
||||
FROM subscribers s WHERE s.id = ANY($1);
|
||||
|
||||
-- name: get-subscribers-by-emails
|
||||
-- Get subscribers by emails.
|
||||
SELECT * FROM subscribers WHERE email=ANY($1);
|
||||
|
@ -179,7 +188,6 @@ INSERT INTO subscriber_lists (subscriber_id, list_id, status)
|
|||
-- When subscriber is edited from the admin form, retain the status. Otherwise, a blocklisted
|
||||
-- subscriber when being re-enabled, their subscription statuses change.
|
||||
WHEN subscriber_lists.status = 'confirmed' THEN 'confirmed'
|
||||
WHEN $9 = TRUE THEN subscriber_lists.status
|
||||
ELSE $8::subscription_status
|
||||
END
|
||||
);
|
||||
|
@ -413,6 +421,10 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
|
|||
-- lists
|
||||
-- name: get-lists
|
||||
SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type END)
|
||||
AND CASE
|
||||
-- Optional list IDs based on user permission.
|
||||
WHEN $3 = TRUE THEN TRUE ELSE id = ANY($4::INT[])
|
||||
END
|
||||
ORDER BY CASE WHEN $2 = 'id' THEN id END, CASE WHEN $2 = 'name' THEN name END;
|
||||
|
||||
-- name: query-lists
|
||||
|
@ -427,7 +439,11 @@ WITH ls AS (
|
|||
AND ($4 = '' OR type = $4::list_type)
|
||||
AND ($5 = '' OR optin = $5::list_optin)
|
||||
AND (CARDINALITY($6::VARCHAR(100)[]) = 0 OR $6 <@ tags)
|
||||
OFFSET $7 LIMIT (CASE WHEN $8 < 1 THEN NULL ELSE $8 END)
|
||||
AND CASE
|
||||
-- Optional list IDs based on user permission.
|
||||
WHEN $7 = TRUE THEN TRUE ELSE id = ANY($8::INT[])
|
||||
END
|
||||
OFFSET $9 LIMIT (CASE WHEN $10 < 1 THEN NULL ELSE $10 END)
|
||||
),
|
||||
statuses AS (
|
||||
SELECT
|
||||
|
@ -873,28 +889,6 @@ WITH view AS (
|
|||
INSERT INTO campaign_views (campaign_id, subscriber_id)
|
||||
VALUES((SELECT campaign_id FROM view), (SELECT subscriber_id FROM view));
|
||||
|
||||
-- users
|
||||
-- name: get-users
|
||||
SELECT * FROM users WHERE $1 = 0 OR id = $1 OFFSET $2 LIMIT $3;
|
||||
|
||||
-- name: create-user
|
||||
INSERT INTO users (email, name, password, type, status) VALUES($1, $2, $3, $4, $5) RETURNING id;
|
||||
|
||||
-- name: update-user
|
||||
UPDATE users SET
|
||||
email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
|
||||
name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
|
||||
password=(CASE WHEN $4 != '' THEN $4 ELSE password END),
|
||||
type=(CASE WHEN $5 != '' THEN $5::user_type ELSE type END),
|
||||
status=(CASE WHEN $6 != '' THEN $6::user_status ELSE status END),
|
||||
updated_at=NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: delete-user
|
||||
-- Delete a user, except for the primordial super admin.
|
||||
DELETE FROM users WHERE $1 != 1 AND id=$1;
|
||||
|
||||
|
||||
-- templates
|
||||
-- name: get-templates
|
||||
-- Only if the second param ($2) is true, body is returned.
|
||||
|
@ -1048,3 +1042,171 @@ DELETE FROM bounces WHERE subscriber_id = (SELECT id FROM sub);
|
|||
-- name: get-db-info
|
||||
SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),
|
||||
'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info;
|
||||
|
||||
-- name: create-user
|
||||
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, list_role_id, status)
|
||||
VALUES($1, $2, (
|
||||
CASE
|
||||
-- For user types with password_login enabled, bcrypt and store the hash of the password.
|
||||
WHEN $6::user_type != 'api' AND $2 AND $3 != ''
|
||||
THEN CRYPT($3, GEN_SALT('bf'))
|
||||
WHEN $6 = 'api'
|
||||
-- For APIs, store the password (token) as-is.
|
||||
THEN $3
|
||||
ELSE NULL
|
||||
END
|
||||
), $4, $5, $6, (SELECT id FROM roles WHERE id = $7 AND type = 'user'), (SELECT id FROM roles WHERE id = $8 AND type = 'list'), $9) RETURNING id;
|
||||
|
||||
-- name: update-user
|
||||
WITH u AS (
|
||||
-- Edit is only allowed if there are more than 1 active super users or
|
||||
-- if the only superadmin user's status/role isn't being changed.
|
||||
SELECT
|
||||
CASE
|
||||
WHEN (SELECT COUNT(*) FROM users WHERE id != $1 AND status = 'enabled' AND type = 'user' AND user_role_id = 1) = 0 AND ($8 != 1 OR $10 != 'enabled')
|
||||
THEN FALSE
|
||||
ELSE TRUE
|
||||
END AS canEdit
|
||||
)
|
||||
UPDATE users SET
|
||||
username=(CASE WHEN $2 != '' THEN $2 ELSE username END),
|
||||
password_login=$3,
|
||||
password=(CASE WHEN $3 = TRUE THEN (CASE WHEN $4 != '' THEN CRYPT($4, GEN_SALT('bf')) ELSE password END) ELSE NULL END),
|
||||
email=(CASE WHEN $5 != '' THEN $5 ELSE email END),
|
||||
name=(CASE WHEN $6 != '' THEN $6 ELSE name END),
|
||||
type=(CASE WHEN $7 != '' THEN $7::user_type ELSE type END),
|
||||
user_role_id=(CASE WHEN $8 != 0 THEN (SELECT id FROM roles WHERE id = $8 AND type = 'user') ELSE user_role_id END),
|
||||
list_role_id=(
|
||||
CASE
|
||||
WHEN $9 < 0 THEN NULL
|
||||
WHEN $9 > 0 THEN (SELECT id FROM roles WHERE id = $9 AND type = 'list')
|
||||
ELSE list_role_id END
|
||||
),
|
||||
status=(CASE WHEN $10 != '' THEN $10::user_status ELSE status END),
|
||||
updated_at=NOW()
|
||||
WHERE id=$1 AND (SELECT canEdit FROM u) = TRUE;
|
||||
|
||||
-- name: delete-users
|
||||
WITH u AS (
|
||||
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND user_role_id=1 AND status='enabled'
|
||||
)
|
||||
DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0;
|
||||
|
||||
-- name: get-users
|
||||
WITH ur AS (
|
||||
SELECT id, name, permissions FROM roles WHERE type = 'user' AND parent_id IS NULL
|
||||
),
|
||||
lr AS (
|
||||
SELECT r.id, r.name, r.permissions, r.list_id, l.name AS list_name
|
||||
FROM roles r
|
||||
LEFT JOIN lists l ON r.list_id = l.id
|
||||
WHERE r.type = 'list' AND r.parent_id IS NULL
|
||||
),
|
||||
lp AS (
|
||||
SELECT lr.id AS list_role_id,
|
||||
JSONB_AGG(
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', COALESCE(cr.list_id, lr.list_id),
|
||||
'name', COALESCE(cl.name, lr.list_name),
|
||||
'permissions', COALESCE(cr.permissions, lr.permissions)
|
||||
)
|
||||
) AS list_role_perms
|
||||
FROM lr
|
||||
LEFT JOIN roles cr ON cr.parent_id = lr.id AND cr.type = 'list'
|
||||
LEFT JOIN lists cl ON cr.list_id = cl.id
|
||||
GROUP BY lr.id
|
||||
)
|
||||
SELECT
|
||||
users.*,
|
||||
ur.id AS user_role_id,
|
||||
ur.name AS user_role_name,
|
||||
ur.permissions AS user_role_permissions,
|
||||
lp.list_role_id,
|
||||
lr.name AS list_role_name,
|
||||
lp.list_role_perms
|
||||
FROM users
|
||||
LEFT JOIN ur ON users.user_role_id = ur.id
|
||||
LEFT JOIN lp ON users.list_role_id = lp.list_role_id
|
||||
LEFT JOIN lr ON lp.list_role_id = lr.id
|
||||
WHERE
|
||||
(
|
||||
CASE
|
||||
WHEN $1::INT != 0 THEN users.id = $1
|
||||
WHEN $2::TEXT != '' THEN username = $2
|
||||
WHEN $3::TEXT != '' THEN email = $3
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
ORDER BY users.created_at;
|
||||
|
||||
|
||||
-- name: get-api-tokens
|
||||
SELECT username, password FROM users WHERE status='enabled' AND type='api';
|
||||
|
||||
-- name: login-user
|
||||
WITH u AS (
|
||||
SELECT users.*, r.name as role_name, r.permissions FROM users
|
||||
LEFT JOIN roles r ON (r.id = users.user_role_id)
|
||||
WHERE username=$1 AND status != 'disabled' AND password_login = TRUE
|
||||
)
|
||||
SELECT * FROM u WHERE CRYPT($2, password) = password;
|
||||
|
||||
-- name: update-user-profile
|
||||
UPDATE users SET name=$2, email=(CASE WHEN password_login THEN $3 ELSE email END),
|
||||
password=(CASE WHEN $4 = TRUE THEN (CASE WHEN $5 != '' THEN CRYPT($5, GEN_SALT('bf')) ELSE password END) ELSE NULL END)
|
||||
WHERE id=$1;
|
||||
|
||||
-- name: update-user-login
|
||||
UPDATE users SET loggedin_at=NOW(), avatar=(CASE WHEN $2 != '' THEN $2 ELSE avatar END) WHERE id=$1;
|
||||
|
||||
-- name: get-user-roles
|
||||
WITH mainroles AS (
|
||||
SELECT ur.* FROM roles ur WHERE type = 'user' AND ur.parent_id IS NULL
|
||||
),
|
||||
listPerms AS (
|
||||
SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
|
||||
FROM roles ur
|
||||
LEFT JOIN lists ON(lists.id = ur.list_id)
|
||||
WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id
|
||||
)
|
||||
SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p
|
||||
LEFT JOIN listPerms l ON p.id = l.parent_id ORDER BY p.created_at;
|
||||
|
||||
-- name: get-list-roles
|
||||
WITH mainroles AS (
|
||||
SELECT ur.* FROM roles ur WHERE type = 'list' AND ur.parent_id IS NULL
|
||||
),
|
||||
listPerms AS (
|
||||
SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
|
||||
FROM roles ur
|
||||
LEFT JOIN lists ON(lists.id = ur.list_id)
|
||||
WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id
|
||||
)
|
||||
SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p
|
||||
LEFT JOIN listPerms l ON p.id = l.parent_id ORDER BY p.created_at;
|
||||
|
||||
|
||||
-- name: create-role
|
||||
INSERT INTO roles (name, type, permissions, created_at, updated_at) VALUES($1, $2, $3, NOW(), NOW()) RETURNING *;
|
||||
|
||||
-- name: upsert-list-permissions
|
||||
WITH d AS (
|
||||
-- Delete lists that aren't included.
|
||||
DELETE FROM roles WHERE parent_id = $1 AND list_id != ALL($2::INT[])
|
||||
),
|
||||
p AS (
|
||||
-- Get (list_id, perms[]), (list_id, perms[])
|
||||
SELECT UNNEST($2) AS list_id, JSONB_ARRAY_ELEMENTS(TO_JSONB($3::TEXT[][])) AS perms
|
||||
)
|
||||
INSERT INTO roles (parent_id, list_id, permissions, type)
|
||||
SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), ''), 'list' FROM p
|
||||
ON CONFLICT (parent_id, list_id) DO UPDATE SET permissions = EXCLUDED.permissions;
|
||||
|
||||
-- name: delete-list-permission
|
||||
DELETE FROM roles WHERE parent_id=$1 AND list_id=$2;
|
||||
|
||||
-- name: update-role
|
||||
UPDATE roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *;
|
||||
|
||||
-- name: delete-role
|
||||
DELETE FROM roles WHERE id=$1;
|
||||
|
|
47
schema.sql
47
schema.sql
|
@ -7,6 +7,11 @@ DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('r
|
|||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
|
||||
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
|
||||
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'api');
|
||||
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
||||
DROP TYPE IF EXISTS role_type CASCADE; CREATE TYPE role_type AS ENUM ('user', 'list');
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- subscribers
|
||||
DROP TABLE IF EXISTS subscribers CASCADE;
|
||||
|
@ -246,6 +251,7 @@ INSERT INTO settings (key, value) VALUES
|
|||
('security.enable_captcha', 'false'),
|
||||
('security.captcha_key', '""'),
|
||||
('security.captcha_secret', '""'),
|
||||
('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}'),
|
||||
('upload.provider', '"filesystem"'),
|
||||
('upload.max_file_size', '5000'),
|
||||
('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'),
|
||||
|
@ -295,7 +301,48 @@ DROP INDEX IF EXISTS idx_bounces_camp_id; CREATE INDEX idx_bounces_camp_id ON bo
|
|||
DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON bounces(source);
|
||||
DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces((TIMEZONE('UTC', created_at)::DATE));
|
||||
|
||||
-- roles
|
||||
DROP TABLE IF EXISTS roles CASCADE;
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type role_type NOT NULL DEFAULT 'user',
|
||||
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
name TEXT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX roles_idx ON roles (parent_id, list_id);
|
||||
CREATE UNIQUE INDEX roles_name_idx ON roles (type, name) WHERE name IS NOT NULL;
|
||||
|
||||
-- users
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_login BOOLEAN NOT NULL DEFAULT false,
|
||||
password TEXT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar TEXT NULL,
|
||||
type user_type NOT NULL DEFAULT 'user',
|
||||
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
|
||||
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
status user_status NOT NULL DEFAULT 'disabled',
|
||||
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- user sessions
|
||||
DROP TABLE IF EXISTS sessions CASCADE;
|
||||
CREATE TABLE sessions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
data JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
|
||||
);
|
||||
DROP INDEX IF EXISTS idx_sessions; CREATE INDEX idx_sessions ON sessions (id, created_at);
|
||||
|
||||
-- materialized views
|
||||
|
||||
|
|
BIN
static/public/static/auth/auth0.com.png
Normal file
BIN
static/public/static/auth/auth0.com.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
BIN
static/public/static/auth/github.com.png
Normal file
BIN
static/public/static/auth/github.com.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
static/public/static/auth/google.com.png
Normal file
BIN
static/public/static/auth/google.com.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
static/public/static/auth/microsoftonline.com.png
Normal file
BIN
static/public/static/auth/microsoftonline.com.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 199 B |
BIN
static/public/static/auth/oidc.png
Normal file
BIN
static/public/static/auth/oidc.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
|
@ -34,7 +34,7 @@ h4 {
|
|||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="email"], select {
|
||||
input[type="text"], input[type="email"], input[type="password"], select {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 3px;
|
||||
|
@ -61,6 +61,9 @@ input[disabled] {
|
|||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
.error {
|
||||
color: #FF5722;
|
||||
}
|
||||
.button {
|
||||
background: #0055d4;
|
||||
padding: 15px 30px;
|
||||
|
@ -84,7 +87,8 @@ input[disabled] {
|
|||
color: #0055d4;
|
||||
}
|
||||
.button.button-outline:hover {
|
||||
background-color: #0055d4;
|
||||
border-color: #333;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
@ -178,6 +182,21 @@ input[disabled] {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login .submit {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.login button {
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login button img {
|
||||
max-width: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#btn-back {
|
||||
display: none;
|
||||
}
|
||||
|
|
43
static/public/templates/login.html
Normal file
43
static/public/templates/login.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{{ define "admin-login" }}
|
||||
{{ template "header" .}}
|
||||
|
||||
<section class="login">
|
||||
<h2>{{ .L.T "users.login"}}</h2>
|
||||
{{ if .Data.PasswordEnabled }}
|
||||
<form method="post" action="{{ .RootURL }}/admin/login" class="form">
|
||||
<div>
|
||||
<input type="hidden" name="nonce" value="{{ .Data.Nonce }}" />
|
||||
<input type="hidden" name="next" value="{{ .Data.NextURI }}" />
|
||||
<p>
|
||||
<label for="username">{{ .L.T "users.username" }}</label>
|
||||
<input id="username" type="text" name="username" autofocus required minlength="3" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="password">{{ .L.T "users.password" }}</label>
|
||||
<input id="password" type="password" name="password" required minlength="8" />
|
||||
</p>
|
||||
|
||||
{{ if .Data.Error }}<p><span class="error">{{ .Data.Error }}</span></p>{{ end }}
|
||||
|
||||
<p class="submit"><button class="button" type="submit">{{ .L.T "users.login" }}</button></p>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Data.OIDCProvider }}
|
||||
<form method="post" action="{{ .RootURL }}/auth/oidc">
|
||||
<div>
|
||||
<input type="hidden" name="nonce" value="{{ .Data.Nonce }}" />
|
||||
<input type="hidden" name="next" value="{{ .Data.NextURI }}" />
|
||||
<p><button class="button button-outline" type="submit">
|
||||
<img src="{{ .RootURL }}/public/static/auth/{{ .Data.OIDCProviderLogo }}" alt="" />
|
||||
{{ .L.Ts "users.loginOIDC" "name" .Data.OIDCProvider }}
|
||||
</button></p>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
</section>
|
||||
|
||||
{{ template "footer" .}}
|
||||
{{ end }}
|
Loading…
Reference in a new issue