···11open React
2233-let[@react.component] make ~(children : unit -> React.element) () =
33+let[@react.component] make ~(children : unit -> React.element)
44+ ?(fallback = null) () =
45 let is_mounted, set_mounted = useState (fun () -> false) in
56 useEffect (fun () ->
67 let () = set_mounted (fun _ -> true) in
78 None ) ;
88- if is_mounted then children () else null
99+ if is_mounted then children () else fallback
+5-4
frontend/src/components/Input.mlx
···44let req_marker = " *"
5566let[@react.component] make ?id ~name ?(className = "") ?(type_ = "text") ?label
77- ?(sr_only = false) ?value ?placeholder ?(required = false)
88- ?(disabled = false) ?trailing () =
77+ ?(sr_only = false) ?value ?placeholder ?autoComplete ?(required = false)
88+ ?(disabled = false) ?trailing ?(show_optional_indicator = true) () =
99 let id = Option.value id ~default:name in
1010 let placeholder = if label <> None && sr_only then label else placeholder in
1111 let input =
···1414 type_
1515 name
1616 ?placeholder
1717+ ?autoComplete
1718 required
1819 disabled
1920 ?value
···2728 <div
2829 className=( "flex justify-between text-sm"
2930 ^ if sr_only then " sr-only" else "" )>
3030- <label htmlFor=id className="text-mist-100">
3131+ <label htmlFor=id className="text-mist-100 mb-1">
3132 (string label)
3233 ( if required then
3334 <span className="text-phoenix-100">(string req_marker)</span>
3435 else null )
3536 </label>
3636- ( if required then null
3737+ ( if required || not show_optional_indicator then null
3738 else <span className="text-mist-80">(string "optional")</span> )
3839 </div>
3940 | None ->
···11+let has_valid_delete_code (actor : Data_store.Types.actor) =
22+ match (actor.auth_code, actor.auth_code_expires_at) with
33+ | Some code, Some expires_at ->
44+ String.starts_with ~prefix:"del-" code && expires_at > Util.now_ms ()
55+ | _ ->
66+ false
77+88+let parse_email_change_code (actor : Data_store.Types.actor) =
99+ match (actor.auth_code, actor.auth_code_expires_at) with
1010+ | Some code, Some expires_at when expires_at > Util.now_ms () ->
1111+ if String.starts_with ~prefix:"eml-" code then
1212+ let rest = String.sub code 6 (String.length code - 6) in
1313+ match String.index_opt rest ':' with
1414+ | Some idx ->
1515+ let token = String.sub rest 0 idx in
1616+ let new_email =
1717+ String.sub rest (idx + 1) (String.length rest - idx - 1)
1818+ in
1919+ Some (token, new_email)
2020+ | None ->
2121+ None
2222+ else None
2323+ | _ ->
2424+ None
2525+2626+let get_handler =
2727+ Xrpc.handler (fun ctx ->
2828+ match%lwt Session.Raw.get_current_did ctx.req with
2929+ | None ->
3030+ Dream.redirect ctx.req "/account/login"
3131+ | Some did -> (
3232+ let%lwt logged_in_users =
3333+ Session.list_logged_in_actors ctx.req ctx.db
3434+ in
3535+ match%lwt Data_store.get_actor_by_identifier did ctx.db with
3636+ | None ->
3737+ Dream.redirect ctx.req "/account/login"
3838+ | Some actor ->
3939+ let current_user : Frontend.AccountSwitcher.actor =
4040+ {did= actor.did; handle= actor.handle; avatar_data_uri= None}
4141+ in
4242+ let csrf_token = Dream.csrf_token ctx.req in
4343+ let deactivated = actor.deactivated_at <> None in
4444+ let email_change_info = parse_email_change_code actor in
4545+ let email_change_pending = Option.is_some email_change_info in
4646+ let pending_email = Option.map snd email_change_info in
4747+ let delete_pending = has_valid_delete_code actor in
4848+ Util.render_html ~title:"Account"
4949+ (module Frontend.AccountPage)
5050+ ~props:
5151+ { current_user
5252+ ; logged_in_users
5353+ ; csrf_token
5454+ ; handle= actor.handle
5555+ ; email= actor.email
5656+ ; deactivated
5757+ ; email_change_pending
5858+ ; pending_email
5959+ ; email_error= None
6060+ ; delete_pending
6161+ ; error= None
6262+ ; success= None
6363+ ; delete_error= None } ) )
6464+6565+let post_handler =
6666+ Xrpc.handler (fun ctx ->
6767+ match%lwt Session.Raw.get_current_did ctx.req with
6868+ | None ->
6969+ Dream.redirect ctx.req "/account/login"
7070+ | Some did -> (
7171+ let%lwt logged_in_users =
7272+ Session.list_logged_in_actors ctx.req ctx.db
7373+ in
7474+ match%lwt Data_store.get_actor_by_identifier did ctx.db with
7575+ | None ->
7676+ Dream.redirect ctx.req "/account/login"
7777+ | Some actor -> (
7878+ let current_user : Frontend.AccountSwitcher.actor =
7979+ {did= actor.did; handle= actor.handle; avatar_data_uri= None}
8080+ in
8181+ let csrf_token = Dream.csrf_token ctx.req in
8282+ let render_page ?error ?success ?email_error ?delete_error () =
8383+ let%lwt actor_opt =
8484+ Data_store.get_actor_by_identifier did ctx.db
8585+ in
8686+ let actor = Option.get actor_opt in
8787+ let deactivated = actor.deactivated_at <> None in
8888+ let email_change_info = parse_email_change_code actor in
8989+ let email_change_pending = Option.is_some email_change_info in
9090+ let pending_email = Option.map snd email_change_info in
9191+ let delete_pending = has_valid_delete_code actor in
9292+ Util.render_html ~title:"Account"
9393+ (module Frontend.AccountPage)
9494+ ~props:
9595+ { current_user= {current_user with handle= actor.handle}
9696+ ; logged_in_users
9797+ ; csrf_token
9898+ ; handle= actor.handle
9999+ ; email= actor.email
100100+ ; deactivated
101101+ ; email_change_pending
102102+ ; pending_email
103103+ ; email_error
104104+ ; delete_pending
105105+ ; error
106106+ ; success
107107+ ; delete_error }
108108+ in
109109+ match%lwt Dream.form ctx.req with
110110+ | `Ok fields -> (
111111+ let action = List.assoc_opt "action" fields in
112112+ match action with
113113+ | Some "save" -> (
114114+ let new_handle =
115115+ List.assoc_opt "handle" fields
116116+ |> Option.value ~default:actor.handle
117117+ in
118118+ let new_password = List.assoc_opt "password" fields in
119119+ (* update handle if changed *)
120120+ let%lwt handle_result =
121121+ if new_handle <> actor.handle then
122122+ match Util.validate_handle new_handle with
123123+ | Error e ->
124124+ Lwt.return_error e
125125+ | Ok () -> (
126126+ match%lwt
127127+ Data_store.get_actor_by_identifier new_handle
128128+ ctx.db
129129+ with
130130+ | Some _ ->
131131+ Lwt.return_error "Handle already in use"
132132+ | None ->
133133+ let%lwt () =
134134+ Data_store.update_actor_handle ~did
135135+ ~handle:new_handle ctx.db
136136+ in
137137+ Lwt.return_ok () )
138138+ else Lwt.return_ok ()
139139+ in
140140+ match handle_result with
141141+ | Error e ->
142142+ render_page ~error:e ()
143143+ | Ok () ->
144144+ (* update password if provided *)
145145+ let%lwt () =
146146+ match new_password with
147147+ | Some pw when String.length pw > 0 ->
148148+ Data_store.update_password ~did ~password:pw
149149+ ctx.db
150150+ | _ ->
151151+ Lwt.return_unit
152152+ in
153153+ render_page ~success:"Changes saved." () )
154154+ | Some "reactivate" ->
155155+ let%lwt () = Data_store.activate_actor did ctx.db in
156156+ let%lwt _ =
157157+ Sequencer.sequence_account ctx.db ~did ~active:true
158158+ ~status:`Active ()
159159+ in
160160+ render_page ~success:"Your account has been reactivated."
161161+ ()
162162+ | Some "deactivate" ->
163163+ let%lwt () = Data_store.deactivate_actor did ctx.db in
164164+ let%lwt _ =
165165+ Sequencer.sequence_account ctx.db ~did ~active:false
166166+ ~status:`Deactivated ()
167167+ in
168168+ let%lwt () = Session.Raw.clear_session ctx.req in
169169+ Dream.redirect ctx.req "/account/login"
170170+ | Some "request_delete" ->
171171+ let code = "del-" ^ Mist.Tid.now () in
172172+ let expires_at = Util.now_ms () + (15 * 60 * 1000) in
173173+ let%lwt () =
174174+ Data_store.set_auth_code ~did ~code ~expires_at ctx.db
175175+ in
176176+ (* TODO: send email with code *)
177177+ Dream.log "delete account code for %s: %s" did code ;
178178+ render_page ()
179179+ | Some "confirm_delete" -> (
180180+ let token =
181181+ List.assoc_opt "token" fields
182182+ |> Option.value ~default:""
183183+ in
184184+ match (actor.auth_code, actor.auth_code_expires_at) with
185185+ | Some code, Some expires_at
186186+ when code = token && expires_at > Util.now_ms () ->
187187+ let%lwt () = Data_store.delete_actor did ctx.db in
188188+ let%lwt _ =
189189+ Sequencer.sequence_account ctx.db ~did ~active:false
190190+ ~status:`Deleted ()
191191+ in
192192+ let%lwt () = Session.Raw.clear_session ctx.req in
193193+ Dream.redirect ctx.req "/account/login"
194194+ | _ ->
195195+ render_page
196196+ ~delete_error:
197197+ "Invalid or expired confirmation code."
198198+ () )
199199+ | Some "cancel_delete" ->
200200+ let%lwt () = Data_store.clear_auth_code ~did ctx.db in
201201+ render_page ()
202202+ | Some "request_email_change" -> (
203203+ let new_email =
204204+ List.assoc_opt "new_email" fields
205205+ |> Option.value ~default:"" |> String.trim
206206+ in
207207+ if String.length new_email = 0 then
208208+ render_page
209209+ ~email_error:"Please enter a new email address." ()
210210+ else if new_email = actor.email then
211211+ render_page
212212+ ~email_error:"That's already your email address." ()
213213+ else
214214+ match%lwt
215215+ Data_store.get_actor_by_identifier new_email ctx.db
216216+ with
217217+ | Some _ ->
218218+ render_page ~email_error:"Email is already in use."
219219+ ()
220220+ | None ->
221221+ let token = Mist.Tid.now () in
222222+ let code = "eml-" ^ token ^ ":" ^ new_email in
223223+ let expires_at =
224224+ Util.now_ms () + (15 * 60 * 1000)
225225+ in
226226+ let%lwt () =
227227+ Data_store.set_auth_code ~did ~code ~expires_at
228228+ ctx.db
229229+ in
230230+ (* TODO: send email with code *)
231231+ Dream.log "email change code for %s: %s" actor.email
232232+ code ;
233233+ render_page () )
234234+ | Some "confirm_email_change" -> (
235235+ let token =
236236+ List.assoc_opt "token" fields
237237+ |> Option.value ~default:"" |> String.trim
238238+ in
239239+ match parse_email_change_code actor with
240240+ | Some (stored_token, new_email) when stored_token = token
241241+ ->
242242+ let%lwt () =
243243+ Data_store.update_email ~did ~email:new_email ctx.db
244244+ in
245245+ let%lwt () = Data_store.clear_auth_code ~did ctx.db in
246246+ render_page ~success:"Email address updated." ()
247247+ | _ ->
248248+ render_page
249249+ ~email_error:"Invalid or expired verification code."
250250+ () )
251251+ | Some "cancel_email_change" ->
252252+ let%lwt () = Data_store.clear_auth_code ~did ctx.db in
253253+ render_page ()
254254+ | _ ->
255255+ render_page ~error:"Invalid action." () )
256256+ | _ ->
257257+ render_page ~error:"Invalid form submission." () ) ) )
+111
pegasus/lib/api/account_/permissions.ml
···11+let get_client_host client_id =
22+ let uri = Uri.of_string client_id in
33+ Uri.host uri |> Option.value ~default:client_id
44+55+let get_handler =
66+ Xrpc.handler (fun ctx ->
77+ match%lwt Session.Raw.get_current_did ctx.req with
88+ | None ->
99+ Dream.redirect ctx.req "/account/login"
1010+ | Some did -> (
1111+ let%lwt logged_in_users =
1212+ Session.list_logged_in_actors ctx.req ctx.db
1313+ in
1414+ match%lwt Data_store.get_actor_by_identifier did ctx.db with
1515+ | None ->
1616+ Dream.redirect ctx.req "/account/login"
1717+ | Some actor ->
1818+ let current_user : Frontend.AccountSwitcher.actor =
1919+ {did= actor.did; handle= actor.handle; avatar_data_uri= None}
2020+ in
2121+ let csrf_token = Dream.csrf_token ctx.req in
2222+ let%lwt clients =
2323+ Oauth.Queries.get_distinct_clients_by_did ctx.db did
2424+ in
2525+ let%lwt authorized_apps =
2626+ Lwt_list.filter_map_s
2727+ (fun (client_id, _last_refreshed) ->
2828+ try%lwt
2929+ let%lwt metadata =
3030+ Oauth.Client.fetch_client_metadata client_id
3131+ in
3232+ let app : Frontend.AccountPermissionsPage.authorized_app =
3333+ { client_id
3434+ ; client_name= metadata.client_name
3535+ ; client_host= get_client_host client_id }
3636+ in
3737+ Lwt.return_some app
3838+ with _ ->
3939+ let app : Frontend.AccountPermissionsPage.authorized_app =
4040+ { client_id
4141+ ; client_name= None
4242+ ; client_host= get_client_host client_id }
4343+ in
4444+ Lwt.return_some app )
4545+ clients
4646+ in
4747+ let%lwt device_rows =
4848+ Oauth.Queries.get_distinct_devices_by_did ctx.db did
4949+ in
5050+ let current_ip = Dream.client ctx.req in
5151+ let current_ua = Dream.header ctx.req "User-Agent" in
5252+ let devices =
5353+ List.map
5454+ (fun (last_ip, last_user_agent, last_refreshed_at) ->
5555+ let is_current =
5656+ last_ip = current_ip && last_user_agent = current_ua
5757+ in
5858+ ( {last_ip; last_user_agent; last_refreshed_at; is_current}
5959+ : Frontend.AccountPermissionsPage.device ) )
6060+ device_rows
6161+ in
6262+ Util.render_html ~title:"Permissions"
6363+ (module Frontend.AccountPermissionsPage)
6464+ ~props:
6565+ { current_user
6666+ ; logged_in_users
6767+ ; csrf_token
6868+ ; authorized_apps
6969+ ; devices } ) )
7070+7171+let post_handler =
7272+ Xrpc.handler (fun ctx ->
7373+ match%lwt Session.Raw.get_current_did ctx.req with
7474+ | None ->
7575+ Dream.redirect ctx.req "/account/login"
7676+ | Some did -> (
7777+ match%lwt Dream.form ctx.req with
7878+ | `Ok fields -> (
7979+ let action = List.assoc_opt "action" fields in
8080+ match action with
8181+ | Some "revoke_app" -> (
8282+ let client_id = List.assoc_opt "client_id" fields in
8383+ match client_id with
8484+ | Some client_id ->
8585+ let%lwt () =
8686+ Oauth.Queries.delete_oauth_tokens_by_client ctx.db ~did
8787+ ~client_id
8888+ in
8989+ Dream.redirect ctx.req "/account/permissions"
9090+ | None ->
9191+ Dream.redirect ctx.req "/account/permissions" )
9292+ | Some "sign_out_device" ->
9393+ let last_ip =
9494+ List.assoc_opt "last_ip" fields |> Option.value ~default:""
9595+ in
9696+ let last_user_agent =
9797+ match List.assoc_opt "last_user_agent" fields with
9898+ | Some "" ->
9999+ None
100100+ | other ->
101101+ other
102102+ in
103103+ let%lwt () =
104104+ Oauth.Queries.delete_oauth_tokens_by_device ctx.db ~did
105105+ ~last_ip ~last_user_agent
106106+ in
107107+ Dream.redirect ctx.req "/account/permissions"
108108+ | _ ->
109109+ Dream.redirect ctx.req "/account/permissions" )
110110+ | _ ->
111111+ Dream.redirect ctx.req "/account/permissions" ) )
+5
pegasus/lib/auth.ml
···309309 match%lwt Data_store.get_actor_by_identifier did db with
310310 | Some {deactivated_at= None; _} ->
311311 Lwt.return_ok (Access {did})
312312+ | Some {deactivated_at= Some _; _} when Dream.target req = "/account" ->
313313+ Lwt.return_ok (Access {did})
314314+ | Some {deactivated_at= Some _; _}
315315+ when String.starts_with ~prefix:"/account" (Dream.target req) ->
316316+ raise (Errors.Redirect "/account")
312317 | Some {deactivated_at= Some _; _} ->
313318 Lwt.return_error
314319 @@ Errors.auth_required ~name:"AccountDeactivated"
+2
pegasus/lib/errors.ml
···6677exception NotFoundError of (string * string)
8899+exception Redirect of string
1010+911exception UseDpopNonceError
10121113let is_xrpc_error = function
+48
pegasus/lib/oauth/queries.ml
···140140 |sql}
141141 record_out]
142142 ~did
143143+144144+let get_distinct_clients_by_did conn did =
145145+ Util.use_pool conn
146146+ @@ [%rapper
147147+ get_many
148148+ {sql|
149149+ SELECT DISTINCT @string{client_id}, MAX(@int{last_refreshed_at}) as last_refreshed_at
150150+ FROM oauth_tokens
151151+ WHERE did = %string{did}
152152+ GROUP BY client_id
153153+ ORDER BY last_refreshed_at DESC
154154+ |sql}]
155155+ ~did
156156+157157+let get_distinct_devices_by_did conn did =
158158+ Util.use_pool conn
159159+ @@ [%rapper
160160+ get_many
161161+ {sql|
162162+ SELECT @string{last_ip}, @string?{last_user_agent},
163163+ MAX(@int{last_refreshed_at}) as last_refreshed_at
164164+ FROM oauth_tokens
165165+ WHERE did = %string{did}
166166+ GROUP BY last_ip, last_user_agent
167167+ ORDER BY last_refreshed_at DESC
168168+ |sql}]
169169+ ~did
170170+171171+let delete_oauth_tokens_by_client conn ~did ~client_id =
172172+ Util.use_pool conn
173173+ @@ [%rapper
174174+ execute
175175+ {sql|
176176+ DELETE FROM oauth_tokens
177177+ WHERE did = %string{did} AND client_id = %string{client_id}
178178+ |sql}]
179179+ ~did ~client_id
180180+181181+let delete_oauth_tokens_by_device conn ~did ~last_ip ~last_user_agent =
182182+ Util.use_pool conn
183183+ @@ [%rapper
184184+ execute
185185+ {sql|
186186+ DELETE FROM oauth_tokens
187187+ WHERE did = %string{did} AND last_ip = %string{last_ip}
188188+ AND (last_user_agent = %string?{last_user_agent} OR (last_user_agent IS NULL AND %string?{last_user_agent} IS NULL))
189189+ |sql}]
190190+ ~did ~last_ip ~last_user_agent
+2-4
pegasus/lib/session.ml
···140140 Lwt_list.filter_map_s
141141 (fun did ->
142142 match%lwt Data_store.get_actor_by_identifier did db with
143143- | Some {deactivated_at= None; handle; _} -> (
143143+ | Some {handle; _} -> (
144144 let actor : Frontend.OauthAuthorizePage.actor =
145145 {did; handle; avatar_data_uri= None}
146146 in
···164164 Lwt.return_some actor )
165165 | None ->
166166 Lwt.return_some actor )
167167- | Some {deactivated_at= Some _; _} ->
168168- Lwt.return_none
169169- | None ->
167167+ | _ ->
170168 Lwt.return_none )
171169 dids
+16-11
pegasus/lib/xrpc.ml
···991010let handler ?(auth : Auth.Verifiers.t = Any) (hdlr : handler) (init : init) =
1111 let open Errors in
1212- let auth = Auth.Verifiers.of_t auth in
1313- try%lwt
1414- match%lwt auth init with
1515- | Ok creds -> (
1616- try%lwt hdlr {req= init.req; db= init.db; auth= creds}
1717- with e ->
1212+ try
1313+ let auth = Auth.Verifiers.of_t auth in
1414+ try%lwt
1515+ match%lwt auth init with
1616+ | Ok creds -> (
1717+ try%lwt hdlr {req= init.req; db= init.db; auth= creds}
1818+ with e ->
1919+ if not (is_xrpc_error e) then log_exn ~req:init.req e ;
2020+ exn_to_response e )
2121+ | Error e ->
2222+ exn_to_response e
2323+ with
2424+ | Redirect r ->
2525+ Dream.redirect init.req r
2626+ | e ->
1827 if not (is_xrpc_error e) then log_exn ~req:init.req e ;
1919- exn_to_response e )
2020- | Error e ->
2128 exn_to_response e
2222- with e ->
2323- if not (is_xrpc_error e) then log_exn ~req:init.req e ;
2424- exn_to_response e
2929+ with Redirect r -> Dream.redirect init.req r
25302631let parse_query (req : Dream.request)
2732 (of_yojson : Yojson.Safe.t -> ('a, string) result) : 'a =