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