From b410a7fea9ee1436c5c3dfa237d0648858b27933 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:01:18 +0200 Subject: [PATCH] feat: register the Users admin screen with the privileged-member guard - permissions (the four Winter codes and manage_privileged_groups), the user navigation item and the embedded admin YAML - Users controller: list with deactivated users, row states, the groups, created date and activated filters, and the form's name, surname and email - a member of a group in golem15.user.privileged_groups (default admin) keeps their email, password and row unless the administrator holds golem15.users.manage_privileged_groups; the refusal is a 403 that saves nothing --- admin.go | 36 ++++ admin_navigation.go | 31 ++++ admin_permissions.go | 49 ++++++ classes/admin_actions.go | 48 ++++++ classes/privileged.go | 118 +++++++++++++ config/config.yaml | 2 + controllers/admin_registry.go | 28 +++ controllers/request_db.go | 23 +++ controllers/users/config_filter.yaml | 14 ++ controllers/users/config_form.yaml | 14 ++ controllers/users/config_list.yaml | 20 +++ controllers/users_admin_controller.go | 239 ++++++++++++++++++++++++++ lang/en/lang.yaml | 31 ++++ lang/pl/lang.yaml | 31 ++++ models/user/columns.yaml | 28 +++ models/user/fields.yaml | 16 ++ session_test.go | 13 +- 17 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 admin.go create mode 100644 admin_navigation.go create mode 100644 admin_permissions.go create mode 100644 classes/admin_actions.go create mode 100644 classes/privileged.go create mode 100644 controllers/admin_registry.go create mode 100644 controllers/request_db.go create mode 100644 controllers/users/config_filter.yaml create mode 100644 controllers/users/config_form.yaml create mode 100644 controllers/users/config_list.yaml create mode 100644 controllers/users_admin_controller.go create mode 100644 models/user/columns.yaml create mode 100644 models/user/fields.yaml diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..a988408 --- /dev/null +++ b/admin.go @@ -0,0 +1,36 @@ +package user + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// adminFS holds the admin YAML and partials, file by file: the controllers +// directory also holds files that are not admin assets. +// +//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml models/user/fields.yaml models/user/columns.yaml +var adminFS embed.FS + +func (p *Plugin) AdminFS() fs.FS { return adminFS } + +// AdminControllers hands the controllers a lazy accessor of the booted app, +// so they read the database handle and the config at request time. +func (p *Plugin) AdminControllers() []pact.AdminController { + return controllers.AdminControllers(func() *backpack.App { + if p == nil { + return nil + } + return p.app + }) +} + +var ( + _ pact.AdminAssets = (*Plugin)(nil) + _ pact.HasAdminControllers = (*Plugin)(nil) + _ pact.HasPermissions = (*Plugin)(nil) + _ pact.HasNavigation = (*Plugin)(nil) +) diff --git a/admin_navigation.go b/admin_navigation.go new file mode 100644 index 0000000..e0d23b9 --- /dev/null +++ b/admin_navigation.go @@ -0,0 +1,31 @@ +package user + +import ( + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Navigation is the plugin's Winter registerNavigation() entry: the main item +// user (order 555) with its side menu. Controller names an admin controller +// id in place of a backend URL, and icons are lucide names. +func (*Plugin) Navigation() []pact.NavigationItem { + return []pact.NavigationItem{ + { + Code: "user", + Label: "golem15.user::lang.users.menu_label", + Icon: "user", + Permissions: []string{"golem15.users.*"}, + Order: 555, + Controller: "golem15.user.users", + SideMenu: []pact.NavigationItem{ + { + Code: "users", + Label: "golem15.user::lang.users.menu_label", + Icon: "user", + Permissions: []string{controllers.PermissionAccessUsers}, + Controller: "golem15.user.users", + }, + }, + }, + } +} diff --git a/admin_permissions.go b/admin_permissions.go new file mode 100644 index 0000000..8ebbe76 --- /dev/null +++ b/admin_permissions.go @@ -0,0 +1,49 @@ +package user + +import ( + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/pact" +) + +const userPermissionTab = "golem15.user::lang.plugin.tab" + +// Permissions is the plugin's Winter registerPermissions() catalog plus one +// code of its own. The four PHP codes default to the developer role; +// impersonate_user is registered so imported backend roles that name it stay +// valid, although no screen uses it. manage_privileged_groups has no default +// role: only a superuser, or a role that was granted it explicitly, may +// change the email or password of a privileged-group member or delete one. +func (*Plugin) Permissions() []pact.Permission { + return []pact.Permission{ + { + Code: controllers.PermissionAccessUsers, + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.access_users", + Roles: []string{"developer"}, + }, + { + Code: "golem15.users.access_groups", + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.access_groups", + Roles: []string{"developer"}, + }, + { + Code: "golem15.users.access_settings", + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.access_settings", + Roles: []string{"developer"}, + }, + { + Code: "golem15.users.impersonate_user", + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.impersonate_user", + Roles: []string{"developer"}, + }, + { + Code: classes.PermissionManagePrivilegedGroups, + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.manage_privileged_groups", + }, + } +} diff --git a/classes/admin_actions.go b/classes/admin_actions.go new file mode 100644 index 0000000..fb34cd5 --- /dev/null +++ b/classes/admin_actions.go @@ -0,0 +1,48 @@ +package classes + +import ( + "context" + "fmt" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "gorm.io/gorm" +) + +// ThrottleStates reads the ban and suspension state of userIDs from +// user_throttle in one query. A user is banned when any of their rows is +// banned, and suspended when a row is suspended and its suspended_at lies +// inside the suspension window (golem15.user.throttle.suspension_minutes, +// read from ctx as ContextWithThrottle stores it). It is a pure read: an +// expired suspension is not lifted here. +func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, suspended map[uint]bool, err error) { + banned, suspended = map[uint]bool{}, map[uint]bool{} + if db == nil { + return nil, nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + if len(userIDs) == 0 { + return banned, suspended, nil + } + minutes := throttleFrom(ctx).minutes + if minutes <= 0 { + minutes = 15 + } + window := time.Duration(minutes) * time.Minute + var rows []models.Throttle + err = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx). + Where("user_id IN ? AND (is_banned OR is_suspended)", userIDs). + Find(&rows).Error + if err != nil { + return nil, nil, err + } + now := time.Now() + for _, row := range rows { + if row.IsBanned { + banned[row.UserID] = true + } + if row.IsSuspended && row.SuspendedAt != nil && now.Before(row.SuspendedAt.Add(window)) { + suspended[row.UserID] = true + } + } + return banned, suspended, nil +} diff --git a/classes/privileged.go b/classes/privileged.go new file mode 100644 index 0000000..b9633ad --- /dev/null +++ b/classes/privileged.go @@ -0,0 +1,118 @@ +package classes + +import ( + "context" + "fmt" + "strings" + + "git.golem15.com/golem15/summercms/modules/compass" + "gorm.io/gorm" +) + +// PermissionManagePrivilegedGroups is the backend permission an administrator +// needs, on top of golem15.users.access_users, to change the email or the +// password of a member of a privileged group, or to delete such a user for +// good. +const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups" + +// privilegedGroupsKey is the config list of privileged group codes. +const privilegedGroupsKey = "golem15.user.privileged_groups" + +// defaultPrivilegedGroup is the only privileged code when the config names +// none: the site-admin group. +const defaultPrivilegedGroup = "admin" + +// PrivilegedGroupCodes returns the group codes whose members are protected, +// read from golem15.user.privileged_groups on every call so a config change +// applies without a restart. A nil config or a missing key gives the single +// code admin. Blank entries are dropped; a key that is present and empty +// means that no group is privileged. +func PrivilegedGroupCodes(cfg *compass.Config) []string { + if cfg == nil { + return []string{defaultPrivilegedGroup} + } + raw, ok := cfg.Lookup(privilegedGroupsKey) + if !ok { + return []string{defaultPrivilegedGroup} + } + var entries []string + switch v := raw.(type) { + case []string: + entries = v + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + entries = append(entries, s) + } + } + case string: + // An environment override arrives as one comma-separated string. + entries = strings.Split(v, ",") + } + codes := make([]string, 0, len(entries)) + for _, entry := range entries { + if code := strings.TrimSpace(entry); code != "" { + codes = append(codes, code) + } + } + return codes +} + +// IsPrivilegedCode reports whether a group's code is in codes. Codes compare +// case-sensitively, as HasGroupCode does, and a group without a code is +// never privileged. +func IsPrivilegedCode(codes []string, code *string) bool { + if code == nil || *code == "" { + return false + } + for _, privileged := range codes { + if privileged == *code { + return true + } + } + return false +} + +// PrivilegedGroupIDs returns the ids of the user_groups rows whose code is in +// codes, ordered by id. +func PrivilegedGroupIDs(ctx context.Context, db *gorm.DB, codes []string) ([]uint, error) { + if db == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + ids := []uint{} + if len(codes) == 0 { + return ids, nil + } + err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx). + Table("user_groups"). + Where("code IN ?", codes). + Order("id"). + Pluck("id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + +// IsPrivilegedMember reports whether the user belongs to at least one group +// whose code is in codes. A user in no group, and a group without a code, +// give false. A deactivated user's membership counts: it survives the soft +// delete. +func IsPrivilegedMember(ctx context.Context, db *gorm.DB, codes []string, userID uint) (bool, error) { + if db == nil { + return false, fmt.Errorf("golem15.user: gorm handle is missing") + } + if len(codes) == 0 || userID == 0 { + return false, nil + } + held, err := UserGroupCodes(ctx, db.Session(&gorm.Session{NewDB: true}), userID) + if err != nil { + return false, err + } + for i := range held { + if IsPrivilegedCode(codes, &held[i]) { + return true, nil + } + } + return false, nil +} diff --git a/config/config.yaml b/config/config.yaml index 0254cd1..7f97486 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,3 +22,5 @@ password: min_length: 8 api_tokens: prefix: usr_ +privileged_groups: + - admin diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go new file mode 100644 index 0000000..460bb8b --- /dev/null +++ b/controllers/admin_registry.go @@ -0,0 +1,28 @@ +package controllers + +import ( + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// AdminControllers is the plugin's admin controller list. app returns the +// booted application; it is asked on every call, so a controller never keeps +// a database handle, a config or a mailer of its own. +func AdminControllers(app func() *backpack.App) []pact.AdminController { + return []pact.AdminController{ + usersAdminController{app: app}, + } +} + +func init() { + var _ pact.AdminController = usersAdminController{} + var _ pact.AdminPermissioned = usersAdminController{} + var _ pact.AdminRecordSource = usersAdminController{} + var _ pact.ListExtendQuery = usersAdminController{} + var _ pact.FormExtendQuery = usersAdminController{} + var _ pact.ListRowStates = usersAdminController{} + var _ pact.FormRules = usersAdminController{} + var _ pact.FilterOptions = usersAdminController{} + var _ pact.FormBeforeUpdate = usersAdminController{} + var _ pact.FormBeforeDelete = usersAdminController{} +} diff --git a/controllers/request_db.go b/controllers/request_db.go new file mode 100644 index 0000000..63d4e86 --- /dev/null +++ b/controllers/request_db.go @@ -0,0 +1,23 @@ +package controllers + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/cabana" + "gorm.io/gorm" +) + +// requestDB is the database handle a hook or scope should read through. Inside +// an admin write it is the transaction the write runs in, so the hook sees the +// write's snapshot and does not take a second pool connection while the +// transaction holds row locks. Outside a transaction (the list) it is the +// application pool from the booted app. It is nil when neither exists. +func requestDB(ctx context.Context, pool func() *gorm.DB) *gorm.DB { + if tx, ok := cabana.TxFromContext(ctx); ok { + return tx + } + if pool == nil { + return nil + } + return pool() +} diff --git a/controllers/users/config_filter.yaml b/controllers/users/config_filter.yaml new file mode 100644 index 0000000..e48189d --- /dev/null +++ b/controllers/users/config_filter.yaml @@ -0,0 +1,14 @@ +scopes: + groups: + label: golem15.user::lang.group.label + modelClass: Golem15\User\Models\UserGroup + nameFrom: name + scope: filterByGroup + created_date: + label: golem15.user::lang.user.created_at + type: daterange + column: created_at + activated: + label: golem15.user::lang.user.status_activated + type: switch + column: is_activated diff --git a/controllers/users/config_form.yaml b/controllers/users/config_form.yaml new file mode 100644 index 0000000..c929c8e --- /dev/null +++ b/controllers/users/config_form.yaml @@ -0,0 +1,14 @@ +name: golem15.user::lang.user.label +form: ~/plugins/golem15/user/models/user/fields.yaml +modelClass: Golem15\User\Models\User +defaultRedirect: golem15/user/users + +update: + redirect: golem15/user/users/update/:id + redirectClose: golem15/user/users + +messages: + update: backend::lang.messages.form.update + saved: backend::lang.messages.form.saved + deleteConfirm: golem15.user::lang.users.delete_confirm + deleted: backend::lang.messages.form.deleted diff --git a/controllers/users/config_list.yaml b/controllers/users/config_list.yaml new file mode 100644 index 0000000..589759b --- /dev/null +++ b/controllers/users/config_list.yaml @@ -0,0 +1,20 @@ +list: ~/plugins/golem15/user/models/user/columns.yaml +modelClass: Golem15\User\Models\User +title: golem15.user::lang.users.list_title +recordUrl: golem15/user/users/update/:id +noRecordsMessage: backend::lang.list.no_records +recordsPerPage: 20 +showCheckboxes: true +showSetup: true +filter: config_filter.yaml + +toolbar: + buttons: [] + search: + prompt: backend::lang.list.search_prompt + +messages: + empty: golem15.user::lang.users.list_empty + rowStateDeleted: golem15.user::lang.users.state_deactivated + rowStateNegative: golem15.user::lang.users.state_banned + rowStateDisabled: golem15.user::lang.users.state_not_activated diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go new file mode 100644 index 0000000..f621fd9 --- /dev/null +++ b/controllers/users_admin_controller.go @@ -0,0 +1,239 @@ +package controllers + +import ( + "context" + "errors" + "strconv" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// PermissionAccessUsers is the backend permission of the Users screen, the +// PHP Users::$requiredPermissions. +const PermissionAccessUsers = "golem15.users.access_users" + +// usersLang prefixes the phrase keys of the Users screen. +const usersLang = "golem15.user::lang.users." + +// usersAdminController is the Winter Users backend controller: the list with +// its filters and row states, and the form. The screen and every write need +// golem15.users.access_users. +// +// app is resolved per call from the booted plugin. Nothing is cached: the +// database handle, the config and the privileged group list are read when a +// request needs them. +type usersAdminController struct { + app func() *backpack.App +} + +func (usersAdminController) ID() string { return "golem15.user.users" } + +func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` } + +func (usersAdminController) ConfigDir() string { return "controllers/users" } + +func (usersAdminController) RequiredPermissions() []string { + return []string{PermissionAccessUsers} +} + +func (usersAdminController) NewRecord() any { return &models.User{} } + +// application returns the booted app, or nil before boot. +func (c usersAdminController) application() *backpack.App { + if c.app == nil { + return nil + } + return c.app() +} + +// db is the application pool, or nil when the app has none. +func (c usersAdminController) db() *gorm.DB { + app := c.application() + if app == nil { + return nil + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok { + return nil + } + return gdb +} + +// config is the application config, or nil before boot. +func (c usersAdminController) config() *compass.Config { + app := c.application() + if app == nil { + return nil + } + return app.Config +} + +// ListExtendQuery and FormExtendQuery include deactivated (soft-deleted) +// users, the PHP listExtendQuery and formExtendQuery withTrashed(). +func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { + if db == nil { + return db + } + return db.Unscoped() +} + +func (usersAdminController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { + if db == nil { + return db + } + return db.Unscoped() +} + +// ListRowStates is the PHP listInjectRowClass: deleted for a deactivated +// user, negative for a banned one, disabled for one who is not activated. +// The ban state of the whole page comes from one user_throttle query. +func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) { + ids := make([]uint, 0, len(records)) + for _, record := range records { + user, ok := record.(*models.User) + if !ok || user == nil { + return nil, errors.New("users admin: list record is not a user") + } + ids = append(ids, user.ID) + } + banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, ids) + if err != nil { + return nil, err + } + out := make([][]pact.RowState, len(records)) + for i, record := range records { + user := record.(*models.User) + if user.DeletedAt.Valid { + out[i] = append(out[i], pact.RowStateDeleted) + } + if banned[user.ID] { + out[i] = append(out[i], pact.RowStateNegative) + } + if !user.IsActivated { + out[i] = append(out[i], pact.RowStateDisabled) + } + } + return out, nil +} + +// FormRules are the rules of an admin save. They replace User.Rules(), which +// is the register contract and would ask every admin update for a confirmed +// password. +func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string { + return map[string]string{ + "email": "required|between:6,255|email|unique:users", + } +} + +// FilterOptions serves the choices of the groups filter (scope +// filterByGroup): every user group by name. Any other scope has no choices. +func (c usersAdminController) FilterOptions(scope string) []pact.Option { + if scope != models.FilterByGroup { + return nil + } + db := c.db() + if db == nil { + return nil + } + var groups []models.UserGroup + if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&groups).Error; err != nil { + return nil + } + out := make([]pact.Option, 0, len(groups)) + for _, group := range groups { + out = append(out, pact.Option{Value: strconv.FormatUint(uint64(group.ID), 10), Label: group.Name}) + } + return out +} + +// guardPrivilegedMember protects the account of a privileged-group member +// against takeover. It answers nil when the administrator on ctx holds +// golem15.users.manage_privileged_groups, or when the user is not a member of +// a group whose code is in golem15.user.privileged_groups; otherwise it +// refuses with a 403 carrying message and details. Membership is read +// through the write's transaction. A failed query is returned as it is. +func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID uint, message string, details map[string]any) error { + principal, _ := bouncer.User(ctx) + if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) { + return nil + } + db := requestDB(ctx, c.db) + if db == nil { + return errors.New("users admin: gorm handle is missing") + } + privileged, err := classes.IsPrivilegedMember(ctx, db, classes.PrivilegedGroupCodes(c.config()), userID) + if err != nil { + return err + } + if !privileged { + return nil + } + return &cabana.ForbiddenError{Message: message, Details: details} +} + +// FormBeforeUpdate starts with the takeover guard: a changed email or a +// submitted password on a member of a privileged group needs +// golem15.users.manage_privileged_groups. Everything else an update does +// comes after it. +func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error { + user, ok := model.(*models.User) + if !ok || user == nil { + return errors.New("users admin: form model is not a user") + } + if err := c.guardCredentials(ctx, user); err != nil { + return err + } + return nil +} + +// guardCredentials compares the email the save would write with the stored +// one and looks for a submitted password, then asks guardPrivilegedMember +// for the fields that would change. +func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error { + db := requestDB(ctx, c.db) + if db == nil { + return errors.New("users admin: gorm handle is missing") + } + var stored []string + err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). + Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error + if err != nil { + return err + } + if len(stored) != 1 { + return errors.New("users admin: the user being updated was not found") + } + const message = usersLang + "privileged_member_forbidden" + details := map[string]any{} + if stored[0] != user.Email { + details["email"] = []string{message} + } + if values, ok := cabana.VirtualFieldsFromContext(ctx); ok { + if password, _ := values["password"].(string); password != "" { + details["password"] = []string{message} + } + } + if len(details) == 0 { + return nil + } + return c.guardPrivilegedMember(ctx, user.ID, message, details) +} + +// FormBeforeDelete refuses the delete of a privileged-group member without +// golem15.users.manage_privileged_groups. The framework calls it for the +// form's delete and once per record inside the bulk delete's transaction, so +// one refused record rolls the whole selection back. +func (c usersAdminController) FormBeforeDelete(ctx context.Context, model any) error { + user, ok := model.(*models.User) + if !ok || user == nil { + return errors.New("users admin: form model is not a user") + } + return c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_delete_forbidden", nil) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 307c7f7..f3cb673 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -1,3 +1,34 @@ account: invalid_login: Invalid email or password no_avatar: Your account has no display picture to remove. +plugin: + tab: Users + access_users: Manage Users + access_groups: Manage User Groups + access_settings: Manage User Settings + impersonate_user: Impersonate Users + manage_privileged_groups: Manage privileged group membership +users: + menu_label: Users + list_title: Manage Users + list_empty: No users match this view. Clear the filters, or add one with New User. + state_deactivated: Deactivated + state_banned: Banned + state_not_activated: Not activated + delete_confirm: Do you really want to delete this user? This user will be permanently removed and unrecoverable. + privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved. + privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted. +user: + label: User + id: ID + name: Name + surname: Surname + email: Email + account: Account + created_at: Registered + last_seen: Last seen + created_ip_address: Created IP Address + last_ip_address: Last IP Address + status_activated: Activated +group: + label: Group diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 5c6c455..a2cbbb7 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -1,3 +1,34 @@ account: invalid_login: Nieprawidłowy email lub hasło no_avatar: Brak avatara +plugin: + tab: Użytkownicy + access_users: Zarządzaj użytkownikami + access_groups: Zarządzaj grupami użytkowników + access_settings: Zarządzaj ustawieniami użytkownika + impersonate_user: Wcielaj się w użytkowników + manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych +users: + menu_label: Użytkownicy + list_title: Zarządzaj użytkownikami + list_empty: Brak użytkowników w tym widoku. Wyczyść filtry lub dodaj użytkownika przyciskiem Nowy użytkownik. + state_deactivated: Dezaktywowany + state_banned: Zbanowany + state_not_activated: Nieaktywowany + delete_confirm: Czy na pewno chcesz usunąć tego użytkownika? Zostanie trwale usunięty bez możliwości odzyskania. + privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane. + privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte. +user: + label: Użytkownik + id: ID + name: Imię / nick + surname: Nazwisko + email: Email + account: Konto + created_at: Zarejestrowany + last_seen: Ostatnio widziany + created_ip_address: Stworzono z IP + last_ip_address: Ostatnie IP + status_activated: Aktywowany +group: + label: Grupa diff --git a/models/user/columns.yaml b/models/user/columns.yaml new file mode 100644 index 0000000..8b20cb1 --- /dev/null +++ b/models/user/columns.yaml @@ -0,0 +1,28 @@ +columns: + id: + label: golem15.user::lang.user.id + invisible: true + name: + label: golem15.user::lang.user.name + searchable: true + surname: + label: golem15.user::lang.user.surname + searchable: true + invisible: true + email: + label: golem15.user::lang.user.email + searchable: true + created_at: + label: golem15.user::lang.user.created_at + type: datetime + last_seen: + label: golem15.user::lang.user.last_seen + type: datetime + created_ip_address: + label: golem15.user::lang.user.created_ip_address + searchable: true + invisible: true + last_ip_address: + label: golem15.user::lang.user.last_ip_address + searchable: true + invisible: true diff --git a/models/user/fields.yaml b/models/user/fields.yaml new file mode 100644 index 0000000..69b872b --- /dev/null +++ b/models/user/fields.yaml @@ -0,0 +1,16 @@ +fields: + name: + label: golem15.user::lang.user.name + type: text + span: left + tab: golem15.user::lang.user.account + surname: + label: golem15.user::lang.user.surname + type: text + span: right + tab: golem15.user::lang.user.account + email: + label: golem15.user::lang.user.email + type: text + span: full + tab: golem15.user::lang.user.account diff --git a/session_test.go b/session_test.go index 70c42d8..f6ab691 100644 --- a/session_test.go +++ b/session_test.go @@ -34,7 +34,9 @@ import ( ) const ( - sessionSecret = "summercms-test-only-hs256-secret" + sessionSecret = "summercms-test-only-hs256-secret" + // adminHarnessSecret signs backend tokens in tests that mount the admin API. + adminHarnessSecret = "summercms-test-only-admin-hs256-secret" invalidLoginBody = `{"error":true,"message":"Invalid email or password"}` prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883" ) @@ -43,7 +45,10 @@ var ( sessionOnce sync.Once sessionGDB *gorm.DB sessionSQL *sql.DB - sessionErr error + // sessionDSN is the container's connection string; the admin harness + // opens its own database in the same container with it. + sessionDSN string + sessionErr error ) func TestLogin(t *testing.T) { @@ -507,6 +512,9 @@ func sessionConfig(t *testing.T) *compass.Config { Environ: []string{ "SUMMER_ENV=development", "SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret, + // The plugin registers admin controllers, so assembling its + // routes mounts the admin API, which needs its own secret. + "SUMMER_ADMIN__JWT__SECRET=" + adminHarnessSecret, }, }) if err != nil { @@ -538,6 +546,7 @@ func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) { sessionErr = err return } + sessionDSN = dsn sqlDB, err := sql.Open("pgx", dsn) if err != nil { sessionErr = err