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;
--- a/schema/version.sql	Tue Sep 21 12:49:30 2021 +0200
+++ b/schema/version.sql	Fri Oct 15 15:14:33 2021 +0200
@@ -1,1 +1,1 @@
-INSERT INTO gemma_schema_version(version) VALUES (1463);
+INSERT INTO gemma_schema_version(version) VALUES (1465);