objective categorical abstract machine language personal data server

Admin UI

futur.blue 85b8e34b b0c234b6

verified
+1468 -38
+9 -1
bin/main.ml
··· 22 22 ; (post, "/oauth/authorize", Api.Oauth_.Authorize.post_handler) 23 23 ; (options, "/oauth/token", Xrpc.handler (fun _ -> Dream.empty `No_Content)) 24 24 ; (post, "/oauth/token", Api.Oauth_.Token.post_handler) 25 - ; (* account *) 25 + ; (* account ui *) 26 26 (get, "/account", Api.Account_.Index.get_handler) 27 27 ; (post, "/account", Api.Account_.Index.post_handler) 28 28 ; (get, "/account/permissions", Api.Account_.Permissions.get_handler) ··· 30 30 ; (get, "/account/login", Api.Account_.Login.get_handler) 31 31 ; (post, "/account/login", Api.Account_.Login.post_handler) 32 32 ; (get, "/account/logout", Api.Account_.Logout.handler) 33 + ; (* admin ui *) 34 + (get, "/admin", Api.Admin_.Index.handler) 35 + ; (get, "/admin/login", Api.Admin_.Login.get_handler) 36 + ; (post, "/admin/login", Api.Admin_.Login.post_handler) 37 + ; (get, "/admin/users", Api.Admin_.Users.get_handler) 38 + ; (post, "/admin/users", Api.Admin_.Users.post_handler) 39 + ; (get, "/admin/invites", Api.Admin_.Invites.get_handler) 40 + ; (post, "/admin/invites", Api.Admin_.Invites.post_handler) 33 41 ; (* unauthed *) 34 42 ( get 35 43 , "/xrpc/com.atproto.server.describeServer"
+7 -2
frontend/client/Router.mlx
··· 14 14 15 15 let routes = 16 16 [ {path= "/oauth/authorize"; template= (module OauthAuthorizePage)} 17 + ; {path= "/account/login"; template= (module LoginPage)} 17 18 ; {path= "/account"; template= (module AccountPage)} 18 19 ; {path= "/account/permissions"; template= (module AccountPermissionsPage)} 19 - ; {path= "/account/login"; template= (module LoginPage)} ] 20 + ; {path= "/admin/login"; template= (module AdminLoginPage)} 21 + ; {path= "/admin/users"; template= (module AdminUsersPage)} 22 + ; {path= "/admin/invites"; template= (module AdminInvitesPage)} ] 20 23 21 24 let find_by_path path_to_find = 22 - List.find_opt (fun {path; _} -> path = path_to_find) routes 25 + List.find_opt 26 + (fun {path; _} -> path = path_to_find || path = path_to_find ^ "/") 27 + routes 23 28 24 29 let get_current_path () = [%mel.raw {| window.location.pathname |}] 25 30
+9 -19
frontend/src/components/AccountSidebar.mlx
··· 1 1 [@@@ocaml.warning "-26-27"] 2 2 3 - open React 4 - 5 3 type actor = AccountSwitcher.actor 6 4 7 5 let pages = ··· 9 7 ; ("Permissions", "/account/permissions") ] 10 8 11 9 let[@react.component] make ~current_user ~logged_in_users ~active_page () = 12 - let selected_class = "text-mana-100 font-medium" in 13 - let unselected_class = "text-mist-100 hover:text-mana-100" in 14 - <aside className="flex flex-col gap-y-2 min-w-48 max-w-3xs"> 15 - <AccountSwitcher 16 - current_user logged_in_users add_account_url="/account/login" 17 - /> 18 - <nav className="flex flex-col gap-y-1 mt-2"> 19 - ( List.map 20 - (fun (label, href) -> 21 - let className = 22 - if href = active_page then selected_class else unselected_class 23 - in 24 - <a href className key=href>(string label)</a> ) 25 - pages 26 - |> Array.of_list |> array ) 27 - </nav> 28 - </aside> 10 + <Sidebar 11 + pages 12 + active_page 13 + header=( 14 + <AccountSwitcher 15 + current_user logged_in_users add_account_url="/account/login" 16 + /> 17 + ) 18 + />
+3 -2
frontend/src/components/AccountSwitcher.mlx
··· 28 28 rounded-lg focus-visible:outline-none hover:bg-mist-20/40 \ 29 29 active:bg-mist-20/40" 30 30 else 31 - "group flex flex-row items-center gap-x-1 px-2 py-1.5 -mx-2 rounded-lg \ 32 - focus-visible:outline-none hover:bg-mist-20/40 active:bg-mist-20/40" 31 + "group min-w-48 flex flex-row items-center gap-x-1 px-2 py-1.5 -mx-2 \ 32 + rounded-lg focus-visible:outline-none hover:bg-mist-20/40 \ 33 + active:bg-mist-20/40" 33 34 in 34 35 let value_class = 35 36 if inline then "text-mana-100 font-serif inline-flex items-center gap-x-1"
+1 -2
frontend/src/components/Input.mlx
··· 5 5 6 6 let[@react.component] make ?id ~name ?(className = "") ?(type_ = "text") ?label 7 7 ?(sr_only = false) ?value ?placeholder ?autoComplete ?(required = false) 8 - ?(disabled = false) ?trailing ?(showIndicator = true) ?onChange () 9 - = 8 + ?(disabled = false) ?trailing ?(showIndicator = true) ?onChange () = 10 9 let id = Option.value id ~default:name in 11 10 let placeholder = if label <> None && sr_only then label else placeholder in 12 11 let input =
+38
frontend/src/components/ReactAria.mlx
··· 110 110 [@@mel.module "react-aria-components"] [@@react.component] 111 111 end 112 112 [@@platform js] 113 + 114 + module MenuTrigger = struct 115 + external make : 116 + children:React.element 117 + -> ?isOpen:bool 118 + -> ?defaultOpen:bool 119 + -> ?onOpenChange:(bool -> unit) 120 + -> React.element = "MenuTrigger" 121 + [@@mel.module "react-aria-components"] [@@react.component] 122 + end 123 + [@@platform js] 124 + 125 + module Menu = struct 126 + external make : 127 + children:React.element 128 + -> ?className:string 129 + -> ?onAction:(string -> unit) 130 + -> React.element = "Menu" 131 + [@@mel.module "react-aria-components"] [@@react.component] 132 + end 133 + [@@platform js] 134 + 135 + module MenuItem = struct 136 + external make : 137 + children:React.element 138 + -> ?id:string 139 + -> ?className:string 140 + -> ?onAction:(unit -> unit) 141 + -> React.element = "MenuItem" 142 + [@@mel.module "react-aria-components"] [@@react.component] 143 + end 144 + [@@platform js] 145 + 146 + module Separator = struct 147 + external make : ?className:string -> React.element = "Separator" 148 + [@@mel.module "react-aria-components"] [@@react.component] 149 + end 150 + [@@platform js]
+20
frontend/src/components/Sidebar.mlx
··· 1 + [@@@ocaml.warning "-26-27"] 2 + 3 + open React 4 + 5 + let[@react.component] make ~pages ~active_page ?header () = 6 + let selected_class = "text-mana-100 font-medium" in 7 + let unselected_class = "text-mist-100 hover:text-mana-100" in 8 + <aside className="flex flex-col gap-y-2 min-w-24 max-w-3xs"> 9 + (match header with Some h -> h | None -> null) 10 + <nav className="flex flex-col gap-y-1 mt-2"> 11 + ( List.map 12 + (fun (label, href) -> 13 + let className = 14 + if href = active_page then selected_class else unselected_class 15 + in 16 + <a href className key=href>(string label)</a> ) 17 + pages 18 + |> Array.of_list |> array ) 19 + </nav> 20 + </aside>
+11
frontend/src/icons/CheckmarkIcon.mlx
··· 1 + let[@react.component] make ?className ?(strokeWidth = "2") () = 2 + <svg 3 + ?className 4 + viewBox="0 0 24 24" 5 + fill="none" 6 + stroke="currentColor" 7 + strokeLinecap="round" 8 + strokeLinejoin="round" 9 + strokeWidth> 10 + <path d="M20 6 9 17l-5-5" /> 11 + </svg>
+14
frontend/src/icons/EllipsisIcon.mlx
··· 1 + let[@react.component] make ?className ?(strokeWidth = "2") () = 2 + <svg 3 + ?className 4 + viewBox="0 0 24 24" 5 + fill="none" 6 + stroke="currentColor" 7 + strokeLinecap="round" 8 + strokeLinejoin="round" 9 + strokeWidth> 10 + <path 11 + d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 \ 12 + 2M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2" 13 + /> 14 + </svg>
+18
frontend/src/icons/TrashIcon.mlx
··· 1 + let[@react.component] make ?className ?(strokeWidth = "2") () = 2 + <svg 3 + ?className 4 + viewBox="0 0 24 24" 5 + fill="none" 6 + stroke="currentColor" 7 + strokeLinecap="round" 8 + strokeLinejoin="round" 9 + strokeWidth> 10 + <path 11 + d="M10 11V17M14 11V17M19 6V20C19 20.5304 18.7893 21.0391 18.4142 \ 12 + 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 \ 13 + 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6M3 6H21M8 6V4C8 \ 14 + 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 \ 15 + 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 \ 16 + 3.46957 16 4V6" 17 + /> 18 + </svg>
+11
frontend/src/icons/XIcon.mlx
··· 1 + let[@react.component] make ?className ?(strokeWidth = "2") () = 2 + <svg 3 + ?className 4 + viewBox="0 0 24 24" 5 + fill="none" 6 + stroke="currentColor" 7 + strokeLinecap="round" 8 + strokeLinejoin="round" 9 + strokeWidth> 10 + <path d="M18 6 6 18M6 6l12 12" /> 11 + </svg>
+292
frontend/src/templates/AdminInvitesPage.mlx
··· 1 + [@@@ocaml.warning "-26-27"] 2 + 3 + open Melange_json.Primitives 4 + open React 5 + 6 + type invite = 7 + { code: string 8 + ; did: string 9 + ; remaining: int } 10 + [@@deriving json] 11 + 12 + type props = 13 + { invites: invite list 14 + ; csrf_token: string 15 + ; error: string option [@default None] 16 + ; success: string option [@default None] } 17 + [@@deriving json] 18 + 19 + let admin_pages = 20 + [ ("Users", "/admin/users") 21 + ; ("Invite codes", "/admin/invites") ] 22 + 23 + let[@react.component] make 24 + ~props: 25 + ({ invites 26 + ; csrf_token 27 + ; error 28 + ; success } : 29 + props ) () = 30 + (* create invite modal state *) 31 + let createModalOpen, setCreateModalOpen = useState (fun () -> false) in 32 + let newCode, setNewCode = useState (fun () -> "") in 33 + let newDid, setNewDid = useState (fun () -> "") in 34 + let newRemaining, setNewRemaining = useState (fun () -> "1") in 35 + (* edit modal state *) 36 + let editModalFor, setEditModalFor = useState (fun () -> (None : invite option)) in 37 + let editDid, setEditDid = useState (fun () -> "admin") in 38 + let editRemaining, setEditRemaining = useState (fun () -> "") in 39 + (* delete confirmation state *) 40 + let deleteConfirmFor, setDeleteConfirmFor = useState (fun () -> (None : invite option)) in 41 + <div className="w-full max-w-3xl h-auto px-4 sm:px-0 flex flex-col md:flex-row gap-12"> 42 + <Sidebar pages=admin_pages active_page="/admin/invites" /> 43 + <main className="flex-1 w-full max-w-2xl"> 44 + <h1 className="text-2xl font-serif text-mana-200 mb-1"> 45 + (string "invite codes") 46 + </h1> 47 + <p className="text-mist-100 mb-4"> 48 + (string "Manage invite codes for new account registration.") 49 + </p> 50 + <div className="flex flex-col sm:flex-row gap-4 mb-6"> 51 + <ClientOnly fallback=( 52 + <Button kind=`Primary className="w-full sm:max-w-64"> 53 + (string "create invite code") 54 + </Button> 55 + )> 56 + [%browser_only 57 + (fun () -> 58 + let module Aria = ReactAria in 59 + <Aria.DialogTrigger 60 + isOpen=createModalOpen 61 + onOpenChange=(fun o -> setCreateModalOpen (fun _ -> o))> 62 + <Aria.Pressable> 63 + <Button 64 + kind=`Primary 65 + className="w-full sm:max-w-64" 66 + onClick=(fun _ -> setCreateModalOpen (fun _ -> true))> 67 + (string "create invite code") 68 + </Button> 69 + </Aria.Pressable> 70 + <Aria.ModalOverlay 71 + className="fixed inset-0 z-50 bg-mist-80/80 flex items-center justify-center" 72 + isDismissable=true> 73 + <Aria.Modal 74 + className="bg-feather-100 border border-mist-60 rounded-xl px-6 pb-6 pt-5 w-full max-w-sm mx-4 shadow-xl"> 75 + <Aria.Dialog className="outline-none"> 76 + <Aria.Heading 77 + slot="title" 78 + className="text-lg font-serif text-mana-200 mb-2"> 79 + (string "create invite code") 80 + </Aria.Heading> 81 + <p className="text-mist-100 mb-4"> 82 + (string "Create a new invite code for user registration.") 83 + </p> 84 + <form className="flex flex-col gap-y-3"> 85 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 86 + <input type_="hidden" name="action" value="create_invite" /> 87 + <Input 88 + name="new_code" 89 + label="Code (optional)" 90 + placeholder="Leave empty to generate" 91 + showIndicator=false 92 + value=newCode 93 + onChange=(fun e -> 94 + setNewCode (fun _ -> (Event.Form.target e)##value)) 95 + /> 96 + <Input 97 + name="did" 98 + label="For (DID)" 99 + placeholder="admin" 100 + showIndicator=false 101 + value=newDid 102 + onChange=(fun e -> 103 + setNewDid (fun _ -> (Event.Form.target e)##value)) 104 + /> 105 + <Input 106 + name="remaining" 107 + type_="number" 108 + label="Available uses" 109 + showIndicator=false 110 + value=newRemaining 111 + onChange=(fun e -> 112 + setNewRemaining (fun _ -> (Event.Form.target e)##value)) 113 + /> 114 + <Button formMethod="post" type_="submit" className="mt-2"> 115 + (string "create") 116 + </Button> 117 + </form> 118 + </Aria.Dialog> 119 + </Aria.Modal> 120 + </Aria.ModalOverlay> 121 + </Aria.DialogTrigger> )] 122 + </ClientOnly> 123 + </div> 124 + ( match error with 125 + | Some err -> 126 + <div className="mb-4"> 127 + <span className="inline-flex items-center text-phoenix-100 text-sm"> 128 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 129 + </span> 130 + </div> 131 + | None -> null ) 132 + ( match success with 133 + | Some msg -> 134 + <div className="mb-4"> 135 + <span className="inline-flex items-center text-mana-100 text-sm"> 136 + <CheckmarkIcon className="w-4 h-4 mr-2" /> (string msg) 137 + </span> 138 + </div> 139 + | None -> null ) 140 + <div className="overflow-x-auto"> 141 + <table className="w-full min-w-xl text-sm"> 142 + <thead> 143 + <tr className="text-left text-mist-80"> 144 + <th className="pb-2 font-normal">(string "Code")</th> 145 + <th className="pb-2 font-normal">(string "For")</th> 146 + <th className="pb-2 font-normal">(string "Remaining")</th> 147 + <th className="pb-2 font-normal w-20"></th> 148 + </tr> 149 + </thead> 150 + <tbody> 151 + ( List.map 152 + (fun (invite : invite) -> 153 + <tr key=invite.code className="border-t border-mist-60/50"> 154 + <td className="py-3 pr-4"> 155 + <span className="font-mono text-mana-100"> 156 + (string invite.code) 157 + </span> 158 + </td> 159 + <td className="py-3 pr-4 text-mist-100"> 160 + (string invite.did) 161 + </td> 162 + <td className="py-3 pr-4 text-mist-100"> 163 + (string (string_of_int invite.remaining)) 164 + </td> 165 + <td className="py-3"> 166 + <div className="flex gap-2"> 167 + <button 168 + className="p-1 text-mist-80 hover:text-mana-100 cursor-pointer" 169 + onClick=(fun _ -> 170 + setEditDid (fun _ -> invite.did) ; 171 + setEditRemaining (fun _ -> string_of_int invite.remaining) ; 172 + setEditModalFor (fun _ -> Some invite))> 173 + <PencilLineIcon className="w-4 h-4" /> 174 + </button> 175 + <button 176 + className="p-1 text-mist-80 hover:text-phoenix-100 cursor-pointer" 177 + onClick=(fun _ -> setDeleteConfirmFor (fun _ -> Some invite))> 178 + <TrashIcon className="w-4 h-4" /> 179 + </button> 180 + </div> 181 + </td> 182 + </tr> ) 183 + invites 184 + |> Array.of_list |> array ) 185 + </tbody> 186 + </table> 187 + </div> 188 + (* edit modal *) 189 + <ClientOnly fallback=null> 190 + [%browser_only 191 + (fun () -> 192 + let module Aria = ReactAria in 193 + <Aria.DialogTrigger 194 + isOpen=(editModalFor <> None) 195 + onOpenChange=(fun o -> if not o then setEditModalFor (fun _ -> None))> 196 + <Aria.ModalOverlay 197 + className="fixed inset-0 z-50 bg-mist-80/80 flex items-center justify-center" 198 + isDismissable=true> 199 + <Aria.Modal 200 + className="bg-feather-100 border border-mist-60 rounded-xl px-6 pb-6 pt-5 w-full max-w-sm mx-4 shadow-xl"> 201 + <Aria.Dialog className="outline-none"> 202 + ( match editModalFor with 203 + | Some invite -> 204 + <form className="flex flex-col gap-y-3"> 205 + <Aria.Heading 206 + slot="title" 207 + className="text-lg font-serif text-mana-200 mb-2"> 208 + (string "edit invite code") 209 + </Aria.Heading> 210 + <p className="text-mist-80 text-sm"> 211 + (string ("Code: " ^ invite.code)) 212 + </p> 213 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 214 + <input type_="hidden" name="action" value="update_invite" /> 215 + <input type_="hidden" name="code" value=invite.code /> 216 + <Input 217 + name="did" 218 + label="For (DID)" 219 + showIndicator=false 220 + value=editDid 221 + onChange=(fun e -> 222 + setEditDid (fun _ -> (Event.Form.target e)##value)) 223 + /> 224 + <Input 225 + name="remaining" 226 + type_="number" 227 + label="Remaining uses" 228 + showIndicator=false 229 + value=editRemaining 230 + onChange=(fun e -> 231 + setEditRemaining (fun _ -> (Event.Form.target e)##value)) 232 + /> 233 + <div className="flex gap-3 mt-2"> 234 + <Button formMethod="post" type_="submit">(string "save")</Button> 235 + <Button 236 + kind=`Tertiary 237 + onClick=(fun _ -> setEditModalFor (fun _ -> None))> 238 + (string "cancel") 239 + </Button> 240 + </div> 241 + </form> 242 + | None -> null ) 243 + </Aria.Dialog> 244 + </Aria.Modal> 245 + </Aria.ModalOverlay> 246 + </Aria.DialogTrigger> )] 247 + </ClientOnly> 248 + (* delete confirmation modal *) 249 + <ClientOnly fallback=null> 250 + [%browser_only 251 + (fun () -> 252 + let module Aria = ReactAria in 253 + <Aria.DialogTrigger 254 + isOpen=(deleteConfirmFor <> None) 255 + onOpenChange=(fun o -> if not o then setDeleteConfirmFor (fun _ -> None))> 256 + <Aria.ModalOverlay 257 + className="fixed inset-0 z-50 bg-mist-80/80 flex items-center justify-center" 258 + isDismissable=true> 259 + <Aria.Modal 260 + className="bg-feather-100 border border-mist-60 rounded-xl px-6 pb-6 pt-5 w-full max-w-sm mx-4 shadow-xl"> 261 + <Aria.Dialog className="outline-none"> 262 + ( match deleteConfirmFor with 263 + | Some invite -> 264 + <form className="flex flex-col gap-y-3"> 265 + <Aria.Heading 266 + slot="title" 267 + className="text-lg font-serif text-mana-200 mb-2"> 268 + (string "delete invite code") 269 + </Aria.Heading> 270 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 271 + <input type_="hidden" name="action" value="delete_invite" /> 272 + <input type_="hidden" name="code" value=invite.code /> 273 + <p className="text-mist-100"> 274 + (string ("Are you sure you want to delete invite code " ^ invite.code ^ "?")) 275 + </p> 276 + <div className="flex gap-3 mt-2"> 277 + <Button kind=`Danger formMethod="post" type_="submit">(string "delete")</Button> 278 + <Button 279 + kind=`Tertiary 280 + onClick=(fun _ -> setDeleteConfirmFor (fun _ -> None))> 281 + (string "cancel") 282 + </Button> 283 + </div> 284 + </form> 285 + | None -> null ) 286 + </Aria.Dialog> 287 + </Aria.Modal> 288 + </Aria.ModalOverlay> 289 + </Aria.DialogTrigger> )] 290 + </ClientOnly> 291 + </main> 292 + </div>
+33
frontend/src/templates/AdminLoginPage.mlx
··· 1 + open Melange_json.Primitives 2 + open React 3 + 4 + type props = {csrf_token: string; error: string option [@default None]} 5 + [@@deriving json] 6 + 7 + let[@react.component] make ~props:({csrf_token; error} : props) () = 8 + <main className="w-full h-auto max-w-xs px-4 sm:px-0"> 9 + <h1 className="text-2xl font-serif text-mana-200 mb-2">(string "admin")</h1> 10 + <span className="w-full text-balance text-mist-100"> 11 + (string "Enter your admin password to continue.") 12 + </span> 13 + <form className="w-full flex flex-col mt-4 mb-2 gap-y-2"> 14 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 15 + <Input 16 + sr_only=true 17 + name="password" 18 + type_="password" 19 + label="password" 20 + autoComplete="current-password" 21 + /> 22 + ( match error with 23 + | Some error -> 24 + <span className="inline-flex items-center text-phoenix-100 text-sm"> 25 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string error) 26 + </span> 27 + | None -> 28 + null ) 29 + <Button type_="submit" formMethod="post" className="mt-2"> 30 + (string "sign in") 31 + </Button> 32 + </form> 33 + </main>
+527
frontend/src/templates/AdminUsersPage.mlx
··· 1 + [@@@ocaml.warning "-26-27"] 2 + 3 + open Melange_json.Primitives 4 + open React 5 + 6 + type actor = 7 + { did: string 8 + ; handle: string 9 + ; email: string 10 + ; email_confirmed: bool 11 + ; created_at: string 12 + ; deactivated: bool } 13 + [@@deriving json] 14 + 15 + type props = 16 + { actors: actor list 17 + ; csrf_token: string 18 + ; filter: string 19 + ; cursor: string 20 + ; next_cursor: string option [@default None] 21 + ; hostname: string 22 + ; error: string option [@default None] 23 + ; success: string option [@default None] } 24 + [@@deriving json] 25 + 26 + let admin_pages = 27 + [ ("Users", "/admin/users") 28 + ; ("Invite codes", "/admin/invites") ] 29 + 30 + let[@react.component] make 31 + ~props: 32 + ({ actors 33 + ; csrf_token 34 + ; filter 35 + ; cursor 36 + ; next_cursor 37 + ; hostname 38 + ; error 39 + ; success } : 40 + props ) () = 41 + (* create account modal state *) 42 + let createModalOpen, setCreateModalOpen = useState (fun () -> false) in 43 + let newEmail, setNewEmail = useState (fun () -> "") in 44 + let newHandle, setNewHandle = useState (fun () -> "") in 45 + let newPassword, setNewPassword = useState (fun () -> "") in 46 + (* action menu state *) 47 + let menuOpenFor, setMenuOpenFor = useState (fun () -> (None : string option)) in 48 + (* edit modal state, tracks relevant actor and action *) 49 + let editModal, setEditModal = useState (fun () -> (None : (actor * string) option)) in 50 + let editValue, setEditValue = useState (fun () -> "") in 51 + (* delete confirmation state *) 52 + let deleteConfirmFor, setDeleteConfirmFor = useState (fun () -> (None : actor option)) in 53 + <div className="w-full max-w-3xl h-auto mx-8 px-4 sm:px-0 flex flex-col md:flex-row gap-12"> 54 + <Sidebar pages=admin_pages active_page="/admin/users" /> 55 + <main className="flex-1 w-full max-w-2xl"> 56 + <h1 className="text-2xl font-serif text-mana-200 mb-1"> 57 + (string "users") 58 + </h1> 59 + <p className="text-mist-100 mb-4"> 60 + (string "Enter a DID, handle, or email to filter the list by.") 61 + </p> 62 + <div className="flex flex-col sm:flex-row gap-4 mb-6"> 63 + <form className="flex-1"> 64 + <Input 65 + name="filter" 66 + placeholder="Filter users" 67 + value=filter 68 + showIndicator=false 69 + /> 70 + </form> 71 + <ClientOnly fallback=( 72 + <Button kind=`Primary className="w-full sm:max-w-64"> 73 + (string "create account") 74 + </Button> 75 + )> 76 + [%browser_only 77 + (fun () -> 78 + let module Aria = ReactAria in 79 + <Aria.DialogTrigger 80 + isOpen=createModalOpen 81 + onOpenChange=(fun o -> setCreateModalOpen (fun _ -> o))> 82 + <Aria.Pressable> 83 + <Button 84 + kind=`Primary 85 + className="w-full sm:max-w-64" 86 + onClick=(fun _ -> setCreateModalOpen (fun _ -> true))> 87 + (string "create account") 88 + </Button> 89 + </Aria.Pressable> 90 + <Aria.ModalOverlay 91 + className="fixed inset-0 z-50 bg-mist-80/80 flex items-center justify-center" 92 + isDismissable=true> 93 + <Aria.Modal 94 + className="bg-feather-100 border border-mist-60 rounded-xl px-6 pb-6 pt-5 w-full max-w-sm mx-4 shadow-xl"> 95 + <Aria.Dialog className="outline-none"> 96 + <Aria.Heading 97 + slot="title" 98 + className="text-lg font-serif text-mana-200 mb-2"> 99 + (string "create account") 100 + </Aria.Heading> 101 + <p className="text-mist-100 mb-4"> 102 + (string "Quickly create a new account on this PDS.") 103 + </p> 104 + <form className="flex flex-col gap-y-3"> 105 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 106 + <input type_="hidden" name="action" value="create_account" /> 107 + <Input 108 + name="email" 109 + type_="email" 110 + label="Email" 111 + required=true 112 + showIndicator=false 113 + value=newEmail 114 + onChange=(fun e -> 115 + setNewEmail (fun _ -> (Event.Form.target e)##value)) 116 + /> 117 + <Input 118 + name="handle" 119 + label="Handle" 120 + required=true 121 + showIndicator=false 122 + value=newHandle 123 + onChange=(fun e -> 124 + setNewHandle (fun _ -> (Event.Form.target e)##value)) 125 + trailing=( 126 + <span className="font-serif text-mist-80 text-sm whitespace-nowrap"> 127 + (string ("." ^ hostname)) 128 + </span> 129 + ) 130 + /> 131 + <Input 132 + name="password" 133 + type_="password" 134 + label="Password" 135 + required=true 136 + showIndicator=false 137 + value=newPassword 138 + onChange=(fun e -> 139 + setNewPassword (fun _ -> (Event.Form.target e)##value)) 140 + /> 141 + <Button formMethod="post" type_="submit" className="mt-2"> 142 + (string "create") 143 + </Button> 144 + </form> 145 + </Aria.Dialog> 146 + </Aria.Modal> 147 + </Aria.ModalOverlay> 148 + </Aria.DialogTrigger> )] 149 + </ClientOnly> 150 + </div> 151 + ( match error with 152 + | Some err -> 153 + <div className="mb-4"> 154 + <span className="inline-flex items-center text-phoenix-100 text-sm"> 155 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 156 + </span> 157 + </div> 158 + | None -> null ) 159 + ( match success with 160 + | Some msg -> 161 + <div className="mb-4"> 162 + <span className="inline-flex items-center text-mana-100 text-sm"> 163 + <CheckmarkIcon className="w-4 h-4 mr-2" /> (string msg) 164 + </span> 165 + </div> 166 + | None -> null ) 167 + <div className="overflow-x-auto"> 168 + <table className="w-full min-w-xl text-sm"> 169 + <thead> 170 + <tr className="text-left text-mist-80"> 171 + <th className="pb-2 font-normal">(string "Handle")</th> 172 + <th className="pb-2 font-normal">(string "Email")</th> 173 + <th className="pb-2 font-normal">(string "Created at")</th> 174 + <th className="pb-2 font-normal w-8"></th> 175 + </tr> 176 + </thead> 177 + <tbody> 178 + ( List.map 179 + (fun (actor : actor) -> 180 + let handleClasses = "font-medium truncate " ^ 181 + (if actor.deactivated then "text-phoenix-100" else "text-mana-100") in 182 + <tr key=actor.did className="border-t border-mist-60/50"> 183 + <td className="py-3 pr-4"> 184 + <div className="flex items-center gap-3"> 185 + <div className="max-w-54"> 186 + <p className=handleClasses ?title=(if actor.deactivated then Some "Deactivated" else None)> 187 + (string actor.handle) 188 + </p> 189 + <p className="text-xs text-mist-80 truncate"> 190 + (string actor.did) 191 + </p> 192 + </div> 193 + </div> 194 + </td> 195 + <td className="py-3 pr-4"> 196 + <div className="flex items-center gap-1"> 197 + <span className="text-mist-100">(string actor.email)</span> 198 + ( if actor.email_confirmed then 199 + <CheckmarkIcon className="w-4 h-4 text-mana-100" /> 200 + else 201 + <XIcon className="w-4 h-4 text-phoenix-100" /> ) 202 + </div> 203 + </td> 204 + <td className="py-3 pr-4 text-mist-100"> 205 + (string actor.created_at) 206 + </td> 207 + <td className="py-3"> 208 + <ClientOnly fallback=( 209 + <button className="p-1 text-mist-80 hover:text-mana-100"> 210 + <EllipsisIcon className="w-5 h-5" /> 211 + </button> 212 + )> 213 + [%browser_only 214 + (fun () -> 215 + let module Aria = ReactAria in 216 + let isOpen = menuOpenFor = Some actor.did in 217 + <Aria.MenuTrigger 218 + isOpen=isOpen 219 + onOpenChange=(fun o -> 220 + setMenuOpenFor (fun _ -> if o then Some actor.did else None))> 221 + <Aria.Pressable> 222 + <button 223 + className="p-1 text-mist-80 hover:text-mana-100 cursor-pointer" 224 + onClick=(fun _ -> setMenuOpenFor (fun _ -> Some actor.did))> 225 + <EllipsisIcon className="w-5 h-5" /> 226 + </button> 227 + </Aria.Pressable> 228 + <Aria.Popover 229 + className="bg-feather-100 border border-mist-60/50 rounded-lg shadow-xl py-1 min-w-48"> 230 + <Aria.Menu 231 + className="outline-none" 232 + onAction=(fun action -> 233 + setMenuOpenFor (fun _ -> None) ; 234 + match action with 235 + | "change_handle" -> 236 + setEditValue (fun _ -> actor.handle) ; 237 + setEditModal (fun _ -> Some (actor, "handle")) 238 + | "change_email" -> 239 + setEditValue (fun _ -> actor.email) ; 240 + setEditModal (fun _ -> Some (actor, "email")) 241 + | "change_password" -> 242 + setEditValue (fun _ -> "") ; 243 + setEditModal (fun _ -> Some (actor, "password")) 244 + | "send_password_reset" -> 245 + setEditModal (fun _ -> Some (actor, "send_reset")) 246 + | "deactivate" -> 247 + setEditModal (fun _ -> Some (actor, "deactivate")) 248 + | "reactivate" -> 249 + setEditModal (fun _ -> Some (actor, "reactivate")) 250 + | "delete" -> 251 + setDeleteConfirmFor (fun _ -> Some actor) 252 + | _ -> () )> 253 + <Aria.MenuItem 254 + id="change_handle" 255 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-mist-100"> 256 + (string "Change handle") 257 + </Aria.MenuItem> 258 + <Aria.MenuItem 259 + id="change_email" 260 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-mist-100"> 261 + (string "Change email") 262 + </Aria.MenuItem> 263 + <Aria.MenuItem 264 + id="change_password" 265 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-mist-100"> 266 + (string "Change password") 267 + </Aria.MenuItem> 268 + <Aria.MenuItem 269 + id="send_password_reset" 270 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-mist-100"> 271 + (string "Send password reset code") 272 + </Aria.MenuItem> 273 + <Aria.Separator className="my-1 border-t border-mist-60/50" /> 274 + ( if actor.deactivated then 275 + <Aria.MenuItem 276 + id="reactivate" 277 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-mana-100"> 278 + (string "Reactivate account") 279 + </Aria.MenuItem> 280 + else 281 + <Aria.MenuItem 282 + id="deactivate" 283 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-phoenix-100"> 284 + (string "Deactivate account") 285 + </Aria.MenuItem> ) 286 + <Aria.MenuItem 287 + id="delete" 288 + className="px-3 py-2 outline-none cursor-pointer hover:bg-mist-60/30 text-phoenix-100"> 289 + (string "Delete account") 290 + </Aria.MenuItem> 291 + </Aria.Menu> 292 + </Aria.Popover> 293 + </Aria.MenuTrigger> )] 294 + </ClientOnly> 295 + </td> 296 + </tr> ) 297 + actors 298 + |> Array.of_list |> array ) 299 + </tbody> 300 + </table> 301 + </div> 302 + ( match next_cursor with 303 + | Some cursor -> 304 + <div className="mt-4"> 305 + <a href=("?filter=" ^ filter ^ "&cursor=" ^ cursor)> 306 + <Button kind=`Secondary>(string "Load more")</Button> 307 + </a> 308 + </div> 309 + | None -> null ) 310 + (* edit modal *) 311 + <ClientOnly fallback=null> 312 + [%browser_only 313 + (fun () -> 314 + let module Aria = ReactAria in 315 + <Aria.DialogTrigger 316 + isOpen=(editModal <> None) 317 + onOpenChange=(fun o -> if not o then setEditModal (fun _ -> None))> 318 + <Aria.ModalOverlay 319 + className="fixed inset-0 z-50 bg-mist-80/80 flex items-center justify-center" 320 + isDismissable=true> 321 + <Aria.Modal 322 + className="bg-feather-100 border border-mist-60 rounded-xl px-6 pb-6 pt-5 w-full max-w-sm mx-4 shadow-xl"> 323 + <Aria.Dialog className="outline-none"> 324 + ( match editModal with 325 + | Some (actor, "handle") -> 326 + <form className="flex flex-col gap-y-3"> 327 + <Aria.Heading 328 + slot="title" 329 + className="text-lg font-serif text-mana-200 mb-2"> 330 + (string "change handle") 331 + </Aria.Heading> 332 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 333 + <input type_="hidden" name="action" value="change_handle" /> 334 + <input type_="hidden" name="did" value=actor.did /> 335 + <Input 336 + name="handle" 337 + label="New handle" 338 + required=true 339 + showIndicator=false 340 + value=editValue 341 + onChange=(fun e -> 342 + setEditValue (fun _ -> (Event.Form.target e)##value)) 343 + /> 344 + <div className="flex gap-3 mt-2"> 345 + <Button formMethod="post" type_="submit">(string "save")</Button> 346 + <Button 347 + kind=`Tertiary 348 + onClick=(fun _ -> setEditModal (fun _ -> None))> 349 + (string "cancel") 350 + </Button> 351 + </div> 352 + </form> 353 + | Some (actor, "email") -> 354 + <form className="flex flex-col gap-y-3"> 355 + <Aria.Heading 356 + slot="title" 357 + className="text-lg font-serif text-mana-200 mb-2"> 358 + (string "change email") 359 + </Aria.Heading> 360 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 361 + <input type_="hidden" name="action" value="change_email" /> 362 + <input type_="hidden" name="did" value=actor.did /> 363 + <Input 364 + name="email" 365 + type_="email" 366 + label="New email" 367 + required=true 368 + showIndicator=false 369 + value=editValue 370 + onChange=(fun e -> 371 + setEditValue (fun _ -> (Event.Form.target e)##value)) 372 + /> 373 + <div className="flex gap-3 mt-2"> 374 + <Button formMethod="post" type_="submit">(string "save")</Button> 375 + <Button 376 + kind=`Tertiary 377 + onClick=(fun _ -> setEditModal (fun _ -> None))> 378 + (string "cancel") 379 + </Button> 380 + </div> 381 + </form> 382 + | Some (actor, "password") -> 383 + <form className="flex flex-col gap-y-3"> 384 + <Aria.Heading 385 + slot="title" 386 + className="text-lg font-serif text-mana-200 mb-2"> 387 + (string "change password") 388 + </Aria.Heading> 389 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 390 + <input type_="hidden" name="action" value="change_password" /> 391 + <input type_="hidden" name="did" value=actor.did /> 392 + <Input 393 + name="password" 394 + type_="password" 395 + label="New password" 396 + required=true 397 + showIndicator=false 398 + value=editValue 399 + onChange=(fun e -> 400 + setEditValue (fun _ -> (Event.Form.target e)##value)) 401 + /> 402 + <div className="flex gap-3 mt-2"> 403 + <Button formMethod="post" type_="submit">(string "save")</Button> 404 + <Button 405 + kind=`Tertiary 406 + onClick=(fun _ -> setEditModal (fun _ -> None))> 407 + (string "cancel") 408 + </Button> 409 + </div> 410 + </form> 411 + | Some (actor, "send_reset") -> 412 + <form className="flex flex-col gap-y-3"> 413 + <Aria.Heading 414 + slot="title" 415 + className="text-lg font-serif text-mana-200 mb-2"> 416 + (string "send password reset") 417 + </Aria.Heading> 418 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 419 + <input type_="hidden" name="action" value="send_password_reset" /> 420 + <input type_="hidden" name="did" value=actor.did /> 421 + <p className="text-mist-100"> 422 + (string ("Send a password reset email to " ^ actor.email ^ "?")) 423 + </p> 424 + <div className="flex gap-3 mt-2"> 425 + <Button formMethod="post" type_="submit">(string "send")</Button> 426 + <Button 427 + kind=`Tertiary 428 + onClick=(fun _ -> setEditModal (fun _ -> None))> 429 + (string "cancel") 430 + </Button> 431 + </div> 432 + </form> 433 + | Some (actor, "deactivate") -> 434 + <form className="flex flex-col gap-y-3"> 435 + <Aria.Heading 436 + slot="title" 437 + className="text-lg font-serif text-mana-200 mb-2"> 438 + (string "deactivate account") 439 + </Aria.Heading> 440 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 441 + <input type_="hidden" name="action" value="deactivate" /> 442 + <input type_="hidden" name="did" value=actor.did /> 443 + <p className="text-mist-100"> 444 + (string ("Deactivate " ^ actor.handle ^ "? The account can be reactivated later.")) 445 + </p> 446 + <div className="flex gap-3 mt-2"> 447 + <Button kind=`Danger formMethod="post" type_="submit">(string "deactivate")</Button> 448 + <Button 449 + kind=`Tertiary 450 + onClick=(fun _ -> setEditModal (fun _ -> None))> 451 + (string "cancel") 452 + </Button> 453 + </div> 454 + </form> 455 + | Some (actor, "reactivate") -> 456 + <form className="flex flex-col gap-y-3"> 457 + <Aria.Heading 458 + slot="title" 459 + className="text-lg font-serif text-mana-200 mb-2"> 460 + (string "reactivate account") 461 + </Aria.Heading> 462 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 463 + <input type_="hidden" name="action" value="reactivate" /> 464 + <input type_="hidden" name="did" value=actor.did /> 465 + <p className="text-mist-100"> 466 + (string ("Reactivate " ^ actor.handle ^ "?")) 467 + </p> 468 + <div className="flex gap-3 mt-2"> 469 + <Button formMethod="post" type_="submit">(string "reactivate")</Button> 470 + <Button 471 + kind=`Tertiary 472 + onClick=(fun _ -> setEditModal (fun _ -> None))> 473 + (string "cancel") 474 + </Button> 475 + </div> 476 + </form> 477 + | _ -> null ) 478 + </Aria.Dialog> 479 + </Aria.Modal> 480 + </Aria.ModalOverlay> 481 + </Aria.DialogTrigger> )] 482 + </ClientOnly> 483 + (* delete confirmation modal *) 484 + <ClientOnly fallback=null> 485 + [%browser_only 486 + (fun () -> 487 + let module Aria = ReactAria in 488 + <Aria.DialogTrigger 489 + isOpen=(deleteConfirmFor <> None) 490 + onOpenChange=(fun o -> if not o then setDeleteConfirmFor (fun _ -> None))> 491 + <Aria.ModalOverlay 492 + className="fixed inset-0 z-50 bg-mist-80/80 flex items-center justify-center" 493 + isDismissable=true> 494 + <Aria.Modal 495 + className="bg-feather-100 border border-mist-60 rounded-xl px-6 pb-6 pt-5 w-full max-w-sm mx-4 shadow-xl"> 496 + <Aria.Dialog className="outline-none"> 497 + ( match deleteConfirmFor with 498 + | Some actor -> 499 + <form className="flex flex-col gap-y-3"> 500 + <Aria.Heading 501 + slot="title" 502 + className="text-lg font-serif text-mana-200 mb-2"> 503 + (string "delete account") 504 + </Aria.Heading> 505 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 506 + <input type_="hidden" name="action" value="delete" /> 507 + <input type_="hidden" name="did" value=actor.did /> 508 + <p className="text-mist-100"> 509 + (string ("Are you sure you want to delete " ^ actor.handle ^ "? This action cannot be undone.")) 510 + </p> 511 + <div className="flex gap-3 mt-2"> 512 + <Button kind=`Danger formMethod="post" type_="submit">(string "delete")</Button> 513 + <Button 514 + kind=`Tertiary 515 + onClick=(fun _ -> setDeleteConfirmFor (fun _ -> None))> 516 + (string "cancel") 517 + </Button> 518 + </div> 519 + </form> 520 + | None -> null ) 521 + </Aria.Dialog> 522 + </Aria.Modal> 523 + </Aria.ModalOverlay> 524 + </Aria.DialogTrigger> )] 525 + </ClientOnly> 526 + </main> 527 + </div>
+7
pegasus/lib/api/admin_/index.ml
··· 1 + let handler = 2 + Xrpc.handler (fun ctx -> 3 + match%lwt Session.is_admin_authenticated ctx.req with 4 + | true -> 5 + Dream.redirect ctx.req "/admin/users" 6 + | false -> 7 + Dream.redirect ctx.req "/admin/login" )
+82
pegasus/lib/api/admin_/invites.ml
··· 1 + let invite_to_view (invite : Data_store.Types.invite_code) : 2 + Frontend.AdminInvitesPage.invite = 3 + {code= invite.code; did= invite.did; remaining= invite.remaining} 4 + 5 + let get_handler = 6 + Xrpc.handler (fun ctx -> 7 + match%lwt Session.is_admin_authenticated ctx.req with 8 + | false -> 9 + Dream.redirect ctx.req "/admin/login" 10 + | true -> 11 + let%lwt invites = Data_store.list_invites ~limit:100 ctx.db in 12 + let invites = List.map invite_to_view invites in 13 + let csrf_token = Dream.csrf_token ctx.req in 14 + Util.render_html ~title:"Admin / Invite Codes" 15 + (module Frontend.AdminInvitesPage) 16 + ~props:{invites; csrf_token; error= None; success= None} ) 17 + 18 + let post_handler = 19 + Xrpc.handler (fun ctx -> 20 + match%lwt Session.is_admin_authenticated ctx.req with 21 + | false -> 22 + Dream.redirect ctx.req "/admin/login" 23 + | true -> ( 24 + let csrf_token = Dream.csrf_token ctx.req in 25 + let render_page ?error ?success () = 26 + let%lwt invites = Data_store.list_invites ~limit:100 ctx.db in 27 + let invites = List.map invite_to_view invites in 28 + Util.render_html ~title:"Admin / Invite Codes" 29 + (module Frontend.AdminInvitesPage) 30 + ~props:{invites; csrf_token; error; success} 31 + in 32 + match%lwt Dream.form ctx.req with 33 + | `Ok fields -> ( 34 + let action = List.assoc_opt "action" fields in 35 + let code = 36 + List.assoc_opt "code" fields |> Option.value ~default:"" 37 + in 38 + match action with 39 + | Some "create_invite" -> ( 40 + let did = 41 + List.assoc_opt "did" fields |> Option.value ~default:"admin" 42 + in 43 + let remaining = 44 + List.assoc_opt "remaining" fields 45 + |> Option.value ~default:"1" |> int_of_string_opt 46 + |> Option.value ~default:1 47 + in 48 + let new_code = 49 + List.assoc_opt "new_code" fields |> Option.value ~default:"" 50 + in 51 + let code = 52 + if String.length new_code > 0 then new_code 53 + else Server.CreateInviteCode.generate_code did 54 + in 55 + match%lwt Data_store.get_invite ~code ctx.db with 56 + | Some _ -> 57 + render_page ~error:"Invite code already exists." () 58 + | None -> 59 + let%lwt () = 60 + Data_store.create_invite ~code ~did ~remaining ctx.db 61 + in 62 + render_page ~success:("Invite code created: " ^ code) () ) 63 + | Some "update_invite" -> 64 + let did = 65 + List.assoc_opt "did" fields |> Option.value ~default:"admin" 66 + in 67 + let remaining = 68 + List.assoc_opt "remaining" fields 69 + |> Option.value ~default:"1" |> int_of_string_opt 70 + |> Option.value ~default:1 71 + in 72 + let%lwt () = 73 + Data_store.update_invite ~code ~did ~remaining ctx.db 74 + in 75 + render_page ~success:"Invite code updated." () 76 + | Some "delete_invite" -> 77 + let%lwt () = Data_store.delete_invite ~code ctx.db in 78 + render_page ~success:"Invite code deleted." () 79 + | _ -> 80 + render_page ~error:"Invalid action." () ) 81 + | _ -> 82 + render_page ~error:"Invalid form submission." () ) )
+30
pegasus/lib/api/admin_/login.ml
··· 1 + let get_handler = 2 + Xrpc.handler (fun ctx -> 3 + match%lwt Session.is_admin_authenticated ctx.req with 4 + | true -> 5 + Dream.redirect ctx.req "/admin/users" 6 + | false -> 7 + let csrf_token = Dream.csrf_token ctx.req in 8 + Util.render_html ~title:"Admin Login" 9 + (module Frontend.AdminLoginPage) 10 + ~props:{csrf_token; error= None} ) 11 + 12 + let post_handler = 13 + Xrpc.handler (fun ctx -> 14 + let csrf_token = Dream.csrf_token ctx.req in 15 + match%lwt Dream.form ctx.req with 16 + | `Ok fields -> 17 + let password = 18 + List.assoc_opt "password" fields |> Option.value ~default:"" 19 + in 20 + if password = Env.admin_password then 21 + let%lwt () = Session.set_admin_authenticated ctx.req true in 22 + Dream.redirect ctx.req "/admin/users" 23 + else 24 + Util.render_html ~status:`Unauthorized ~title:"Admin Login" 25 + (module Frontend.AdminLoginPage) 26 + ~props:{csrf_token; error= Some "Invalid password."} 27 + | _ -> 28 + Util.render_html ~status:`Unauthorized ~title:"Admin Login" 29 + (module Frontend.AdminLoginPage) 30 + ~props:{csrf_token; error= Some "Invalid form submission."} )
+255
pegasus/lib/api/admin_/users.ml
··· 1 + let format_date timestamp_ms = 2 + let ts = float_of_int timestamp_ms /. 1000.0 in 3 + let dt = Timedesc.of_timestamp_float_s_exn ts in 4 + Format.asprintf "%a" 5 + (Timedesc.pp 6 + ~format:"{year}-{mon:0X}-{day:0X}, {12hour:0X}:{min:0X} {am/pm:XX}" () ) 7 + dt 8 + 9 + let actor_to_view (actor : Data_store.Types.actor) : 10 + Frontend.AdminUsersPage.actor = 11 + { did= actor.did 12 + ; handle= actor.handle 13 + ; email= actor.email 14 + ; email_confirmed= actor.email_confirmed_at <> None 15 + ; created_at= format_date actor.created_at 16 + ; deactivated= actor.deactivated_at <> None } 17 + 18 + let get_handler = 19 + Xrpc.handler (fun ctx -> 20 + match%lwt Session.is_admin_authenticated ctx.req with 21 + | false -> 22 + Dream.redirect ctx.req "/admin/login" 23 + | true -> 24 + let filter = 25 + Dream.query ctx.req "filter" |> Option.value ~default:"" 26 + in 27 + let cursor = 28 + Dream.query ctx.req "cursor" |> Option.value ~default:"" 29 + in 30 + let limit = 20 in 31 + let%lwt actors = 32 + Data_store.list_actors_filtered ~filter ~cursor ~limit:(limit + 1) 33 + ctx.db 34 + in 35 + let has_more = List.length actors > limit in 36 + let actors = 37 + if has_more then List.filteri (fun i _ -> i < limit) actors 38 + else actors 39 + in 40 + let next_cursor = 41 + if has_more then 42 + match List.rev actors with 43 + | last :: _ -> 44 + Some last.did 45 + | [] -> 46 + None 47 + else None 48 + in 49 + let actors = List.map actor_to_view actors in 50 + let csrf_token = Dream.csrf_token ctx.req in 51 + let hostname = Env.hostname in 52 + Util.render_html ~title:"Admin / Users" 53 + (module Frontend.AdminUsersPage) 54 + ~props: 55 + { actors 56 + ; csrf_token 57 + ; filter 58 + ; cursor 59 + ; next_cursor 60 + ; hostname 61 + ; error= None 62 + ; success= None } ) 63 + 64 + let post_handler = 65 + Xrpc.handler (fun ctx -> 66 + match%lwt Session.is_admin_authenticated ctx.req with 67 + | false -> 68 + Dream.redirect ctx.req "/admin/login" 69 + | true -> ( 70 + let csrf_token = Dream.csrf_token ctx.req in 71 + let hostname = Env.hostname in 72 + let render_page ?error ?success () = 73 + let filter = 74 + Dream.query ctx.req "filter" |> Option.value ~default:"" 75 + in 76 + let cursor = 77 + Dream.query ctx.req "cursor" |> Option.value ~default:"" 78 + in 79 + let limit = 20 in 80 + let%lwt actors = 81 + Data_store.list_actors_filtered ~filter ~cursor ~limit:(limit + 1) 82 + ctx.db 83 + in 84 + let has_more = List.length actors > limit in 85 + let actors = 86 + if has_more then List.filteri (fun i _ -> i < limit) actors 87 + else actors 88 + in 89 + let actors = List.map actor_to_view actors in 90 + let next_cursor = 91 + if has_more then 92 + match List.rev actors with 93 + | last :: _ -> 94 + Some last.did 95 + | [] -> 96 + None 97 + else None 98 + in 99 + Util.render_html ~title:"Admin / Users" 100 + (module Frontend.AdminUsersPage) 101 + ~props: 102 + { actors 103 + ; csrf_token 104 + ; filter 105 + ; cursor 106 + ; next_cursor 107 + ; hostname 108 + ; error 109 + ; success } 110 + in 111 + match%lwt Dream.form ctx.req with 112 + | `Ok fields -> ( 113 + let action = List.assoc_opt "action" fields in 114 + let did = 115 + List.assoc_opt "did" fields |> Option.value ~default:"" 116 + in 117 + match action with 118 + | Some "create_account" -> ( 119 + let email = 120 + List.assoc_opt "email" fields 121 + |> Option.value ~default:"" |> String.lowercase_ascii 122 + in 123 + let handle = 124 + List.assoc_opt "handle" fields |> Option.value ~default:"" 125 + in 126 + let password = 127 + List.assoc_opt "password" fields |> Option.value ~default:"" 128 + in 129 + if 130 + String.length email = 0 131 + || String.length handle = 0 132 + || String.length password = 0 133 + then render_page ~error:"All fields are required." () 134 + else 135 + let handle = handle ^ "." ^ Env.hostname in 136 + match Util.validate_handle handle with 137 + | Error e -> 138 + render_page ~error:e () 139 + | Ok _ -> ( 140 + match%lwt 141 + Data_store.get_actor_by_identifier email ctx.db 142 + with 143 + | Some _ -> 144 + render_page ~error:"Email already in use." () 145 + | None -> ( 146 + match%lwt 147 + Data_store.get_actor_by_identifier handle ctx.db 148 + with 149 + | Some _ -> 150 + render_page ~error:"Handle already in use." () 151 + | None -> ( 152 + let signing_key, signing_pubkey = 153 + Kleidos.K256.generate_keypair () 154 + in 155 + let sk_did = 156 + Kleidos.K256.pubkey_to_did_key signing_pubkey 157 + in 158 + match%lwt 159 + Plc.submit_genesis Env.rotation_key sk_did handle 160 + with 161 + | Error e -> 162 + render_page 163 + ~error:("Failed to create DID: " ^ e) 164 + () 165 + | Ok new_did -> 166 + let sk_priv_mk = 167 + Kleidos.K256.privkey_to_multikey signing_key 168 + in 169 + let%lwt () = 170 + Data_store.create_actor ~did:new_did ~handle 171 + ~email ~password ~signing_key:sk_priv_mk 172 + ctx.db 173 + in 174 + let () = 175 + Util.mkfile_p 176 + (Util.Constants.user_db_filepath new_did) 177 + ~perm:0o644 178 + in 179 + let%lwt repo = 180 + Repository.load ~write:true ~create:true 181 + ~ds:ctx.db new_did 182 + in 183 + let%lwt _ = 184 + Repository.put_initial_commit repo 185 + in 186 + let%lwt _ = 187 + Sequencer.sequence_identity ctx.db 188 + ~did:new_did ~handle () 189 + in 190 + let%lwt _ = 191 + Sequencer.sequence_account ctx.db ~did:new_did 192 + ~active:true () 193 + in 194 + render_page 195 + ~success:("Account created: " ^ handle) 196 + () ) ) ) ) 197 + | Some "change_handle" -> ( 198 + let handle = 199 + List.assoc_opt "handle" fields |> Option.value ~default:"" 200 + in 201 + match%lwt 202 + Identity.UpdateHandle.update_handle ~did ~handle ctx.db 203 + with 204 + | Ok () -> 205 + render_page ~success:"Handle updated." () 206 + | Error e -> 207 + render_page ~error:e () ) 208 + | Some "change_email" -> ( 209 + let email = 210 + List.assoc_opt "email" fields 211 + |> Option.value ~default:"" |> String.lowercase_ascii 212 + in 213 + match%lwt Data_store.get_actor_by_identifier email ctx.db with 214 + | Some existing when existing.did <> did -> 215 + render_page ~error:"Email already in use." () 216 + | _ -> 217 + let%lwt () = Data_store.update_email ~did ~email ctx.db in 218 + render_page ~success:"Email updated." () ) 219 + | Some "change_password" -> 220 + let password = 221 + List.assoc_opt "password" fields |> Option.value ~default:"" 222 + in 223 + let%lwt () = 224 + Data_store.update_password ~did ~password ctx.db 225 + in 226 + render_page ~success:"Password updated." () 227 + | Some "send_password_reset" -> ( 228 + match%lwt Data_store.get_actor_by_identifier did ctx.db with 229 + | None -> 230 + render_page ~error:"Account not found." () 231 + | Some actor -> 232 + let%lwt () = 233 + Server.RequestPasswordReset.request_password_reset actor 234 + ctx.db 235 + in 236 + render_page ~success:"Password reset email sent." () ) 237 + | Some "deactivate" -> 238 + let%lwt _ = 239 + Server.DeactivateAccount.deactivate_account ~did ctx.db 240 + in 241 + render_page ~success:"Account deactivated." () 242 + | Some "reactivate" -> 243 + let%lwt () = Data_store.activate_actor did ctx.db in 244 + let%lwt _ = 245 + Sequencer.sequence_account ctx.db ~did ~active:true 246 + ~status:`Active () 247 + in 248 + render_page ~success:"Account reactivated." () 249 + | Some "delete" -> 250 + let%lwt _ = Server.DeleteAccount.delete_account ~did ctx.db in 251 + render_page ~success:"Account deleted." () 252 + | _ -> 253 + render_page ~error:"Invalid action." () ) 254 + | _ -> 255 + render_page ~error:"Invalid form submission." () ) )
+6
pegasus/lib/api/identity/updateHandle.ml
··· 9 9 | Some _ -> 10 10 Lwt.return_error "handle already in use" 11 11 | None -> ( 12 + let%lwt {handle= prev_handle; _} = 13 + Data_store.get_actor_by_identifier did db |> Lwt.map Option.get 14 + in 12 15 let%lwt () = Data_store.update_actor_handle ~did ~handle db in 13 16 let%lwt plc_result = 14 17 if String.starts_with ~prefix:"did:plc:" did then ··· 25 28 latest.operation.also_known_as 26 29 | false -> 27 30 ("at://" ^ handle) :: latest.operation.also_known_as 31 + in 32 + let aka = 33 + List.filter (fun x -> x <> "at://" ^ prev_handle) aka 28 34 in 29 35 let signed = 30 36 Plc.sign_operation Env.rotation_key
+1 -1
pegasus/lib/api/server/createAccount.ml
··· 95 95 let () = 96 96 Util.mkfile_p (Util.Constants.user_db_filepath did) ~perm:0o644 97 97 in 98 - let%lwt repo = Repository.load ~write:true ~ds:ctx.db did in 98 + let%lwt repo = Repository.load ~write:true ~create:true ~ds:ctx.db did in 99 99 let%lwt _ = Repository.put_initial_commit repo in 100 100 let%lwt _ = 101 101 Sequencer.sequence_identity ctx.db ~did ~handle:input.handle ()
+46
pegasus/lib/data_store.ml
··· 133 133 |sql} 134 134 record_out] 135 135 136 + let delete_invite = 137 + [%rapper 138 + execute 139 + {sql| DELETE FROM invite_codes WHERE code = %string{code} 140 + |sql}] 141 + 142 + let update_invite = 143 + [%rapper 144 + execute 145 + {sql| UPDATE invite_codes SET did = %string{did}, remaining = %int{remaining} 146 + WHERE code = %string{code} 147 + |sql}] 148 + 149 + let list_actors_filtered = 150 + [%rapper 151 + get_many 152 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 153 + FROM actors 154 + WHERE (did LIKE '%' || %string{filter} || '%' 155 + OR handle LIKE '%' || %string{filter} || '%' 156 + OR email LIKE '%' || %string{filter} || '%') 157 + AND did > %string{cursor} 158 + ORDER BY did ASC LIMIT %int{limit} 159 + |sql} 160 + record_out] 161 + 162 + let list_all_actors = 163 + [%rapper 164 + get_many 165 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 166 + FROM actors 167 + WHERE did > %string{cursor} 168 + ORDER BY did ASC LIMIT %int{limit} 169 + |sql} 170 + record_out] 171 + 136 172 (* reserved keys *) 137 173 let create_reserved_key = 138 174 [%rapper ··· 339 375 340 376 let list_invites ?(limit = 100) conn = 341 377 Util.use_pool conn @@ Queries.list_invites ~limit 378 + 379 + let delete_invite ~code conn = Util.use_pool conn @@ Queries.delete_invite ~code 380 + 381 + let update_invite ~code ~did ~remaining conn = 382 + Util.use_pool conn @@ Queries.update_invite ~code ~did ~remaining 383 + 384 + let list_actors_filtered ?(cursor = "") ?(limit = 100) ~filter conn = 385 + if String.length filter = 0 then 386 + Util.use_pool conn @@ Queries.list_all_actors ~cursor ~limit 387 + else Util.use_pool conn @@ Queries.list_actors_filtered ~filter ~cursor ~limit 342 388 343 389 (* reserved keys *) 344 390 let create_reserved_key ~key_did ~did ~private_key conn =
+48 -11
pegasus/lib/session.ml
··· 1 1 type data = 2 2 { current_did: string option [@default None] 3 3 ; logged_in_dids: string list [@default []] 4 - ; session_id: string option [@default None] } 4 + ; session_id: string option [@default None] 5 + ; admin_authenticated: bool [@default false] } 5 6 [@@deriving yojson {strict= false}] 6 7 7 - let default = {current_did= None; logged_in_dids= []; session_id= None} 8 + let default = 9 + { current_did= None 10 + ; logged_in_dids= [] 11 + ; session_id= None 12 + ; admin_authenticated= false } 8 13 9 14 type cache_entry = {timestamp: float; data: data} 10 15 ··· 68 73 69 74 let set_current_did req did = 70 75 match%lwt get_session req with 71 - | Some {logged_in_dids; session_id; _} -> 76 + | Some {logged_in_dids; session_id; admin_authenticated; _} -> 72 77 let%lwt () = 73 - set_session req {current_did= Some did; logged_in_dids; session_id} 78 + set_session req 79 + { current_did= Some did 80 + ; logged_in_dids 81 + ; session_id 82 + ; admin_authenticated } 74 83 in 75 84 Lwt.return_unit 76 85 | None -> ··· 85 94 86 95 let set_logged_in_dids req dids = 87 96 match%lwt get_session req with 88 - | Some {current_did; session_id; _} -> 97 + | Some {current_did; session_id; admin_authenticated; _} -> 89 98 let%lwt () = 90 - set_session req {current_did; logged_in_dids= dids; session_id} 99 + set_session req 100 + {current_did; logged_in_dids= dids; session_id; admin_authenticated} 91 101 in 92 102 Lwt.return_unit 93 103 | None -> ··· 98 108 99 109 let log_in_did req did = 100 110 match%lwt get_session req with 101 - | Some {logged_in_dids; session_id; _} -> 111 + | Some {logged_in_dids; session_id; admin_authenticated; _} -> 102 112 let%lwt () = 103 113 set_session req 104 114 { current_did= Some did 105 115 ; logged_in_dids= 106 116 ( if List.mem did logged_in_dids then logged_in_dids 107 117 else did :: logged_in_dids ) 108 - ; session_id } 118 + ; session_id 119 + ; admin_authenticated } 109 120 in 110 121 Lwt.return_unit 111 122 | None -> 112 123 set_session req 113 - {current_did= Some did; logged_in_dids= [did]; session_id= None} 124 + { current_did= Some did 125 + ; logged_in_dids= [did] 126 + ; session_id= None 127 + ; admin_authenticated= false } 114 128 115 129 let log_out_did req did = 116 130 match%lwt get_session req with 117 - | Some {current_did; logged_in_dids; session_id} -> 131 + | Some {current_did; logged_in_dids; session_id; admin_authenticated} -> 118 132 let%lwt () = 119 133 set_session req 120 134 { current_did 121 135 ; logged_in_dids= List.filter (fun d -> d <> did) logged_in_dids 122 - ; session_id } 136 + ; session_id 137 + ; admin_authenticated } 123 138 in 124 139 Lwt.return_unit 125 140 | None -> ··· 167 182 | _ -> 168 183 Lwt.return_none ) 169 184 dids 185 + 186 + let set_admin_authenticated req authenticated = 187 + match%lwt get_session req with 188 + | Some {current_did; logged_in_dids; session_id; _} -> 189 + set_session req 190 + { current_did 191 + ; logged_in_dids 192 + ; session_id 193 + ; admin_authenticated= authenticated } 194 + | None -> 195 + set_session req 196 + { current_did= None 197 + ; logged_in_dids= [] 198 + ; session_id= None 199 + ; admin_authenticated= authenticated } 200 + 201 + let is_admin_authenticated req = 202 + match%lwt get_session req with 203 + | Some {admin_authenticated; _} -> 204 + Lwt.return admin_authenticated 205 + | None -> 206 + Lwt.return false