objective categorical abstract machine language personal data server

Password reset flow

futur.blue 79452164 79e9bbd2

verified
+247 -1
+2
bin/main.ml
··· 78 78 ; (post, "/account/migrate", Api.Account_.Migrate.post_handler) 79 79 ; (post, "/account/switch", Api.Account_.Login.switch_account_handler) 80 80 ; (get, "/account/logout", Api.Account_.Logout.handler) 81 + ; (get, "/account/reset", Api.Account_.Password_reset.get_handler) 82 + ; (post, "/account/reset", Api.Account_.Password_reset.post_handler) 81 83 ; (* passkey management (authed) *) 82 84 (get, "/account/passkeys", Api.Account_.Passkeys.list_handler) 83 85 ; ( get
+1
frontend/client/Router.mlx
··· 16 16 [ {path= "/"; template= (module RootPage)} 17 17 ; {path= "/oauth/authorize"; template= (module OauthAuthorizePage)} 18 18 ; {path= "/account/login"; template= (module LoginPage)} 19 + ; {path= "/account/reset"; template= (module PasswordResetPage)} 19 20 ; {path= "/account/signup"; template= (module SignupPage)} 20 21 ; {path= "/account/migrate"; template= (module MigratePage)} 21 22 ; {path= "/account"; template= (module AccountPage)}
+8 -1
frontend/src/templates/LoginPage.mlx
··· 215 215 (string "create an account") 216 216 </a> 217 217 </li> 218 - <li> 218 + <li className="mb-1"> 219 219 <a 220 220 className="text-mana-100 underline hover:text-mana-200" 221 221 href="/account/migrate"> 222 222 (string "migrate from another PDS") 223 + </a> 224 + </li> 225 + <li> 226 + <a 227 + className="text-mana-100 underline hover:text-mana-200" 228 + href="/account/password-reset"> 229 + (string "reset your password") 223 230 </a> 224 231 </li> 225 232 </ul>
+132
frontend/src/templates/PasswordResetPage.mlx
··· 1 + [@@@ocaml.warning "-26-27"] 2 + 3 + open Melange_json.Primitives 4 + open React 5 + 6 + type props = 7 + { csrf_token: string 8 + ; step: string [@default "request"] (* "request" | "reset" | "success" *) 9 + ; email_sent_to: string option [@default None] 10 + ; error: string option [@default None] } 11 + [@@deriving json] 12 + 13 + let[@react.component] make 14 + ~props:({csrf_token; step; email_sent_to; error} : props) () = 15 + <main className="w-full h-auto max-w-xs px-4 sm:px-0 my-auto"> 16 + <h1 className="text-2xl font-serif text-mana-200 mb-2"> 17 + (string "reset password") 18 + </h1> 19 + ( match step with 20 + | "success" -> 21 + <div> 22 + <span className="w-full text-balance text-mist-100"> 23 + (string 24 + "Your password has been reset successfully. You can now sign in \ 25 + with your new password." ) 26 + </span> 27 + <div className="mt-4"> 28 + <a 29 + className="text-mana-100 underline hover:text-mana-200" 30 + href="/account/login"> 31 + (string "sign in") 32 + </a> 33 + </div> 34 + </div> 35 + | "reset" -> 36 + <div> 37 + <span className="w-full text-balance text-mist-100"> 38 + ( match email_sent_to with 39 + | Some email -> 40 + string 41 + ( "A reset code has been sent to " ^ email 42 + ^ ". Enter the code and your new password below." ) 43 + | None -> 44 + string 45 + "Enter the reset code from your email and your new password." 46 + ) 47 + </span> 48 + <form className="w-full flex flex-col mt-4 mb-2 gap-y-2"> 49 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 50 + <input type_="hidden" name="step" value="reset" /> 51 + <Input 52 + sr_only=true 53 + name="token" 54 + type_="text" 55 + label="reset code" 56 + autoComplete="one-time-code" 57 + /> 58 + <Input 59 + sr_only=true 60 + name="password" 61 + type_="password" 62 + label="new password" 63 + autoComplete="new-password" 64 + /> 65 + ( match error with 66 + | Some err -> 67 + <span 68 + className="inline-flex items-center text-phoenix-100 text-sm"> 69 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 70 + </span> 71 + | None -> 72 + null ) 73 + <Button type_="submit" formMethod="post" className="mt-2"> 74 + (string "reset password") 75 + </Button> 76 + </form> 77 + <div className="mt-4"> 78 + <a 79 + className="text-sm text-mana-100 hover:text-mana-200" 80 + href="/account/password-reset"> 81 + (string "request a new code") 82 + </a> 83 + </div> 84 + </div> 85 + | _ -> 86 + <div> 87 + <span className="w-full text-balance text-mist-100"> 88 + (string 89 + "Enter your email address to receive a code to reset your \ 90 + password." ) 91 + </span> 92 + <form className="w-full flex flex-col mt-4 mb-2 gap-y-2"> 93 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 94 + <input type_="hidden" name="step" value="request" /> 95 + <Input 96 + sr_only=true 97 + name="email" 98 + type_="email" 99 + label="email" 100 + autoComplete="email" 101 + /> 102 + ( match error with 103 + | Some err -> 104 + <span 105 + className="inline-flex items-center text-phoenix-100 text-sm"> 106 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 107 + </span> 108 + | None -> 109 + null ) 110 + <Button type_="submit" formMethod="post" className="mt-2"> 111 + (string "send reset code") 112 + </Button> 113 + </form> 114 + <div className="mt-4"> 115 + <span className="text-sm text-mist-100"> 116 + (string "already have a code? ") 117 + <a 118 + className="text-mana-100 underline hover:text-mana-200" 119 + href="/account/password-reset?step=reset"> 120 + (string "enter it here") 121 + </a> 122 + </span> 123 + </div> 124 + </div> ) 125 + <div className="mt-2"> 126 + <a 127 + className="text-sm underline text-mana-100 hover:text-mana-200" 128 + href="/account/login"> 129 + (string "back to sign in") 130 + </a> 131 + </div> 132 + </main>
+104
pegasus/lib/api/account_/password_reset.ml
··· 1 + let get_handler = 2 + Xrpc.handler (fun ctx -> 3 + let csrf_token = Dream.csrf_token ctx.req in 4 + let step = 5 + Dream.query ctx.req "step" |> Option.value ~default:"request" 6 + in 7 + Util.render_html ~title:"Reset Password" 8 + (module Frontend.PasswordResetPage) 9 + ~props:{csrf_token; step; email_sent_to= None; error= None} ) 10 + 11 + let post_handler = 12 + Xrpc.handler (fun ctx -> 13 + let csrf_token = Dream.csrf_token ctx.req in 14 + match%lwt Dream.form ctx.req with 15 + | `Ok fields -> ( 16 + let step = 17 + List.assoc_opt "step" fields |> Option.value ~default:"request" 18 + in 19 + match step with 20 + | "reset" -> ( 21 + let token = 22 + List.assoc_opt "token" fields |> Option.value ~default:"" 23 + in 24 + let password = 25 + List.assoc_opt "password" fields |> Option.value ~default:"" 26 + in 27 + if String.length token = 0 then 28 + Util.render_html ~status:`Bad_Request ~title:"Reset Password" 29 + (module Frontend.PasswordResetPage) 30 + ~props: 31 + { csrf_token 32 + ; step= "reset" 33 + ; email_sent_to= None 34 + ; error= Some "Please enter the reset code." } 35 + else if String.length password < 8 then 36 + Util.render_html ~status:`Bad_Request ~title:"Reset Password" 37 + (module Frontend.PasswordResetPage) 38 + ~props: 39 + { csrf_token 40 + ; step= "reset" 41 + ; email_sent_to= None 42 + ; error= Some "Password must be at least 8 characters." } 43 + else 44 + match%lwt 45 + Server.ResetPassword.reset_password ~token ~password ctx.db 46 + with 47 + | Ok _ -> 48 + Util.render_html ~title:"Reset Password" 49 + (module Frontend.PasswordResetPage) 50 + ~props: 51 + { csrf_token 52 + ; step= "success" 53 + ; email_sent_to= None 54 + ; error= None } 55 + | Error Server.ResetPassword.InvalidToken 56 + | Error Server.ResetPassword.ExpiredToken -> 57 + Util.render_html ~status:`Bad_Request ~title:"Reset Password" 58 + (module Frontend.PasswordResetPage) 59 + ~props: 60 + { csrf_token 61 + ; step= "reset" 62 + ; email_sent_to= None 63 + ; error= 64 + Some 65 + "Invalid or expired reset code. Please request a \ 66 + new one." } ) 67 + | _ -> 68 + let email = 69 + List.assoc_opt "email" fields 70 + |> Option.value ~default:"" 71 + |> String.lowercase_ascii 72 + in 73 + if String.length email = 0 then 74 + Util.render_html ~status:`Bad_Request ~title:"Reset Password" 75 + (module Frontend.PasswordResetPage) 76 + ~props: 77 + { csrf_token 78 + ; step= "request" 79 + ; email_sent_to= None 80 + ; error= Some "Please enter your email address." } 81 + else 82 + let%lwt () = 83 + match%lwt Data_store.get_actor_by_identifier email ctx.db with 84 + | Some actor -> 85 + Server.RequestPasswordReset.request_password_reset actor 86 + ctx.db 87 + | None -> 88 + Lwt.return_unit 89 + in 90 + Util.render_html ~title:"Reset Password" 91 + (module Frontend.PasswordResetPage) 92 + ~props: 93 + { csrf_token 94 + ; step= "reset" 95 + ; email_sent_to= Some email 96 + ; error= None } ) 97 + | _ -> 98 + Util.render_html ~status:`Bad_Request ~title:"Reset Password" 99 + (module Frontend.PasswordResetPage) 100 + ~props: 101 + { csrf_token 102 + ; step= "request" 103 + ; email_sent_to= None 104 + ; error= Some "Invalid form submission. Please try again." } )