codex: Show group memberships no user page

And add group user, which shows a link to the group in the page.
This commit is contained in:
2026-01-13 14:23:00 +01:00
parent 11724987b0
commit 4f879252a0
6 changed files with 296 additions and 119 deletions

View File

@@ -18,6 +18,8 @@
<Compile Include="OpenFGA/useReadTuples.fs" />
<Compile Include="OpenFGA/Checkbox.fs" />
<Compile Include="OpenFGA/ArchiveOwnerList.fs" />
<Compile Include="Users/DeleteForm.fs" />
<Compile Include="Users/OpenFgaList.fs" />
<Compile Include="Groups/Utils.fs" />
<Compile Include="Groups/useGroups.fs" />
<Compile Include="Groups/List.fs" />
@@ -34,6 +36,7 @@
<Compile Include="Groups.fs" />
<Compile Include="GroupArchiveAddForm.fs" />
<Compile Include="GroupArchive.fs" />
<Compile Include="GroupUser.fs" />
<Compile Include="Group.fs" />
<Compile Include="ArchivesList.fs" />
<Compile Include="Archives.fs" />

View File

@@ -155,7 +155,7 @@ type Components =
| [ "groups" ] -> Groups.View ()
| [ "groups"; group ] -> Group.View group
| [ "groups"; group; "archives"; Route.Guid id ] -> GroupArchive.View group id
| [ "groups"; group; "users"; user ] -> User.View user
| [ "groups"; group; "users"; user ] -> GroupUser.View group user
| [ "users"; user ] -> User.View user
| [ "organizations" ] -> Organizations.List ()
| [ "organizations"; org ] -> Organization.View org

View File

@@ -0,0 +1,131 @@
namespace Oceanbox.Codex
open Feliz
open Feliz.Router
module GroupUser =
[<ReactComponent>]
let View (group: string) (user: string) =
let fgaUser = sprintf "user:%s" user
let execCtx = {|
time = System.DateTime.Now
task = "*"
usage = "-1"
|}
Html.main [
Html.h1 [
prop.children [
Html.text "Group "
Html.a [
prop.href (Router.format ("groups", group))
prop.text group
]
Html.text " / "
Html.text "User "
Html.a [
prop.href (Router.format ("users", user))
prop.text user
]
]
]
Html.section [
prop.children [
Users.DeleteForm user
]
]
Html.section [
prop.children [
Html.h2 "Archmaester"
Html.p "TODO"
]
]
Html.section [
prop.children [
Html.h2 "OpenFGA"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-8" ]
prop.children [
OpenFGA.Checkbox("active-checkbox", "Active", fgaUser, "active", fgaUser)
OpenFGA.Checkbox("registered-checkbox", "Registered", fgaUser, "registered", fgaUser)
OpenFGA.Checkbox("disabled-checkbox", "Disabled", fgaUser, "disabled", fgaUser)
]
]
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with Owner"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
Users.OpenFgaList(fgaUser, "owner", "archive")
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with View"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
Users.OpenFgaList(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with exec"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
Users.OpenFgaList(fgaUser, "exec", "archive", execCtx)
]
]
]
]
]
]
]
]
]

View File

