diff --git a/cmd/handlers.go b/cmd/handlers.go index d9a8e79..7e5eb01 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -49,6 +49,14 @@ func initHTTPHandlers(e *echo.Echo, app *App) { g = e.Group("", middleware.BasicAuth(basicAuth)) } + e.HTTPErrorHandler = func(err error, c echo.Context) { + // Generic, non-echo error. Log it. + if _, ok := err.(*echo.HTTPError); !ok { + app.log.Println(err.Error()) + } + e.DefaultHTTPErrorHandler(err, c) + } + // Admin JS app views. // /admin/static/* file server is registered in initHTTPServer(). e.GET("/", func(c echo.Context) error { @@ -163,7 +171,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) { e.POST("/subscription/form", handleSubscriptionForm) e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID"))) - e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), + e.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")) diff --git a/cmd/init.go b/cmd/init.go index 4b0caf4..43ea3d2 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -60,6 +60,7 @@ type constants struct { DBBatchSize int `koanf:"batch_size"` Privacy struct { IndividualTracking bool `koanf:"individual_tracking"` + AllowPreferences bool `koanf:"allow_preferences"` AllowBlocklist bool `koanf:"allow_blocklist"` AllowExport bool `koanf:"allow_export"` AllowWipe bool `koanf:"allow_wipe"` diff --git a/cmd/public.go b/cmd/public.go index 2ce9cae..fa16c91 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -48,10 +48,14 @@ type publicTpl struct { type unsubTpl struct { publicTpl - SubUUID string - AllowBlocklist bool - AllowExport bool - AllowWipe bool + Subscriber models.Subscriber + Subscriptions []models.Subscription + SubUUID string + AllowBlocklist bool + AllowExport bool + AllowWipe bool + AllowPreferences bool + ShowManage bool } type optinTpl struct { @@ -175,36 +179,149 @@ func handleViewCampaignMessage(c echo.Context) error { // campaigns link to. func handleSubscriptionPage(c echo.Context) error { var ( - app = c.Get("app").(*App) - campUUID = c.Param("campUUID") - subUUID = c.Param("subUUID") - unsub = c.Request().Method == http.MethodPost - blocklist, _ = strconv.ParseBool(c.FormValue("blocklist")) - out = unsubTpl{} + app = c.Get("app").(*App) + subUUID = c.Param("subUUID") + showManage, _ = strconv.ParseBool(c.FormValue("manage")) + out = unsubTpl{} ) out.SubUUID = subUUID out.Title = app.i18n.T("public.unsubscribeTitle") out.AllowBlocklist = app.constants.Privacy.AllowBlocklist out.AllowExport = app.constants.Privacy.AllowExport out.AllowWipe = app.constants.Privacy.AllowWipe + out.AllowPreferences = app.constants.Privacy.AllowPreferences - // Unsubscribe. - if unsub { - // Is blocklisting allowed? - if !app.constants.Privacy.AllowBlocklist { - blocklist = false + if app.constants.Privacy.AllowPreferences { + out.ShowManage = showManage + } + + // Get the subscriber's lists. + subs, err := app.core.GetSubscriptions(0, subUUID, false) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists")) + } + + s, err := app.core.GetSubscriber(0, subUUID, "") + if err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) + } + out.Subscriber = s + + if s.Status == models.SubscriberStatusBlockListed { + return c.Render(http.StatusOK, tplMessage, + makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.blocklisted"))) + } + + // Filter out unrelated private lists. + if showManage { + out.Subscriptions = make([]models.Subscription, 0, len(subs)) + for _, s := range subs { + if s.Type == models.ListTypePrivate { + if s.SubscriptionStatus.IsZero() { + continue + } + + s.Name = app.i18n.T("public.subPrivateList") + } + + out.Subscriptions = append(out.Subscriptions, s) } + } + return c.Render(http.StatusOK, "subscription", out) +} + +// handleSubscriptionPage renders the subscription management page and +// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in +// campaigns link to. +func handleSubscriptionPrefs(c echo.Context) error { + var ( + app = c.Get("app").(*App) + campUUID = c.Param("campUUID") + subUUID = c.Param("subUUID") + + req struct { + Name string `form:"name" json:"name"` + ListUUIDs []string `form:"l" json:"list_uuids"` + Blocklist bool `form:"blocklist" json:"blocklist"` + Manage bool `form:"manage" json:"manage"` + } + ) + + // Read the form. + if err := c.Bind(&req); err != nil { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidData"))) + } + + // Simple unsubscribe. + blocklist := app.constants.Privacy.AllowBlocklist && req.Blocklist + if !req.Manage || blocklist { if err := app.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo"))) } - return c.Render(http.StatusOK, "subscription", out) + // Is preference management enabled? + if !app.constants.Privacy.AllowPreferences { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature"))) + } + + // Manage preferences. + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || len(req.Name) > 256 { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName"))) + } + + // Get the subscriber from the DB. + sub, err := app.core.GetSubscriber(0, subUUID, "") + if err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("globals.messages.pFound", + "name", app.i18n.T("globals.terms.subscriber")))) + } + sub.Name = req.Name + + // Update name. + if _, err := app.core.UpdateSubscriber(sub.ID, sub); err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) + } + + // Get the subscriber's lists and whatever is not sent in the request (unchecked), + // unsubscribe them. + subs, err := app.core.GetSubscriptions(0, subUUID, false) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists")) + } + reqUUIDs := make(map[string]struct{}) + for _, u := range req.ListUUIDs { + reqUUIDs[u] = struct{}{} + } + + unsubUUIDs := make([]string, 0, len(req.ListUUIDs)) + for _, s := range subs { + if _, ok := reqUUIDs[s.UUID]; !ok { + unsubUUIDs = append(unsubUUIDs, s.UUID) + } + } + + // Unsubscribe from lists. + if err := app.core.UnsubscribeLists([]int{sub.ID}, nil, unsubUUIDs); err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) + + } + + return c.Render(http.StatusOK, tplMessage, + makeMsgTpl(app.i18n.T("globals.messages.done"), "", app.i18n.T("public.prefsSaved"))) } // handleOptinPage renders the double opt-in confirmation page that subscribers @@ -306,7 +423,6 @@ func handleSubscriptionForm(c echo.Context) error { // If there's a nonce value, a bot could've filled the form. if c.FormValue("nonce") != "" { return echo.NewHTTPError(http.StatusBadGateway, app.i18n.T("public.invalidFeature")) - } hasOptin, err := processSubForm(c) @@ -547,7 +663,7 @@ func processSubForm(c echo.Context) (bool, error) { return false, err } - if _, err := app.core.UpdateSubscriber(sub.ID, sub, nil, listUUIDs, false); err != nil { + if _, err := app.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false); err != nil { return false, err } diff --git a/cmd/subscribers.go b/cmd/subscribers.go index 85afbcc..707e838 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -251,7 +251,7 @@ func handleUpdateSubscriber(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } - out, err := app.core.UpdateSubscriber(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs) + out, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true) if err != nil { return err } @@ -364,7 +364,7 @@ func handleManageSubscriberLists(c echo.Context) error { case "remove": err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs) case "unsubscribe": - err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs) + err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } diff --git a/frontend/src/views/settings/privacy.vue b/frontend/src/views/settings/privacy.vue index 878e567..96251a6 100644 --- a/frontend/src/views/settings/privacy.vue +++ b/frontend/src/views/settings/privacy.vue @@ -18,6 +18,12 @@ name="privacy.allow_blocklist" /> + + + + 0 { @@ -325,14 +320,55 @@ func (c *Core) UpdateSubscriber(id int, sub models.Subscriber, listIDs []int, li strings.TrimSpace(sub.Name), sub.Status, json.RawMessage(attribs), - pq.Array(listIDs), - pq.Array(listUUIDs), - subStatus) + ) if err != nil { c.log.Printf("error updating subscriber: %v", err) return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - c.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) + } + + out, err := c.GetSubscriber(sub.ID, "", sub.Email) + if err != nil { + return models.Subscriber{}, err + } + + return out, nil +} + +// UpdateSubscriberWithLists updates a subscriber's properties. +// If deleteLists is set to true, all existing subscriptions are deleted and only +// the ones provided are added or retained. +func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, deleteLists bool) (models.Subscriber, error) { + subStatus := models.SubscriptionStatusUnconfirmed + if preconfirm { + subStatus = models.SubscriptionStatusConfirmed + } + + // Format raw JSON attributes. + attribs := []byte("{}") + if len(sub.Attribs) > 0 { + if b, err := json.Marshal(sub.Attribs); err != nil { + return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscriber}", "error", err.Error())) + } else { + attribs = b + } + } + + _, err := c.q.UpdateSubscriberWithLists.Exec(id, + sub.Email, + strings.TrimSpace(sub.Name), + sub.Status, + json.RawMessage(attribs), + pq.Array(listIDs), + pq.Array(listUUIDs), + subStatus, + deleteLists) + if err != nil { + c.log.Printf("error updating subscriber: %v", err) + return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } out, err := c.GetSubscriber(sub.ID, "", sub.Email) diff --git a/internal/core/subscriptions.go b/internal/core/subscriptions.go index 35cbb22..95046af 100644 --- a/internal/core/subscriptions.go +++ b/internal/core/subscriptions.go @@ -4,10 +4,24 @@ import ( "net/http" "time" + "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/lib/pq" ) +// GetSubscriptions retrieves the subscriptions for a subscriber. +func (c *Core) GetSubscriptions(subID int, subUUID string, allLists bool) ([]models.Subscription, error) { + var out []models.Subscription + err := c.q.GetSubscriptions.Select(&out, subID, subUUID, allLists) + if err != nil { + c.log.Printf("error getting subscriptions: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error())) + } + + return out, err +} + // AddSubscriptions adds list subscriptions to subscribers. func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error { if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil { @@ -66,8 +80,8 @@ func (c *Core) DeleteSubscriptionsByQuery(query string, sourceListIDs, targetLis } // UnsubscribeLists sets list subscriptions to 'unsubscribed'. -func (c *Core) UnsubscribeLists(subIDs, listIDs []int) error { - if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil { +func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []string) error { + if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs), pq.StringArray(listUUIDs)); err != nil { c.log.Printf("error unsubscribing from lists: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error())) diff --git a/internal/manager/manager.go b/internal/manager/manager.go index db13243..e819be9 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -451,6 +451,9 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap { "UnsubscribeURL": func(msg *CampaignMessage) string { return msg.unsubURL }, + "ManageURL": func(msg *CampaignMessage) string { + return msg.unsubURL + "?manage=true" + }, "OptinURL": func(msg *CampaignMessage) string { // Add list IDs. // TODO: Show private lists list on optin e-mail diff --git a/internal/migrations/v2.3.0.go b/internal/migrations/v2.3.0.go index 9cde73b..56d6261 100644 --- a/internal/migrations/v2.3.0.go +++ b/internal/migrations/v2.3.0.go @@ -12,5 +12,14 @@ func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } + // Insert appearance related settings. + if _, err := db.Exec(` + INSERT INTO settings (key, value) VALUES + ('privacy.allow_preferences', 'false') + ON CONFLICT DO NOTHING; + `); err != nil { + return err + } + return nil } diff --git a/models/models.go b/models/models.go index fc40b59..30e6ced 100644 --- a/models/models.go +++ b/models/models.go @@ -116,7 +116,7 @@ var regTplFuncs = []regTplFunc{ }, { - regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|OptinURL|MessageURL)(\s+)?}}`), + regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|ManageURL|OptinURL|MessageURL)(\s+)?}}`), replace: `{{ $2 . }}`, }, } @@ -169,6 +169,13 @@ type subLists struct { Lists types.JSONText `db:"lists"` } +// Subscription represents a list attached to a subscriber. +type Subscription struct { + List + SubscriptionStatus null.String `db:"subscription_status" json:"subscription_status"` + SubscriptionCreatedAt null.String `db:"subscription_created_at" json:"subscription_created_at"` +} + // SubscriberExportProfile represents a subscriber's collated data in JSON for export. type SubscriberExportProfile struct { Email string `db:"email" json:"-"` diff --git a/models/queries.go b/models/queries.go index bf88db4..8521af8 100644 --- a/models/queries.go +++ b/models/queries.go @@ -20,8 +20,10 @@ type Queries struct { GetSubscriber *sqlx.Stmt `query:"get-subscriber"` GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` + GetSubscriptions *sqlx.Stmt `query:"get-subscriptions"` GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"` UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"` + UpdateSubscriberWithLists *sqlx.Stmt `query:"update-subscriber-with-lists"` BlocklistSubscribers *sqlx.Stmt `query:"blocklist-subscribers"` AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"` DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"` diff --git a/models/settings.go b/models/settings.go index aed4996..be75961 100644 --- a/models/settings.go +++ b/models/settings.go @@ -24,6 +24,7 @@ type Settings struct { PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` + PrivacyAllowPreferences bool `json:"privacy.allow_preferences"` PrivacyAllowExport bool `json:"privacy.allow_export"` PrivacyAllowWipe bool `json:"privacy.allow_wipe"` PrivacyExportable []string `json:"privacy.exportable"` diff --git a/queries.sql b/queries.sql index c4b8e1a..f0e4edd 100644 --- a/queries.sql +++ b/queries.sql @@ -25,7 +25,7 @@ SELECT * FROM lists WHEN CARDINALITY($4::UUID[]) > 0 THEN uuid = ANY($4::UUID[]) ELSE TRUE END) - AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status END) + AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status ELSE TRUE END) AND (CASE WHEN $6 != '' THEN lists.optin = $6::list_optin ELSE TRUE END) ORDER BY id; @@ -51,6 +51,19 @@ SELECT id as subscriber_id, LEFT JOIN subs AS s ON (s.subscriber_id = id) ORDER BY ARRAY_POSITION($1, id); +-- name: get-subscriptions +-- Retrieves all lists a subscriber is attached to. +-- if $3 is set to true, all lists are fetched including the subscriber's subscriptions. +-- subscription_status, and subscription_created_at are null in that case. +WITH sub AS ( + SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END +) +SELECT lists.*, subscriber_lists.status as subscription_status, subscriber_lists.created_at as subscription_created_at + FROM lists LEFT JOIN subscriber_lists + ON (subscriber_lists.list_id = lists.id AND subscriber_lists.subscriber_id = (SELECT id FROM sub)) + WHERE CASE WHEN $3 = TRUE THEN TRUE ELSE subscriber_lists.status IS NOT NULL END + ORDER BY subscriber_lists.status; + -- name: insert-subscriber WITH sub AS ( INSERT INTO subscribers (uuid, email, name, status, attribs) @@ -115,6 +128,15 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW() WHERE subscriber_id = (SELECT id FROM sub); -- name: update-subscriber +UPDATE subscribers SET + email=(CASE WHEN $2 != '' THEN $2 ELSE email END), + name=(CASE WHEN $3 != '' THEN $3 ELSE name END), + status=(CASE WHEN $4 != '' THEN $4::subscriber_status ELSE status END), + attribs=(CASE WHEN $5 != '' THEN $5::JSONB ELSE attribs END), + updated_at=NOW() +WHERE id = $1; + +-- name: update-subscriber-with-lists -- Updates a subscriber's data, and given a list of list_ids, inserts subscriptions -- for them while deleting existing subscriptions not in the list. WITH s AS ( @@ -126,13 +148,13 @@ WITH s AS ( updated_at=NOW() WHERE id = $1 RETURNING id ), -d AS ( - DELETE FROM subscriber_lists WHERE subscriber_id = $1 AND list_id != ALL($6) -), listIDs AS ( SELECT id FROM lists WHERE (CASE WHEN CARDINALITY($6::INT[]) > 0 THEN id=ANY($6) ELSE uuid=ANY($7::UUID[]) END) +), +d AS ( + DELETE FROM subscriber_lists WHERE $9 = TRUE AND subscriber_id = $1 AND list_id != ALL(SELECT id FROM listIDs) ) INSERT INTO subscriber_lists (subscriber_id, list_id, status) VALUES( @@ -182,8 +204,14 @@ UPDATE subscriber_lists SET status='confirmed', updated_at=NOW() WHERE subscriber_id = (SELECT id FROM subID) AND list_id = ANY(SELECT id FROM listIDs); -- name: unsubscribe-subscribers-from-lists +WITH listIDs AS ( + SELECT ARRAY( + SELECT id FROM lists WHERE + (CASE WHEN CARDINALITY($2::INT[]) > 0 THEN id=ANY($2) ELSE uuid=ANY($3::UUID[]) END) + ) id +) UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW() - WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b); + WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST((SELECT id FROM listIDs)) b); -- name: unsubscribe-by-campaign -- Unsubscribes a subscriber given a campaign UUID (from all the lists in the campaign) and the subscriber UUID. diff --git a/schema.sql b/schema.sql index fbde897..d16e7e0 100644 --- a/schema.sql +++ b/schema.sql @@ -197,6 +197,7 @@ INSERT INTO settings (key, value) VALUES ('privacy.allow_blocklist', 'true'), ('privacy.allow_export', 'true'), ('privacy.allow_wipe', 'true'), + ('privacy.allow_preferences', 'true'), ('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'), ('privacy.domain_blocklist', '[]'), ('upload.provider', '"filesystem"'), diff --git a/static/public/static/style.css b/static/public/static/style.css index 0f28b91..7256ca4 100644 --- a/static/public/static/style.css +++ b/static/public/static/style.css @@ -50,6 +50,10 @@ input:focus::placeholder { color: transparent; } +input[disabled] { + opacity: 0.5; +} + .center { text-align: center; } @@ -111,8 +115,7 @@ input:focus::placeholder { .row { margin-bottom: 20px; } -.form .lists { - margin-top: 45px; +.lists { list-style-type: none; padding: 0; } diff --git a/static/public/templates/subscription-form.html b/static/public/templates/subscription-form.html index d49695f..152425f 100644 --- a/static/public/templates/subscription-form.html +++ b/static/public/templates/subscription-form.html @@ -15,6 +15,7 @@

+