diff --git a/Makefile b/Makefile index 8e5345326..540e08303 100644 --- a/Makefile +++ b/Makefile @@ -143,3 +143,9 @@ rm-dev-docker: build ## Delete the docker containers including DB volumes. init-dev-docker: build-dev-docker ## Delete the docker containers including DB volumes. cd dev; \ docker compose run --rm backend sh -c "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml" + +# Test a db migration in the dev docker suite. +.PHONY: init-dev-docker +upgrade-dev-docker: build-dev-docker ## Delete the docker containers including DB volumes. + cd dev; \ + docker compose run --rm backend sh -c "make dist && ./listmonk --upgrade --idempotent --yes --config dev/config.toml" diff --git a/cmd/admin.go b/cmd/admin.go index ae8bd1789..c35b3af93 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -4,13 +4,21 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "syscall" "time" + "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/listmonk/internal/messenger/email" "github.com/labstack/echo/v4" null "gopkg.in/volatiletech/null.v6" ) +type messengerConfig struct { + Name string `json:"name"` + DefaultFromEmail string `json:"from_email"` +} + type serverConfig struct { RootURL string `json:"root_url"` FromEmail string `json:"from_email"` @@ -19,14 +27,14 @@ type serverConfig struct { CaptchaEnabled bool `json:"captcha_enabled"` CaptchaKey null.String `json:"captcha_key"` } `json:"public_subscription"` - 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"` + Messengers []messengerConfig `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"` } // GetServerConfig returns general server config. @@ -37,6 +45,7 @@ func (a *App) GetServerConfig(c echo.Context) error { Lang: a.cfg.Lang, Permissions: a.cfg.PermissionsRaw, HasLegacyUser: a.cfg.HasLegacyUser, + Messengers: []messengerConfig{}, } out.PublicSubscription.Enabled = a.cfg.EnablePublicSubPage if a.cfg.Security.EnableCaptcha { @@ -52,9 +61,27 @@ func (a *App) GetServerConfig(c echo.Context) error { } out.Langs = langList - out.Messengers = make([]string, 0, len(a.messengers)) - for _, m := range a.messengers { - out.Messengers = append(out.Messengers, m.Name()) + // List messengers user has access to. + if u, ok := c.Get(auth.UserHTTPCtxKey).(auth.User); ok { + hasPerm := u.HasPerm(auth.PermMessengersGetAll) + if hasPerm || len(u.Messengers) > 0 { + out.Messengers = make([]messengerConfig, 0, len(a.messengers)) + for _, m := range a.messengers { + if hasPerm || slices.Contains(u.Messengers, m.Name()) { + msgr := messengerConfig{Name: m.Name()} + if msgr.Name == emailMsgr { + msgr.DefaultFromEmail = a.cfg.FromEmail + } else if em, ok := m.(*email.Emailer); ok { + msgr.DefaultFromEmail = em.DefaultFromEmail + } + out.Messengers = append(out.Messengers, msgr) + } + } + } + } + // If messengers were renamed, or user has no messengers, fall back to default. + if len(out.Messengers) == 0 { + out.Messengers = []messengerConfig{{Name: emailMsgr, DefaultFromEmail: a.cfg.FromEmail}} } a.Lock() @@ -66,7 +93,7 @@ func (a *App) GetServerConfig(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } -// GetDashboardCharts returns chart data points to render ont he dashboard. +// GetDashboardCharts returns chart data points to render on the dashboard. func (a *App) GetDashboardCharts(c echo.Context) error { // Get the chart data from the DB. out, err := a.core.GetDashboardCharts() diff --git a/cmd/handlers.go b/cmd/handlers.go index f3aa6b515..36d710f47 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -141,7 +141,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) { // Individual list permissions are applied directly within handleGetLists. g.GET("/api/lists", a.GetLists) g.GET("/api/lists/:id", hasID(a.GetList)) - g.POST("/api/lists", pm(a.CreateList, "lists:manage_all")) + g.POST("/api/lists", pm(a.CreateList, "lists:create")) g.PUT("/api/lists/:id", hasID(a.UpdateList)) g.DELETE("/api/lists/:id", hasID(a.DeleteLists)) diff --git a/cmd/lists.go b/cmd/lists.go index a7ff9cdb2..d080b0e45 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -8,6 +8,7 @@ import ( "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" + "github.com/lib/pq" ) // GetLists retrieves lists with additional metadata like subscriber counts. @@ -106,6 +107,16 @@ func (a *App) CreateList(c echo.Context) error { return err } + if user := auth.GetUser(c); user.ListRoleID != nil { + if err := a.core.UpsertListPermissions(*user.ListRoleID, append(user.ListRole.Lists, auth.ListPermission{ + ID: out.ID, + Name: out.Name, + Permissions: pq.StringArray{auth.PermListManage, auth.PermListGet}, + })); err != nil { + return err + } + } + return c.JSON(http.StatusOK, okResp{out}) } diff --git a/cmd/roles.go b/cmd/roles.go index 2806e0e30..6d5699978 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -182,6 +182,11 @@ func (a *App) validateListRole(r auth.ListRole) error { if !strHasLen(r.Name.String, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "name")) } + for _, p := range r.Messengers { + if !strHasLen(p, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("messenger: %s", p))) + } + } for _, l := range r.Lists { for _, p := range l.Permissions { diff --git a/cmd/upgrade.go b/cmd/upgrade.go index c74f2c288..e935b1011 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -41,6 +41,7 @@ var migList = []migFunc{ {"v4.0.0", migrations.V4_0_0}, {"v4.1.0", migrations.V4_1_0}, {"v5.0.0", migrations.V5_0_0}, + {"v5.1.0-alpha1", migrations.V5_1_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/dev/app.Dockerfile b/dev/app.Dockerfile index 21f8bbe75..8c80f2acf 100644 --- a/dev/app.Dockerfile +++ b/dev/app.Dockerfile @@ -1,6 +1,6 @@ -FROM golang:1.20 AS go +FROM golang:1.24.1 AS go -FROM node:16 AS node +FROM node:18 AS node COPY --from=go /usr/local/go /usr/local/go ENV GOPATH /go diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index d378ac3b0..ff8f47f55 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: adminer: image: adminer:4.8.1-standalone diff --git a/docs/docs/content/roles-and-permissions.md b/docs/docs/content/roles-and-permissions.md index 62ab21a7b..70f2c91bf 100644 --- a/docs/docs/content/roles-and-permissions.md +++ b/docs/docs/content/roles-and-permissions.md @@ -1,13 +1,20 @@ -listmonk supports (>= v4.0.0) creating systems users with granular permissions to various features, including list-specific permissions. Users can login with a username and password, or via an OIDC (OpenID Connect) handshake if an auth provider is connected. Various permissions can be grouped into "user roles", which can be assigned to users. List-specific permissions can be grouped into "list roles". +listmonk supports (>= v4.0.0) creating systems users with granular permissions +to various features, including list-specific permissions. Users can login with +a username and password, or via an OIDC (OpenID Connect) handshake if an auth +provider is connected. Users can be assigned a "user role" to grant generic +user and app permissions and a "list role" to grant per-list and per-messenger +permissions, managed per-user in the _Admin -> Users -> Users_ UI. ## User roles -A user role is a collection of user related permissions. User roles are attached to user accounts. User roles can be managed in `Admin -> Users -> User roles` The permissions are described below. +A user role is a collection of generic user and app permissions, described +below. User roles can be managed in the _Admin -> Users -> User roles_ UI. | Group | Permission | Description | | ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | lists | lists:get_all | Get details of all lists | -| | lists:manage_all | Create, update, and delete all lists | +| | lists:manage_all | Update and delete all lists | +| | lists:create | Create new lists | | subscribers | subscribers:get | Get individual subscriber details | | | subscribers:get_all | Get all subscribers and their details | | | subscribers:manage | Add, update, and delete subscribers | @@ -18,6 +25,7 @@ A user role is a collection of user related permissions. User roles are attached | | campaigns:get_all | Get and view campaigns across all lists | | | campaigns:get_analytics | Access campaign performance metrics | | | campaigns:manage | Create, update, and delete campaigns | +| | messengers:get_all | Send campaigns to any/all configured email servers and custom senders | | bounces | bounces:get | Get email bounce records | | | bounces:manage | Process and handle bounced emails | | | webhooks:post_bounce | Receive bounce notifications via webhook | @@ -35,8 +43,44 @@ A user role is a collection of user related permissions. User roles are attached ## List roles -A list role is a collection of permissions assigned per list. Each list can be assigned a view (read) or manage (update) permission. List roles are attached to user accounts. Only the lists defined in a list role is accessible by the user, be it on the admin UI or via API calls. Do note that the `lists:get_all` and `lists:manage_all` permissions in user roles override all per-list permissions. +A list role is a collection of per-list and per-messenger permissions that can +be used to segment subscriber lists, messengers and campaigns to user groups. +List roles can be managed in the _Admin -> Users -> List roles_ UI. + +### Subscribers + +Each list can be assigned a view (read) or manage (update) permission. Users may +only access subscriber lists they have the view permission for, both within the +admin UI and via API calls. The `lists:get_all` and `lists:manage_all` user role +permissions override this behaviour, giving users access to all lists regardless. + +Lists created by a user with the `lists:create` user role permission will be +added to that user's list role with both view and manage permissions granted. +If a user does not have a list role, they may be unable to view created lists. + +- If you want users to be able to create lists shared with other users, + grant them `lists:create` and put them in the same list role. +- If you want users to be able to create lists hidden from other users, + grant them `lists:create` and put them in different list roles. +- If you do not want users to create/manage custom subscriber lists, + do not grant them `lists:create`. + +### Messengers + +Each named messenger can be enabled (checked) or disabled (unchecked). +Messengers can be configured in the _Admin -> Settings -> Settings -> SMTP_ +and _Admin -> Settings -> Settings -> Messengers_ UIs. Users may only send +campaigns to subscriber lists via [messengers](https://listmonk.app/docs/messengers/) +enabled by their list role. The `messengers:get_all` user role permission +overrides this behaviour, allowing a user to send from any/all messengers. + +A user must be able to send from at least one messenger. If a user has not been +granted any named messengers via their list role or use role permissions, they +will default to the generic `email` messenger. ## API users -A user account can be of two types, a regular user or an API user. API users are meant for intertacting with the listmonk APIs programmatically. Unlike regular user accounts that have custom passwords or OIDC for authentication, API users get an automatically generated secret token. +A user account can be of two types, a regular user or an API user. API users +are meant for intertacting with the listmonk APIs programmatically. Unlike +regular user accounts that have custom passwords or OIDC for authentication, +API users get an automatically generated secret token. diff --git a/frontend/src/main.js b/frontend/src/main.js index 46d11e67c..52a54e06e 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -33,7 +33,8 @@ router.afterEach((to) => { 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()]); + const [_profile, cfg] = await Promise.all([api.getUserProfile(), api.getServerConfig()]); + let profile = _profile; const lang = await api.getLang(cfg.lang); i18n.locale = cfg.lang; @@ -42,6 +43,9 @@ async function initConfig(app) { Vue.prototype.$utils = new Utils(i18n); Vue.prototype.$api = api; Vue.prototype.$events = app; + Vue.prototype.$refreshUser = async () => { + profile = await api.getUserProfile(); + }; // $can('permission:name') is used in the UI to check whether the logged in user // has a certain permission to toggle visibility of UI objects and UI functionality. diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index 739eca138..517d27613 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -85,18 +85,11 @@