@@ -1,123 +1,9 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
[<Erase>]
type User =
[<ReactComponent>]
static member List(user: string, relation: string, objectType: string, ?context: obj) =
let objects = OpenFGA.useObjects(user, relation, objectType, context)
if objects.Loading then
Html.p "Loading ..."
else
if Array.isEmpty objects.Objects then
Html.p (sprintf "No objects with user %s relation %s of type %s" user relation objectType)
else
Html.ul [
prop.children (
objects.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key id
prop.children [
Html.a [
prop.href (Router.format("archives", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]
module User =
[<ReactComponent>]
let private DeleteForm (user: string) =
let deleted, setDeleted = React.useState<Result<unit, string> option> None
let deleting, setDeleting = React.useState false
let handleDelete =
React.useCallback (
(fun () ->
setDeleting true
console.info("[User] Deleting user %s", user)
Remoting.adminApi.removeUsers [| user |]
|> Async.StartAsPromise
|> Promise.catch (fun ex ->
match ex with
| :? ProxyRequestException as e ->
let proxyError : Types.ProxyError = JS.JSON.parse e.ResponseText |> unbox
let msg = proxyError.error.errorMsg
Error msg
| ex ->
Error ex.Message
)
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("[User] Successfully deleted user %s", user)
setDeleted (Some (Ok ()))
| Error err ->
console.error("[User] Error deleting user %s: %s", user, err)
setDeleted (Some (Error err))
)
),
[| box user |]
)
React.fragment [
match deleted with
| Some (Ok ()) ->
Html.p "User successfully deleted."
Html.a [
prop.onClick (fun ev ->
ev.preventDefault ()
Router.navigateBack ()
)
prop.href (Router.format "")
prop.text "Back"
]
| Some (Error err) ->
Html.p (sprintf "Error deleting user: %s" err)
| None ->
if deleting then
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.button [
prop.onClick (fun _ -> handleDelete ())
prop.text "Are you sure?"
]
Html.button [
prop.onClick (fun _ -> setDeleting false)
prop.text "Cancel"
]
]
]
Html.p "This will delete the user from the databases. Not disable the user."
else
Html.button [
prop.onClick (fun _ -> setDeleting true)
prop.text "Delete"
]
]
[<ReactComponent>]
let View (user: string) =
let fgaUser = sprintf "user:%s" user
@@ -127,15 +13,49 @@ module User =
usage = "-1"
|}
let groups = OpenFGA.useObjects(fgaUser, "member", "group")
Html.main [
Html.h1 user
Html.section [
prop.children [
DeleteForm user
Users.DeleteForm user
]
]
Html.section [
prop.children [
Html.h2 "Groups"
Html.div [
prop.children [
Html.ul [
prop.children (
groups.Objects
|> Array.map (fun group ->
let split = group.Split ':'
match split with
| [| objectType; groupName |] ->
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", groupName))
prop.text groupName
]
]
]
| _ ->
Html.none
)
)
]
]
]
]
]
Html.section [
prop.children [
Html.h2 "Archmaester"
@@ -175,7 +95,7 @@ module User =
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "owner", "archive")
Users.OpenFgaList(fgaUser, "owner", "archive")
]
]
]
@@ -197,7 +117,7 @@ module User =
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
Users.OpenFgaList(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
]
]
]
@@ -219,7 +139,7 @@ module User =
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "exec", "archive", execCtx)
Users.OpenFgaList(fgaUser, "exec", "archive", execCtx)
]
]
]

View File

@@ -0,0 +1,81 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
module Users =
[<ReactComponent>]
let DeleteForm (user: string) =
let deleted, setDeleted = React.useState<Result<unit, string> option> None
let deleting, setDeleting = React.useState false
let handleDelete =
React.useCallback (
(fun () ->
setDeleting true
console.info("[User] Deleting user %s", user)
Remoting.adminApi.removeUsers [| user |]
|> Async.StartAsPromise
|> Promise.catch (fun ex ->
match ex with
| :? ProxyRequestException as e ->
let proxyError : Types.ProxyError = JS.JSON.parse e.ResponseText |> unbox
let msg = proxyError.error.errorMsg
Error msg
| ex ->
Error ex.Message
)
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("[User] Successfully deleted user %s", user)
setDeleted (Some (Ok ()))
| Error err ->
console.error("[User] Error deleting user %s: %s", user, err)
setDeleted (Some (Error err))
)
),
[| box user |]
)
React.fragment [
match deleted with
| Some (Ok ()) ->
Html.p "User successfully deleted."
Html.a [
prop.onClick (fun ev ->
ev.preventDefault ()
Router.navigateBack ()
)
prop.href (Router.format "")
prop.text "Back"
]
| Some (Error err) ->
Html.p (sprintf "Error deleting user: %s" err)
| None ->
if deleting then
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.button [
prop.onClick (fun _ -> handleDelete ())
prop.text "Are you sure?"
]
Html.button [
prop.onClick (fun _ -> setDeleting false)
prop.text "Cancel"
]
]
]
Html.p "This will delete the user from the databases. Not disable the user."
else
Html.button [
prop.onClick (fun _ -> setDeleting true)
prop.text "Delete"
]
]

View File

@@ -0,0 +1,42 @@
namespace Oceanbox.Codex
open Fable.Core
open Feliz
open Feliz.Router
[<Erase>]
type Users =
[<ReactComponent>]
static member OpenFgaList(user: string, relation: string, objectType: string, ?context: obj) =
let objects = OpenFGA.useObjects(user, relation, objectType, context)
if objects.Loading then
Html.p "Loading ..."
else
if Array.isEmpty objects.Objects then
Html.p (sprintf "No objects with user %s relation %s of type %s" user relation objectType)
else
Html.ul [
prop.children (
objects.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key id
prop.children [
Html.a [
prop.href (Router.format("archives", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]