Mercurial > gemma
changeset 5512:6738655809eb
Merged deactivate-users branch into default.
author | Sascha L. Teichmann <sascha.teichmann@intevation.de> |
---|---|
date | Fri, 15 Oct 2021 15:14:33 +0200 |
parents | 0766dcb7e7f8 (current diff) ba014cc5f969 (diff) |
children | 68358e4603c8 |
files | |
diffstat | 13 files changed, 428 insertions(+), 40 deletions(-) [+] |
line wrap: on
line diff
--- a/client/src/components/ui/UIBoxHeader.vue Tue Sep 21 12:49:30 2021 +0200 +++ b/client/src/components/ui/UIBoxHeader.vue Fri Oct 15 15:14:33 2021 +0200 @@ -10,6 +10,18 @@ {{ title }} </span> <div class="d-flex flex-row"> + <span class="box-control" v-if="checkBox" style="cursor: default;"> + <input + id="checkboxID" + type="checkbox" + class="checkbox-header" + :checked="checkBox.value" + @change="checkBox.callback" + /> + <label for="checkboxID" class="ml-1 small mb-0 checkbox-header"> + {{ checkBox.label }} + </label> + </span> <span class="box-control" v-for="(action, index) in actions" @@ -52,6 +64,8 @@ &.small padding: 0.1rem 0.1rem 0.1rem 0.25rem min-height: 27px +.checkbox-header + cursor: pointer </style> <script> @@ -75,7 +89,8 @@ title: String, closeCallback: Function, actions: Array, - small: Boolean + small: Boolean, + checkBox: Object } }; </script>
--- a/client/src/components/usermanagement/Userdetail.vue Tue Sep 21 12:49:30 2021 +0200 +++ b/client/src/components/usermanagement/Userdetail.vue Fri Oct 15 15:14:33 2021 +0200 @@ -131,7 +131,9 @@ :disabled="submitted" class="shadow-sm btn btn-info submit-button" > - <translate>Save</translate> + <span>{{ + currentUser.active ? $gettext("Save") : $gettext("Reactivate") + }}</span> </button> </div> </form> @@ -245,6 +247,9 @@ }, mounted() { this.currentUser = { ...this.user }; + if (!this.currentUser.active) { + this.currentUser.email = ""; + } this.path = this.user.name; }, watch: { @@ -253,6 +258,9 @@ }, user() { this.currentUser = { ...this.user }; + if (!this.currentUser.active) { + this.currentUser.email = ""; + } this.path = this.user.name; this.clearPassword(); this.clearErrors();
--- a/client/src/components/usermanagement/Usermanagement.vue Tue Sep 21 12:49:30 2021 +0200 +++ b/client/src/components/usermanagement/Usermanagement.vue Fri Oct 15 15:14:33 2021 +0200 @@ -4,7 +4,11 @@ <div class="d-flex content py-2"> <div :class="userlistStyle"> <div class="card shadow-xs"> - <UIBoxHeader icon="users-cog" :title="usersLabel" /> + <UIBoxHeader + icon="users-cog" + :title="usersLabel" + :checkBox="checkboxObject" + /> <UITableHeader :columns="[ { id: 'role', title: `${roleForColumLabel}`, class: 'col-1' }, @@ -15,14 +19,17 @@ ]" /> <UITableBody - :data="users | sortTable(sortColumn, sortDirection, page, pageSize)" + :data=" + usersForTable + | sortTable(sortColumn, sortDirection, page, pageSize) + " :isActive="item => item === currentUser" maxHeight="47rem" > <template v-slot:row="{ item: user }"> <div class="table-cell center col-1" - @click="selectUser(user.user)" + :style="{ opacity: user.active ? '1' : '0.7' }" > <font-awesome-icon v-tooltip="roleLabel(user.role)" @@ -30,16 +37,22 @@ class="fa-lg" /> </div> - <div class="table-cell col-4" @click="selectUser(user.user)"> + <div + class="table-cell col-4" + :style="{ opacity: user.active ? '1' : '0.7' }" + > {{ user.user }} </div> <div + :style="{ opacity: user.active ? '1' : '0.7' }" class="table-cell center col-1" - @click="selectUser(user.user)" > {{ user.country }} </div> - <div class="table-cell col-3" @click="selectUser(user.user)"> + <div + class="table-cell col-3" + :style="{ opacity: user.active ? '1' : '0.7' }" + > {{ user.email }} </div> <div class="table-cell center col-1"> @@ -52,15 +65,30 @@ @change="toggleReport(user)" v-tooltip="receivesReportLabel" :width="40" + :disabled="!user.active" :height="20" /> </div> <div class="table-cell col text-right justify-content-end"> <button + @click="selectUser(user.user)" + class="btn btn-xs btn-dark mr-1" + v-tooltip="getEditLabel(user.active)" + > + <font-awesome-icon + v-if="user.active" + icon="pencil-alt" + fixed-width + /> + <font-awesome-icon v-else icon="undo" fixed-width /> + </button> + <button @click="sendTestMail(user.user)" class="btn btn-xs btn-dark mr-1" v-tooltip="sendMailLabel" v-if="user.email" + :disabled="!user.active" + :style="{ cursor: user.active ? 'pointer' : 'default' }" > <font-awesome-icon icon="paper-plane" fixed-width /> </button> @@ -68,6 +96,8 @@ @click="deleteUser(user.user)" class="btn btn-xs btn-dark" v-tooltip="deleteUserLabel" + :style="{ cursor: user.active ? 'pointer' : 'default' }" + :disabled="!user.active" > <font-awesome-icon icon="trash" fixed-width /> </button> @@ -154,7 +184,9 @@ data() { return { sortColumn: "user", // overriding the sortTable mixin's empty default value - reportToggled: false + reportToggled: false, + usersForTable: [], + areSomeUsersHidden: false }; }, components: { @@ -193,7 +225,7 @@ return this.$gettext("Email"); }, pages() { - return Math.ceil(this.users.length / this.pageSize); + return Math.ceil(this.usersForTable.length / this.pageSize); }, tableStyle() { return { @@ -212,9 +244,46 @@ userlistextended: !this.isUserDetailsVisible } ]; + }, + checkboxObject() { + // Hide checkbox in case there are no deactivated users + if (this.users.some(u => !u.active)) { + return { + value: this.areSomeUsersHidden, + label: "Hide inactive users", + callback: () => { + this.changeDisplayingState(); + } + }; + } else { + return undefined; + } } }, + watch: { + users() { + this.filterUsers(); + } + }, + mounted() { + this.usersForTable = this.users; + }, methods: { + getEditLabel(active) { + if (active) return this.$gettext("Edit user"); + return this.$gettext("Reactivate user"); + }, + changeDisplayingState() { + this.areSomeUsersHidden = !this.areSomeUsersHidden; + this.filterUsers(); + }, + filterUsers() { + if (this.areSomeUsersHidden) { + this.usersForTable = this.users.filter(u => u.active); + } else { + this.usersForTable = this.users; + } + }, toggleReport(user) { HTTP.patch( `/users/${user.user}`, @@ -297,7 +366,18 @@ callback: () => { this.$store .dispatch("usermanagement/deleteUser", { name }) - .then(() => { + .then(response => { + displayInfo({ + message: + name + + // Exclude whitespaces from the string passed to "gettext" function + " " + + this.$gettext("user account") + + " " + + response.data.action + + " " + + this.$gettext("successfully") + }); this.$store .dispatch("usermanagement/loadUsers") .catch(error => {
--- a/client/src/main.js Tue Sep 21 12:49:30 2021 +0200 +++ b/client/src/main.js Fri Oct 15 15:14:33 2021 +0200 @@ -106,6 +106,7 @@ faWater, faWrench, faRedo, + faUndo, faSync, faCrosshairs } from "@fortawesome/free-solid-svg-icons"; @@ -178,6 +179,7 @@ faWater, faWrench, faRedo, + faUndo, faSync, faWindowMinimize, faWindowMaximize,
--- a/client/src/store/usermanagement.js Tue Sep 21 12:49:30 2021 +0200 +++ b/client/src/store/usermanagement.js Fri Oct 15 15:14:33 2021 +0200 @@ -32,7 +32,8 @@ role: null, isNew: true, password: "", - roleLabel: "" + roleLabel: "", + active: true }; }; @@ -130,17 +131,38 @@ }); } else { return new Promise((resolve, reject) => { - HTTP.put("/users/" + path, user, { - headers: { "X-Gemma-Auth": localStorage.getItem("token") } - }) - .then(response => { - commit("setUserDetailsInvisible"); - commit("clearCurrentUser"); - resolve(response); + // Reactivate User + if (!user.active) { + HTTP.patch( + "/users/" + path, + { active: true, email: user.email }, + { + headers: { + "X-Gemma-Auth": localStorage.getItem("token") + } + } + ) + .then(response => { + commit("setUserDetailsInvisible"); + commit("clearCurrentUser"); + resolve(response); + }) + .catch(error => { + reject(error); + }); + } else { + HTTP.put("/users/" + path, user, { + headers: { "X-Gemma-Auth": localStorage.getItem("token") } }) - .catch(error => { - reject(error); - }); + .then(response => { + commit("setUserDetailsInvisible"); + commit("clearCurrentUser"); + resolve(response); + }) + .catch(error => { + reject(error); + }); + } }); } },
--- a/pkg/auth/opendb.go Tue Sep 21 12:49:30 2021 +0200 +++ b/pkg/auth/opendb.go Fri Oct 15 15:14:33 2021 +0200 @@ -104,7 +104,7 @@ ) SELECT rolname FROM pg_roles WHERE oid IN (SELECT oid FROM cte) AND rolname <> current_user -AND EXISTS (SELECT 1 FROM users.list_users WHERE username = current_user)` +AND EXISTS (SELECT 1 FROM users.list_users WHERE username = current_user AND active)` // AllOtherRoles loggs in as user with password and returns a list // of all roles the logged in user has in the system.
--- a/pkg/controllers/user.go Tue Sep 21 12:49:30 2021 +0200 +++ b/pkg/controllers/user.go Fri Oct 15 15:14:33 2021 +0200 @@ -39,10 +39,10 @@ const ( createUserSQL = `INSERT INTO users.list_users - VALUES ($1, $2, $3, $4, NULL, $5, $6)` + VALUES ($1, $2, $3, $4, NULL, $5, $6, true)` createUserExtentSQL = `INSERT INTO users.list_users VALUES ($1, $2, $3, $4, - ST_MakeBox2D(ST_Point($5, $6), ST_Point($7, $8)), $9, $10)` + ST_MakeBox2D(ST_Point($5, $6), ST_Point($7, $8)), $9, $10, true)` updateUserUnprivSQL = `UPDATE users.list_users SET (pw, map_extent, email_address) @@ -57,7 +57,23 @@ = ($2, $3, $4, $5, ST_MakeBox2D(ST_Point($6, $7), ST_Point($8, $9)), $10, $11) WHERE username = $1` - deleteUserSQL = `DELETE FROM users.list_users WHERE username = $1` + deleteUserSQL = ` + WITH del AS ( + DELETE FROM users.list_users + WHERE username = $1 AND ( + rolname NOT IN ('waterway_admin', 'sys_admin') + OR NOT EXISTS (SELECT 1 FROM import.imports WHERE username = $1)) + RETURNING * + ), + up AS ( + UPDATE users.list_users + SET (email_address, report_reciever, active) = ('nomail@example.com', false, false) + WHERE username = $1 AND NOT EXISTS (SELECT * FROM del) + RETURNING * + ) + SELECT + EXISTS (SELECT * FROM del) AS deleted, + EXISTS (SELECT * FROM up) AS updated` listUsersSQL = `SELECT rolname, @@ -66,7 +82,8 @@ email_address, ST_XMin(map_extent), ST_YMin(map_extent), ST_XMax(map_extent), ST_YMax(map_extent), - report_reciever + report_reciever, + active FROM users.list_users` listUserSQL = `SELECT @@ -144,24 +161,41 @@ log.Errorf("%v\n", err2) } - var res sql.Result - - if res, err = db.ExecContext(ctx, deleteUserSQL, user); err != nil { + var deleted, updated bool + if err = db.QueryRowContext(ctx, deleteUserSQL, user).Scan(&deleted, &updated); err != nil { return } - if n, err2 := res.RowsAffected(); err2 == nil && n == 0 { + var action string + + switch { + case !deleted && !updated: err = mw.JSONError{ Code: http.StatusNotFound, Message: fmt.Sprintf("Cannot find user %s.", user), } return + case deleted: + log.Debugf("user '%s' was deleted.\n", user) + action = "deleted" + case updated: + log.Debugf("user '%s' was deactivated.\n", user) + action = "deactivated" + default: + log.Errorf("Should not happen: user '%s' updated and deleted.\n", user) + action = "deleted" } // Running in a go routine should not be necessary. go func() { auth.Sessions.Logout(user) }() - jr = mw.JSONResult{Code: http.StatusNoContent} + jr = mw.JSONResult{ + Result: struct { + Action string `json:"action"` + }{ + Action: action, + }, + } return } @@ -325,6 +359,9 @@ if patch.Reports != nil && priv { update("report_reciever", *patch.Reports) } + if patch.Active != nil && priv { + update("active", *patch.Active) + } if patch.Extent != nil { updateBox("map_extent", patch.Extent) } @@ -454,6 +491,7 @@ &user.Extent.X1, &user.Extent.Y1, &user.Extent.X2, &user.Extent.Y2, &user.Reports, + &user.Active, ); err != nil { return }
--- a/pkg/models/user.go Tue Sep 21 12:49:30 2021 +0200 +++ b/pkg/models/user.go Fri Oct 15 15:14:33 2021 +0200 @@ -47,6 +47,7 @@ Email Email `json:"email"` Country Country `json:"country"` Reports bool `json:"reports"` + Active bool `json:"active"` Extent *BoundingBox `json:"extent"` } @@ -58,6 +59,7 @@ Email *Email `json:"email,omitempty"` Country *Country `json:"country,omitempty"` Reports *bool `json:"reports,omitempty"` + Active *bool `json:"active,omitempty"` Extent *BoundingBox `json:"extent,omitempty"` }
--- a/schema/gemma.sql Tue Sep 21 12:49:30 2021 +0200 +++ b/schema/gemma.sql Fri Oct 15 15:14:33 2021 +0200 @@ -385,7 +385,8 @@ country char(2) NOT NULL REFERENCES countries, map_extent box2d NOT NULL, email_address varchar NOT NULL, - report_reciever boolean NOT NULL DEFAULT false + report_reciever boolean NOT NULL DEFAULT false, + active boolean NOT NULL DEFAULT true ) ; @@ -500,7 +501,8 @@ p.country, p.map_extent, p.email_address, - p.report_reciever + p.report_reciever, + p.active FROM internal.user_profiles p JOIN pg_roles u ON p.username = u.rolname JOIN pg_auth_members a ON u.oid = a.member
--- a/schema/manage_users.sql Tue Sep 21 12:49:30 2021 +0200 +++ b/schema/manage_users.sql Fri Oct 15 15:14:33 2021 +0200 @@ -82,8 +82,18 @@ END IF; INSERT INTO internal.user_profiles ( - username, country, map_extent, email_address) - VALUES (NEW.username, NEW.country, NEW.map_extent, NEW.email_address); + username, country, map_extent, email_address, + report_reciever, active) + VALUES (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, + NEW.report_reciever, NEW.active); + + IF NEW.active THEN + EXECUTE format( + 'ALTER ROLE %I LOGIN', NEW.username); + ELSE + EXECUTE format( + 'ALTER ROLE %I NOLOGIN', NEW.username); + END IF; -- Do not leak new password NEW.pw = ''; @@ -152,8 +162,10 @@ END IF; UPDATE internal.user_profiles p - SET (username, country, map_extent, email_address) - = (NEW.username, NEW.country, NEW.map_extent, NEW.email_address) + SET (username, country, map_extent, email_address, + report_reciever, active) + = (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, + NEW.report_reciever, NEW.active) WHERE p.username = cur_username; IF NEW.rolname <> OLD.rolname @@ -172,6 +184,16 @@ internal.check_password(NEW.pw)); END IF; + IF NEW.active <> OLD.active THEN + IF NEW.active THEN + EXECUTE format( + 'ALTER ROLE %I LOGIN', cur_username); + ELSE + EXECUTE format( + 'ALTER ROLE %I NOLOGIN', cur_username); + END IF; + END IF; + -- Do not leak new password NEW.pw = ''; RETURN NEW; @@ -212,7 +234,6 @@ CREATE TRIGGER delete_user INSTEAD OF DELETE ON users.list_users FOR EACH ROW EXECUTE PROCEDURE internal.delete_user(); - -- To set a role from a hex-encoded user name (which is save from SQL injections). CREATE OR REPLACE FUNCTION public.setrole(role text) RETURNS void AS $$
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schema/updates/1464/01.active.sql Fri Oct 15 15:14:33 2021 +0200 @@ -0,0 +1,100 @@ +ALTER TABLE internal.user_profiles + ADD COLUMN active boolean NOT NULL DEFAULT true; + +CREATE OR REPLACE VIEW users.list_users WITH (security_barrier) AS + SELECT + r.rolname, + p.username, + CAST('' AS varchar) AS pw, + p.country, + p.map_extent, + p.email_address, + p.report_reciever, + p.active + FROM internal.user_profiles p + JOIN pg_roles u ON p.username = u.rolname + JOIN pg_auth_members a ON u.oid = a.member + JOIN pg_roles r ON a.roleid = r.oid + WHERE p.username = current_user + OR pg_has_role('waterway_admin', 'MEMBER') + AND p.country = ( + SELECT country FROM internal.user_profiles + WHERE username = current_user) + AND r.rolname <> 'sys_admin' + OR pg_has_role('sys_admin', 'MEMBER'); + +CREATE OR REPLACE FUNCTION internal.update_user() RETURNS trigger +AS $$ +DECLARE + cur_username varchar; +BEGIN + cur_username = OLD.username; + + IF NEW.username <> cur_username + THEN + EXECUTE format( + 'ALTER ROLE %I RENAME TO %I', cur_username, NEW.username); + cur_username = NEW.username; + END IF; + + UPDATE internal.user_profiles p + SET (username, country, map_extent, email_address, report_reciever, active) + = (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, NEW.report_reciever, NEW.active) + WHERE p.username = cur_username; + + IF NEW.rolname <> OLD.rolname + THEN + EXECUTE format( + 'REVOKE %I FROM %I', OLD.rolname, cur_username); + EXECUTE format( + 'GRANT %I TO %I', NEW.rolname, cur_username); + END IF; + + IF NEW.pw IS NOT NULL AND NEW.pw <> '' + THEN + EXECUTE format( + 'ALTER ROLE %I PASSWORD %L', + cur_username, + internal.check_password(NEW.pw)); + END IF; + + -- Do not leak new password + NEW.pw = ''; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql + SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION internal.create_user() RETURNS trigger +AS $$ +BEGIN + IF NEW.map_extent IS NULL + THEN + NEW.map_extent = ST_Extent(CAST(area AS geometry)) + FROM users.stretches st + JOIN users.stretch_countries stc ON stc.stretch_id = st.id + WHERE stc.country = NEW.country; + END IF; + + IF NEW.username IS NOT NULL + -- otherwise let the constraint on user_profiles speak + THEN + EXECUTE format( + 'CREATE ROLE %I IN ROLE %I LOGIN PASSWORD %L', + NEW.username, + NEW.rolname, + internal.check_password(NEW.pw)); + END IF; + + INSERT INTO internal.user_profiles ( + username, country, map_extent, email_address, report_reciever, active) + VALUES (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, NEW.report_reciever, NEW.active); + + -- Do not leak new password + NEW.pw = ''; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql + SECURITY DEFINER;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schema/updates/1465/01.delete_user.sql Fri Oct 15 15:14:33 2021 +0200 @@ -0,0 +1,98 @@ + +CREATE OR REPLACE FUNCTION internal.create_user() RETURNS trigger +AS $$ +BEGIN + IF NEW.map_extent IS NULL + THEN + NEW.map_extent = ST_Extent(CAST(area AS geometry)) + FROM users.stretches st + JOIN users.stretch_countries stc ON stc.stretch_id = st.id + WHERE stc.country = NEW.country; + END IF; + + IF NEW.username IS NOT NULL + -- otherwise let the constraint on user_profiles speak + THEN + EXECUTE format( + 'CREATE ROLE %I IN ROLE %I LOGIN PASSWORD %L', + NEW.username, + NEW.rolname, + internal.check_password(NEW.pw)); + END IF; + + INSERT INTO internal.user_profiles ( + username, country, map_extent, email_address, + report_reciever, active) + VALUES (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, + NEW.report_reciever, NEW.active); + + IF NEW.active THEN + EXECUTE format( + 'ALTER ROLE %I LOGIN', NEW.username); + ELSE + EXECUTE format( + 'ALTER ROLE %I NOLOGIN', NEW.username); + END IF; + + -- Do not leak new password + NEW.pw = ''; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql + SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION internal.update_user() RETURNS trigger +AS $$ +DECLARE + cur_username varchar; +BEGIN + cur_username = OLD.username; + + IF NEW.username <> cur_username + THEN + EXECUTE format( + 'ALTER ROLE %I RENAME TO %I', cur_username, NEW.username); + cur_username = NEW.username; + END IF; + + UPDATE internal.user_profiles p + SET (username, country, map_extent, email_address, + report_reciever, active) + = (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, + NEW.report_reciever, NEW.active) + WHERE p.username = cur_username; + + IF NEW.rolname <> OLD.rolname + THEN + EXECUTE format( + 'REVOKE %I FROM %I', OLD.rolname, cur_username); + EXECUTE format( + 'GRANT %I TO %I', NEW.rolname, cur_username); + END IF; + + IF NEW.pw IS NOT NULL AND NEW.pw <> '' + THEN + EXECUTE format( + 'ALTER ROLE %I PASSWORD %L', + cur_username, + internal.check_password(NEW.pw)); + END IF; + + IF NEW.active <> OLD.active THEN + IF NEW.active THEN + EXECUTE format( + 'ALTER ROLE %I LOGIN', cur_username); + ELSE + EXECUTE format( + 'ALTER ROLE %I NOLOGIN', cur_username); + END IF; + END IF; + + -- Do not leak new password + NEW.pw = ''; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql + SECURITY DEFINER;