diff --git a/src/config.rs b/src/config.rs
index a854a7230fcab99ace0047da31f50f35e296e2d1..4812888a9eea9022e4243ab645c1347347fc1bfd 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -24,6 +24,7 @@ pub struct UccbotConfig {
     pub tiebreaker_role: u64,
     pub unregistered_member_role: u64,
     pub registered_member_role: u64,
+    pub expired_member_role: u64,
     pub command_prefix: String,
     pub for_vote: String,
     pub against_vote: String,
diff --git a/src/config.yml b/src/config.yml
index 9cbe58b5c615563ef2b94fa9c60cd2a7f2455107..5ea40fba6739d2cbe38266b1592b790d341c45cf 100644
--- a/src/config.yml
+++ b/src/config.yml
@@ -13,6 +13,7 @@ vote_role: 607478818038480937
 tiebreaker_role: 607509283483025409
 unregistered_member_role: 608282247350714408
 registered_member_role: 608282133118582815
+expired_member_role: 0
 command_prefix: "!"
 
 for_vote: "👍"
diff --git a/src/ldap.rs b/src/ldap.rs
index ec7d67da46cf5026e96c53956ba1735e6f1f054c..7dce6c243482a85daab43152fb355cf691ae3049 100644
--- a/src/ldap.rs
+++ b/src/ldap.rs
@@ -6,6 +6,7 @@ pub struct LDAPUser {
     pub username: String,
     pub name: String,
     pub when_created: String,
+    pub login_shell: String,
 }
 
 pub fn ldap_search(username: &str) -> Option<LDAPUser> {
@@ -24,7 +25,7 @@ pub fn ldap_search(username: &str) -> Option<LDAPUser> {
             "cn=Users,dc=ad,dc=ucc,dc=gu,dc=uwa,dc=edu,dc=au",
             Scope::Subtree,
             &format!("(cn={})", ldap3::ldap_escape(username)),
-            vec!["when_created", "displayName", "name"],
+            vec!["when_created", "displayName", "name", "loginShell"],
         )
         .expect("LDAP error")
         .success()
@@ -42,10 +43,14 @@ pub fn ldap_search(username: &str) -> Option<LDAPUser> {
             .get("displayName")
             .expect("LDAP failed to get 'displayName' field")
             .join(""),
-        when_created: "".to_string() // result
+        when_created: "".to_string(), // result
             // .get("whenCreated")
             // .expect("LDAP failed to get 'whenCreated' field")
             // .join(""),
+        login_shell: result
+            .get("loginShell")
+            .expect("LDAP failed to get 'loginShell' field")
+            .join(""),
     })
 }
 
diff --git a/src/main.rs b/src/main.rs
index cc347e2fd0973d97c147c5e3768a59f85edfa4c6..d26cd29d8c97b2e0ef07325c20cf6bd431e63ccb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -122,7 +122,8 @@ impl EventHandler for Handler {
                                                  `!poll <text>` to get people's opinions on something", false);
                         embed.field("Account", "`!register <ucc username>` to link your Discord and UCC account\n\
                                                 `!profile <user>` to get the profile of a user\n\
-                                                `!set <bio|git|web|photo>` to set that property of _your_ profile", false);
+                                                `!set <bio|git|web|photo>` to set that property of _your_ profile\n\
+                                                `!updateroles` to update your registered roles", false);
                         embed.field("Fun", "`!cowsay <text>` to have a cow say your words\n\
                                             with no `<text>` it'll give you a fortune 😉", false);
                         embed
@@ -152,6 +153,7 @@ impl EventHandler for Handler {
                 &ctx.http,
                 format!("{:?}", ldap::tla_search(message_content[1]))
             ),
+            "updateroles" => user_management::Commands::update_registered_role(ctx, msg),
             _ => send_message!(
                 msg.channel_id,
                 &ctx.http,
diff --git a/src/user_management.rs b/src/user_management.rs
index 6791618e412d82d9ae56390788f0420fc979caa7..4c48f050a1b038cefef109e4d30d2ea28037658e 100644
--- a/src/user_management.rs
+++ b/src/user_management.rs
@@ -1,7 +1,7 @@
 use rand::seq::SliceRandom;
 use regex::Regex;
 use serenity::{
-    model::{channel::Message, guild::Member},
+    model::{channel::Message, guild::Member, id::RoleId},
     prelude::*,
     utils::MessageBuilder,
 };
@@ -10,7 +10,7 @@ use url::Url;
 
 use crate::config::CONFIG;
 use crate::database;
-use crate::ldap::ldap_exists;
+use crate::ldap::{ldap_exists, ldap_search};
 use crate::token_management::*;
 
 pub fn new_member(ctx: &Context, mut new_member: Member) {
@@ -133,6 +133,55 @@ impl Commands {
             Err(why) => error!("Unable to send message with mutt {:?}", why),
         };
     }
+
+    pub fn get_registered_role(name: String) -> Option<u64> {
+        guard!(let Some(result) = ldap_search(&name) else {
+            return None
+        });
+        if result.login_shell.contains("locked")
+            && CONFIG.expired_member_role > 0 {
+            return Some(CONFIG.expired_member_role)
+        }
+        Some(CONFIG.registered_member_role)
+    }
+
+    // TODO: make this return a result
+    // NOTE: don't make this directly send messages, so it can be used for mass updates
+    pub fn update_registered_role(ctx: Context, msg: Message) {
+        guard!(let Ok(member_info) = database::get_member_info(&msg.author.id.0) else {
+            return // Err()
+        });
+        guard!(let Some(registered_role) = Commands::get_registered_role(member_info.username) else {
+            return // Err()
+        });
+        guard!(let Ok(mut discord_member) = serenity::model::id::GuildId(CONFIG.server_id)
+            .member(ctx.http.clone(), msg.author.id) else {
+            return // Err()
+        });
+
+        let roles_to_remove = vec![
+            CONFIG.registered_member_role,
+            CONFIG.unregistered_member_role,
+            CONFIG.expired_member_role];
+
+        for role in roles_to_remove {
+            if role == registered_role { // remove when vec.remove_item is stable
+                continue
+            }
+            if discord_member.roles.contains(&RoleId::from(role))
+                && discord_member.remove_role(&ctx.http, role).is_err() {
+                return // Err()
+            }
+        }
+
+        if !discord_member.roles.contains(&RoleId::from(registered_role))
+            && discord_member.add_role(&ctx.http, registered_role).is_err() {
+            return // Err()
+        }
+
+        // Ok()
+    }
+
     pub fn verify(ctx: Context, msg: Message, token: &str) {
         match parse_token(&msg.author, token) {
             Ok(name) => {
@@ -146,9 +195,13 @@ impl Commands {
                                 "Unable to remove role: {:?}",
                                 member.remove_role(&ctx.http, CONFIG.unregistered_member_role)
                             );
+                            guard!(let Some(member_role) = Commands::get_registered_role(name) else {
+                                send_message!(msg.channel_id, ctx.http.clone(), "Couldn't find you in LDAP!");
+                                return
+                            });
                             e!(
                                 "Unable to add role: {:?}",
-                                member.add_role(&ctx.http, CONFIG.registered_member_role)
+                                member.add_role(&ctx.http, member_role)
                             );
                             e!(
                                 "Unable to edit nickname: {:?}",