headless_lms_chatbot/chatbot_tools/action_tools/
generate_password_reset_link.rs1use headless_lms_authorization::Action;
2use indexmap::IndexMap;
3use serde_json::json;
4
5use headless_lms_models::chatbot_configurations::ToolCategory;
6use headless_lms_models::{user_details, user_passwords, users};
7use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
8
9use crate::{
10 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
11 chatbot_tools::{
12 ChatbotToolDeclaration,
13 action_tools::{
14 ActionAuditFields, ConfirmableActionTool, ExecutedAction, verify_display_field,
15 },
16 argument_parsing::parse_required_uuid,
17 tool_authorization::{ToolAuthorization, ToolRequirement},
18 },
19 prelude::*,
20 user_context::ChatbotTurnContext,
21};
22
23pub struct GeneratePasswordResetLinkTool;
25
26pub struct GeneratePasswordResetLinkArguments {
29 user_id: Uuid,
30 user_email: String,
31}
32
33#[derive(Deserialize)]
34struct RawArguments {
35 user_id: String,
36 user_email: String,
37}
38
39impl ChatbotToolDeclaration for GeneratePasswordResetLinkTool {
40 const NAME: &'static str = "generate_password_reset_link";
41
42 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
43 vec![ToolRequirement::global(Action::AdministrateUserAccount)]
44 }
45
46 const CATEGORY: ToolCategory = ToolCategory::AdminSupportAccounts;
47
48 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
49 AzureLLMFunctionToolDefinition {
50 tool_type: LLMToolType::Function,
51 name: Self::NAME.to_string(),
52 description: "Generate a one-time password reset link for a user, after the admin confirms. Invalidates any previous reset link for that user. The link is shown to the admin in their browser, never to you.".to_string(),
53 parameters: Schema::strict_object(
54 IndexMap::from([
55 (
56 "user_id".to_string(),
57 SchemaPropertyType::Item(JsonItem {
58 type_field: JSONType::String,
59 description: Some(
60 "The user_id (UUID) of the account to generate a reset link for, as returned by find_user.".to_string(),
61 ),
62 }),
63 ),
64 (
65 "user_email".to_string(),
66 SchemaPropertyType::Item(JsonItem {
67 type_field: JSONType::String,
68 description: Some(
69 "The user's current email, exactly as find_user returned it. Shown to the admin and checked against the account before the link is generated.".to_string(),
70 ),
71 }),
72 ),
73 ]),
74 None,
75 ),
76 strict: true,
77 }
78 }
79}
80
81pub struct GeneratePasswordResetLinkFacts {
84 is_tmc_managed_with_no_local_password: bool,
85}
86
87impl ConfirmableActionTool for GeneratePasswordResetLinkTool {
88 type Arguments = GeneratePasswordResetLinkArguments;
89 type Facts = GeneratePasswordResetLinkFacts;
90
91 fn call_requirements(
92 arguments: &Self::Arguments,
93 _user_context: &ChatbotTurnContext,
94 ) -> Vec<ToolRequirement> {
95 vec![ToolRequirement::on_user(
96 Action::AdministrateUserAccount,
97 arguments.user_id,
98 )]
99 }
100
101 fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments> {
102 let raw: RawArguments = serde_json::from_str(arguments).map_err(|e| {
103 chatbot_err!(
104 InvalidToolArguments,
105 format!("Couldn't parse tool arguments. Arguments: {arguments}"),
106 e
107 )
108 })?;
109
110 let user_id = parse_required_uuid("user_id", &raw.user_id)?;
111
112 let user_email = raw.user_email.trim().to_string();
113 if user_email.is_empty() {
114 return Err(chatbot_err!(
115 InvalidToolArguments,
116 "user_email must not be empty.".to_string()
117 ));
118 }
119
120 Ok(GeneratePasswordResetLinkArguments {
121 user_id,
122 user_email,
123 })
124 }
125
126 async fn execute(
127 conn: &mut PgConnection,
128 app_config: &ApplicationConfiguration,
129 arguments: &Self::Arguments,
130 _authorization: &ToolAuthorization<Self>,
131 ) -> ChatbotResult<(ExecutedAction, Self::Facts)> {
132 let user = users::get_active_by_id(conn, arguments.user_id)
133 .await
134 .map_err(|e| {
135 chatbot_err!(
136 ToolUseError,
137 format!(
138 "No account found with user_id {} (or it is deleted). Re-run find_user.",
139 arguments.user_id
140 ),
141 e
142 )
143 })?;
144
145 let details = user_details::get_user_details_by_user_id(conn, user.id)
146 .await
147 .map_err(|e| {
148 chatbot_err!(
149 ToolUseError,
150 format!("No account details found for user_id {}.", user.id),
151 e
152 )
153 })?;
154
155 verify_display_field(
156 "user_email",
157 &details.email,
158 &arguments.user_email,
159 "find_user",
160 )?;
161
162 let has_local_password =
163 user_passwords::check_if_users_password_is_stored(conn, user.id).await?;
164 let is_tmc_managed_with_no_local_password =
165 user.upstream_id.is_some() && !has_local_password;
166
167 let token =
168 user_passwords::insert_password_reset_token(conn, user.id, Uuid::new_v4()).await?;
169
170 let reset_url = format!(
173 "{}/reset-user-password/{}",
174 app_config.base_url.trim_end_matches('/'),
175 token
176 );
177
178 Ok((
179 ExecutedAction {
180 output: format!(
181 "A password reset link for {} was generated and shown to the admin in the chat. It replaces any previous reset link. The link itself is not available to you.",
182 details.email
183 ),
184 client_payload: Some(json!({ "reset_link": reset_url })),
185 audit: ActionAuditFields {
186 target_user_id: Some(user.id),
187 course_id: None,
188 summary: format!("Generated a password reset link for {}", details.email),
189 },
190 },
191 GeneratePasswordResetLinkFacts {
192 is_tmc_managed_with_no_local_password,
193 },
194 ))
195 }
196
197 fn output_description_instructions(
198 arguments: &Self::Arguments,
199 facts: Option<&Self::Facts>,
200 app_config: &ApplicationConfiguration,
201 ) -> Option<String> {
202 let base_url = app_config.base_url.trim_end_matches('/');
203 let mut notes = vec![
204 "Tell the admin the link is shown above for copy-paste into their reply, that it invalidates any earlier reset link, and to send it only to the account's own email address, after confirming the requester is the account holder.".to_string(),
205 format!(
206 "Point the admin to {base_url}/manage/users/{} to confirm this is the right account and that the address they are about to send the link to is the one on the account -- not as a place to find the link itself.",
207 arguments.user_id
208 ),
209 "The link expires one hour after being generated and is single-use -- tell the admin to send it right away, and to re-run this tool if the student comes back after it has lapsed rather than trying to reuse or recover the old one. There is no admin page that lists outstanding reset links, which is why that expiry and the invalidate-on-regenerate behavior have to be stated here rather than looked up.".to_string(),
210 "You never see the link itself and must not claim to know it or offer to repeat it.".to_string(),
211 ];
212
213 if let Some(facts) = facts
214 && facts.is_tmc_managed_with_no_local_password
215 {
216 notes.push("This account has never had a local password -- redeeming the link will create one and permanently move password management for this account from TMC to this platform, which is worth telling the admin.".to_string());
217 }
218
219 Some(notes.join(" "))
220 }
221}