Skip to main content

headless_lms_chatbot/chatbot_tools/action_tools/
generate_password_reset_link.rs

1use 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
23/// Generates a one-time password reset link for a user, shown to the admin only in the browser.
24pub struct GeneratePasswordResetLinkTool;
25
26/// `user_email` is the display/consistency field: re-checked against the account in [execute]
27/// before a token is minted, so a stale or wrong value refuses rather than mutates.
28pub 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
81/// What [GeneratePasswordResetLinkTool::execute] found out about the account while minting the
82/// link, beyond what the arguments already say.
83pub 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        // Must match the RESET_LINK substitution in server/src/programs/email_deliver.rs exactly,
171        // or the link this hands the admin will not resolve.
172        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}