about summary refs log tree commit diff
path: root/MatrixRoomUtils.Web
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixRoomUtils.Web')
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/PolicyList.razor421
-rw-r--r--MatrixRoomUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor92
2 files changed, 291 insertions, 222 deletions
diff --git a/MatrixRoomUtils.Web/Pages/Rooms/PolicyList.razor b/MatrixRoomUtils.Web/Pages/Rooms/PolicyList.razor
index dbe0648..b89d59c 100644
--- a/MatrixRoomUtils.Web/Pages/Rooms/PolicyList.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/PolicyList.razor
@@ -1,192 +1,124 @@
 @page "/Rooms/{RoomId}/Policies"
 @using LibMatrix
-@using LibMatrix.Homeservers
 @using ArcaneLibs.Extensions
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.EventTypes.Spec.State.Policy
 @using System.Diagnostics
-@using System.Diagnostics.CodeAnalysis
-@using LibMatrix.Extensions
-@using LibMatrix.Responses
-<h3>Policy list editor - Editing @RoomId</h3>
-<hr/>
+@using LibMatrix.RoomTypes
+@using System.Collections.Frozen
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix.EventTypes
 
-<p>
-    This policy list contains @GetPolicyCount(typeof(ServerPolicyRuleEventContent)) server bans,
-    @GetPolicyCount(typeof(RoomPolicyRuleEventContent)) room bans and
-    @GetPolicyCount(typeof(UserPolicyRuleEventContent)) user bans.
-    @foreach (var (key, value) in PolicyEventsByType) {
-        <p>@key.Name: @value.Count</p>
-    }
-</p>
-<InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label>
+@using MatrixRoomUtils.Web.Shared.PolicyEditorComponents
 
-<h3>Server policies</h3>
+<h3>Policy list editor - Editing @RoomId</h3>
 <hr/>
-@if (!GetPolicyEventsByType(typeof(ServerPolicyRuleEventContent)).Any()) {
-    <p>No server policies</p>
-}
-else {
-    <table class="table table-striped table-hover" style="width: fit-content;">
-        <thead>
-            <tr>
-                <th style="max-width: 50vw;">Server</th>
-                <th>Reason</th>
-                <th>Expires</th>
-                <th>Actions</th>
-            </tr>
-        </thead>
-        <tbody>
-            @foreach (var policyEvent in GetValidPolicyEventsByType(typeof(ServerPolicyRuleEventContent))) {
-                var policyData = policyEvent.TypedContent as PolicyRuleEventContent;
-                <tr>
-                    <td>
-                        <span>Entity: @policyData.Entity</span>
-                        <span><br/>State: @policyEvent.StateKey</span>
-                    </td>
-                    <td>@policyData.Reason</td>
-                    <td>
-                        @policyData.ExpiryDateTime
-                    </td>
-                    <td>
-                        <button class="btn" @* @onclick="async () => await RemovePolicyAsync(policyEvent)" *@>Edit</button>
-                        @* <button class="btn btn-danger" $1$ @onclick="async () => await RemovePolicyAsync(policyEvent)" #1#>Remove</button> *@
-                    </td>
-                </tr>
-            }
-        </tbody>
-    </table>
-    <details>
-        <summary>Redacted or invalid events</summary>
-        <table class="table table-striped table-hover" style="width: fit-content;">
-            <thead>
-                <tr>
-                    <th style="max-width: 50vw;">State key</th>
-                    <th>Serialised Contents</th>
-                </tr>
-            </thead>
-            <tbody>
-                @foreach (var policyEvent in GetInvalidPolicyEventsByType(typeof(ServerPolicyRuleEventContent))) {
-                    <tr>
-                        <td>@policyEvent.StateKey</td>
-                        <td>@policyEvent.RawContent.ToJson(false, true)</td>
-                    </tr>
-                }
-            </tbody>
-        </table>
-    </details>
+@* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
+<LinkButton OnClick="@(() => { CurrentlyEditingEvent = new() { Type = "", RawContent = new() }; return Task.CompletedTask; })">Create new policy</LinkButton>
+
+@if (Loading) {
+    <p>Loading...</p>
 }
-<h3>Room policies</h3>
-<hr/>
-@if (!GetPolicyEventsByType(typeof(RoomPolicyRuleEventContent)).Any()) {
-    <p>No room policies</p>
+else if (PolicyEventsByType is not { Count: > 0 }) {
+    <p>No policies yet</p>
 }
 else {
-    <table class="table table-striped table-hover" style="width: fit-content;">
-        <thead>
-            <tr>
-                <th style="max-width: 50vw;">Room</th>
-                <th>Reason</th>
-                <th>Expires</th>
-                <th>Actions</th>
-            </tr>
-        </thead>
-        <tbody>
-            @foreach (var policyEvent in GetValidPolicyEventsByType(typeof(RoomPolicyRuleEventContent))) {
-                var policyData = policyEvent.TypedContent as PolicyRuleEventContent;
-                <tr>
-                    <td>Entity: @policyData.Entity<br/>State: @policyEvent.StateKey</td>
-                    <td>@policyData.Reason</td>
-                    <td>
-                        @policyData.ExpiryDateTime
-                    </td>
-                    <td>
-                        <button class="btn btn-danger" @* @onclick="async () => await RemovePolicyAsync(policyEvent)" *@>Remove</button>
-                    </td>
-                </tr>
-            }
-        </tbody>
-    </table>
-    <details>
-        <summary>Redacted or invalid events</summary>
-        <table class="table table-striped table-hover" style="width: fit-content;">
-            <thead>
-                <tr>
-                    <th style="max-width: 50vw;">State key</th>
-                    <th>Serialised Contents</th>
-                </tr>
-            </thead>
-            <tbody>
-                @foreach (var policyEvent in GetInvalidPolicyEventsByType(typeof(RoomPolicyRuleEventContent))) {
+    @foreach (var (type, value) in PolicyEventsByType) {
+        <p>
+            @(GetValidPolicyEventsByType(type).Count) active,
+            @(GetInvalidPolicyEventsByType(type).Count) invalid
+            (@value.Count total)
+            @(GetPolicyTypeName(type).ToLower())
+        </p>
+    }
+
+    @foreach (var type in KnownPolicyTypes.OrderByDescending(t => GetPolicyEventsByType(t).Count)) {
+        <details>
+            <summary>
+                <span>
+                    @($"{GetPolicyTypeName(type)}: {GetPolicyEventsByType(type).Count} policies")
+                </span>
+                <hr style="margin: revert;"/>
+            </summary>
+            <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+                @{
+                    var policies = GetValidPolicyEventsByType(type);
+                    var invalidPolicies = GetInvalidPolicyEventsByType(type);
+                    // enumerate all properties with friendly name
+                    var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => (x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyNameOrNull()) is not null)
+                        .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
+                        .ToFrozenSet();
+                    var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+                }
+                <thead>
                     <tr>
-                        <td>@policyEvent.StateKey</td>
-                        <td>@policyEvent.RawContent!.ToJson(false, true)</td>
+                        @foreach (var name in propNames) {
+                            <th style="border-width: 1px">@name</th>
+                        }
+                        <th style="border-width: 1px">Actions</th>
                     </tr>
-                }
-            </tbody>
-        </table>
-    </details>
-}
-<h3>User policies</h3>
-<hr/>
-@if (!GetPolicyEventsByType(typeof(UserPolicyRuleEventContent)).Any()) {
-    <p>No user policies</p>
-}
-else {
-    <table class="table table-striped table-hover" style="width: fit-content;">
-        <thead>
-            <tr>
-                @if (EnableAvatars) {
-                    <th></th>
-                }
-                <th style="max-width: 0.2vw; word-wrap: anywhere;">User</th>
-                <th>Reason</th>
-                <th>Expires</th>
-                <th>Actions</th>
-            </tr>
-        </thead>
-        <tbody>
-            @foreach (var policyEvent in GetValidPolicyEventsByType(typeof(UserPolicyRuleEventContent))) {
-                var policyData = policyEvent.TypedContent as PolicyRuleEventContent;
-                <tr>
-                    @if (EnableAvatars) {
-                        <td>
-                            @if (Avatars.ContainsKey(policyData.Entity)) {
-                                <img class="avatar48" src="@Avatars[policyData.Entity]"/>
+                </thead>
+                <tbody style="border-width: 1px;">
+                    @foreach (var policy in policies.OrderBy(x => x.RawContent?["entity"]?.GetValue<string>())) {
+                        <tr>
+                            @{
+                                var typedContent = policy.TypedContent!;
+                                var proxySafeProps = typedContent.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                                    .Where(x => props.Any(y => y.Name == x.Name))
+                                    .ToFrozenSet();
+                                Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
                             }
-                        </td>
+                            @foreach (var prop in proxySafeProps ?? Enumerable.Empty<PropertyInfo>()) {
+                                <td>@prop.GetGetMethod()?.Invoke(typedContent, null)</td>
+                            }
+                            <td>
+                                <div style="display: ruby;">
+                                    @if (PowerLevels.UserHasStatePermission(Homeserver.WhoAmI.UserId, policy.Type)) {
+                                        <LinkButton OnClick="@(() => { CurrentlyEditingEvent = policy; return Task.CompletedTask; })">Edit</LinkButton>
+                                        <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Remove</LinkButton>
+                                        @if (policy.IsLegacyType) {
+                                            <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Update policy type</LinkButton>
+                                        }
+                                    }
+                                </div>
+                            </td>
+                        </tr>
                     }
-                    <td style="word-wrap: anywhere;">Entity: @string.Join("", policyData.Entity.Take(64))<br/>State: @string.Join("", policyEvent.StateKey.Take(64))</td>
-                    <td>@policyData.Reason</td>
-                    <td>
-                        @policyData.ExpiryDateTime
-                    </td>
-                    <td>
-                        <button class="btn btn-danger" @* @onclick="async () => await RemovePolicyAsync(policyEvent)" *@>Remove</button>
-                    </td>
-                </tr>
-            }
-        </tbody>
-    </table>
-    <details>
-        <summary>Redacted or invalid events</summary>
-        <table class="table table-striped table-hover" style="width: fit-content;">
-            <thead>
-                <tr>
-                    <th>State key</th>
-                    <th>Serialised Contents</th>
-                </tr>
-            </thead>
-            <tbody>
-                @foreach (var policyEvent in GetInvalidPolicyEventsByType(typeof(UserPolicyRuleEventContent))) {
-                    <tr>
-                        <td>@policyEvent.StateKey</td>
-                        <td>@policyEvent.RawContent.ToJson(false, true)</td>
-                    </tr>
-                }
-            </tbody>
-        </table>
-    </details>
+                </tbody>
+            </table>
+            <details>
+                <summary>
+                    <u>
+                        @("Invalid " + GetPolicyTypeName(type).ToLower())
+                    </u>
+                </summary>
+                <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+                    <thead>
+                        <tr>
+                            <th style="border-width: 1px">State key</th>
+                            <th style="border-width: 1px">Json contents</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        @foreach (var policy in invalidPolicies) {
+                            <tr>
+                                <td>@policy.StateKey</td>
+                                <td>
+                                    <pre>@policy.RawContent.ToJson(true, false)</pre>
+                                </td>
+                            </tr>
+                        }
+                    </tbody>
+                </table>
+            </details>
+        </details>
+    }
+}
+
+@if (CurrentlyEditingEvent is not null) {
+    <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSave="@(e => UpdatePolicyAsync(e))"></PolicyEditorModal>
 }
 
 @code {
@@ -197,47 +129,59 @@ else {
     private const bool Debug = false;
 #endif
 
+    private bool Loading { get; set; } = true;
     //get room list
     // - sync withroom list filter
     // Type = support.feline.msc3784
     //support.feline.policy.lists.msc.v1
 
     [Parameter]
-    public string? RoomId { get; set; }
+    public string RoomId { get; set; } = null!;
 
     private bool _enableAvatars;
+    private StateEventResponse? _currentlyEditingEvent;
 
-    static readonly Dictionary<string, string?> Avatars = new();
+    // static readonly Dictionary<string, string?> Avatars = new();
     // static readonly Dictionary<string, RemoteHomeserver> Servers = new();
 
     // private static List<StateEventResponse> PolicyEvents { get; set; } = new();
     private Dictionary<Type, List<StateEventResponse>> PolicyEventsByType { get; set; } = new();
 
-    public bool EnableAvatars {
-        get => _enableAvatars;
+    private StateEventResponse? CurrentlyEditingEvent {
+        get => _currentlyEditingEvent;
         set {
-            _enableAvatars = value;
-            if (value) GetAllAvatars();
+            _currentlyEditingEvent = value;
+            StateHasChanged();
         }
     }
 
+    // public bool EnableAvatars {
+    //     get => _enableAvatars;
+    //     set {
+    //         _enableAvatars = value;
+    //         if (value) GetAllAvatars();
+    //     }
+    // }
+
+    private AuthenticatedHomeserverGeneric Homeserver { get; set; }
+    private GenericRoom Room { get; set; }
+    private RoomPowerLevelEventContent PowerLevels { get; set; }
+
     protected override async Task OnInitializedAsync() {
         var sw = Stopwatch.StartNew();
         await base.OnInitializedAsync();
-        var hs = await MRUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
-        RoomId = RoomId.Replace('~', '.');
+        Homeserver = (await MRUStorage.GetCurrentSessionOrNavigate())!;
+        if (Homeserver is null) return;
+        Room = Homeserver.GetRoom(RoomId!);
+        PowerLevels = (await Room.GetPowerLevelsAsync())!;
         await LoadStatesAsync();
         Console.WriteLine($"Policy list editor initialized in {sw.Elapsed}!");
     }
 
     private async Task LoadStatesAsync() {
-        var hs = await MRUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
-
-        var room = hs.GetRoom(RoomId);
-
-        var states = room.GetFullStateAsync();
+        Loading = true;
+        var states = Room.GetFullStateAsync();
+        PolicyEventsByType.Clear();
         await foreach (var state in states) {
             if (state is null) continue;
             if (!state.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) continue;
@@ -245,46 +189,79 @@ else {
             PolicyEventsByType[state.MappedType].Add(state);
         }
 
+        Loading = false;
         StateHasChanged();
     }
 
-    private async Task GetAllAvatars() {
-        // if (!_enableAvatars) return;
-        Console.WriteLine("Getting avatars...");
-        var users = GetValidPolicyEventsByType(typeof(UserPolicyRuleEventContent)).Select(x => x.RawContent!["entity"]!.GetValue<string>()).Where(x => x.Contains(':') && !x.Contains("*")).ToList();
-        Console.WriteLine($"Got {users.Count} users!");
-        var usersByHomeServer = users.GroupBy(x => x!.Split(':')[1]).ToDictionary(x => x.Key!, x => x.ToList());
-        Console.WriteLine($"Got {usersByHomeServer.Count} homeservers!");
-        var homeserverTasks = usersByHomeServer.Keys.Select(x => RemoteHomeserver.TryCreate(x)).ToAsyncEnumerable();
-        await foreach (var server in homeserverTasks) {
-            if (server is null) continue;
-            var profileTasks = usersByHomeServer[server.BaseUrl].Select(x => TryGetProfile(server, x)).ToList();
-            await Task.WhenAll(profileTasks);
-            profileTasks.RemoveAll(x => x.Result is not { Value: { AvatarUrl: not null } });
-            foreach (var profile in profileTasks.Select(x => x.Result!.Value)) {
-                // if (profile is null) continue;
-                if (!string.IsNullOrWhiteSpace(profile.Value.AvatarUrl)) {
-                    var url = await hsResolver.ResolveMediaUri(server.BaseUrl, profile.Value.AvatarUrl);
-                    Avatars.TryAdd(profile.Key, url);
-                }
-                else Avatars.TryAdd(profile.Key, null);
-            }
-            StateHasChanged();
-        }
+    // private async Task GetAllAvatars() {
+    //     // if (!_enableAvatars) return;
+    //     Console.WriteLine("Getting avatars...");
+    //     var users = GetValidPolicyEventsByType(typeof(UserPolicyRuleEventContent)).Select(x => x.RawContent!["entity"]!.GetValue<string>()).Where(x => x.Contains(':') && !x.Contains("*")).ToList();
+    //     Console.WriteLine($"Got {users.Count} users!");
+    //     var usersByHomeServer = users.GroupBy(x => x!.Split(':')[1]).ToDictionary(x => x.Key!, x => x.ToList());
+    //     Console.WriteLine($"Got {usersByHomeServer.Count} homeservers!");
+    //     var homeserverTasks = usersByHomeServer.Keys.Select(x => RemoteHomeserver.TryCreate(x)).ToAsyncEnumerable();
+    //     await foreach (var server in homeserverTasks) {
+    //         if (server is null) continue;
+    //         var profileTasks = usersByHomeServer[server.BaseUrl].Select(x => TryGetProfile(server, x)).ToList();
+    //         await Task.WhenAll(profileTasks);
+    //         profileTasks.RemoveAll(x => x.Result is not { Value: { AvatarUrl: not null } });
+    //         foreach (var profile in profileTasks.Select(x => x.Result!.Value)) {
+    //             // if (profile is null) continue;
+    //             if (!string.IsNullOrWhiteSpace(profile.Value.AvatarUrl)) {
+    //                 var url = await hsResolver.ResolveMediaUri(server.BaseUrl, profile.Value.AvatarUrl);
+    //                 Avatars.TryAdd(profile.Key, url);
+    //             }
+    //             else Avatars.TryAdd(profile.Key, null);
+    //         }
+    //
+    //         StateHasChanged();
+    //     }
+    // }
+    //
+    // private async Task<KeyValuePair<string, UserProfileResponse>?> TryGetProfile(RemoteHomeserver server, string mxid) {
+    //     try {
+    //         return new KeyValuePair<string, UserProfileResponse>(mxid, await server.GetProfileAsync(mxid));
+    //     }
+    //     catch {
+    //         return null;
+    //     }
+    // }
+
+    private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
+
+    private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+
+    private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+
+    private string? GetPolicyTypeNameOrNull(Type type) => type.GetFriendlyNamePluralOrNull()
+                                                          ?? type.GetCustomAttributes<MatrixEventAttribute>()
+                                                              .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.EventName))?.EventName;
+
+    private string GetPolicyTypeName(Type type) => GetPolicyTypeNameOrNull(type) ?? type.Name;
+
+    private async Task RemovePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey, new { });
+        PolicyEventsByType[policyEvent.MappedType].Remove(policyEvent);
+        await LoadStatesAsync();
     }
 
-    private async Task<KeyValuePair<string, UserProfileResponse>?> TryGetProfile(RemoteHomeserver server, string mxid) {
-        try {
-            return new KeyValuePair<string, UserProfileResponse>(mxid, await server.GetProfileAsync(mxid));
-        }
-        catch {
-            return null;
-        }
+    private async Task UpdatePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey, policyEvent.RawContent);
+        await LoadStatesAsync();
     }
 
-    private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
-    private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type).Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
-    private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type).Where(x => string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
-    private int GetPolicyCount(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type].Count : 0;
+    private async Task UpgradePolicyAsync(StateEventResponse policyEvent) {
+        policyEvent.RawContent["upgraded_from_type"] = policyEvent.Type;
+        await LoadStatesAsync();
+    }
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    // event types, unnamed
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
 
 }
\ No newline at end of file
diff --git a/MatrixRoomUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor b/MatrixRoomUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
new file mode 100644
index 0000000..4fd151d
--- /dev/null
+++ b/MatrixRoomUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
@@ -0,0 +1,92 @@
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using System.Collections.Frozen
+@using LibMatrix.EventTypes
+<ModalWindow Title="@((string.IsNullOrWhiteSpace(PolicyEvent.EventId) ? "Creating new " : "Editing ") + (PolicyEvent.MappedType.GetFriendlyNameOrNull()?.ToLower() ?? "event"))"
+             OnCloseClicked="@OnClose" X="60" Y="60" MinWidth="300">
+    @{
+        var policyData = (PolicyEvent.TypedContent as PolicyRuleEventContent)!;
+    }
+    @if (string.IsNullOrWhiteSpace(PolicyEvent.EventId)) {
+        <span>Policy type:</span>
+        <select @bind="@PolicyEvent.Type">
+            <option>Select a value</option>
+            @foreach (var (type, mappedType) in PolicyTypes) {
+                <option value="@type">@mappedType.GetFriendlyName().ToLower()</option>
+            }
+        </select>
+    }
+
+
+    @{
+        // enumerate all properties with friendly name
+        var props = PolicyEvent.MappedType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+            .Where(x => (x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyNameOrNull()) is not null)
+            .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
+            .ToFrozenSet();
+        var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+    }
+    <table>
+        <thead style="border-bottom: solid #ffffff44 1px;">
+            <tr>
+                <th>Property</th>
+                <th>Value</th>
+            </tr>
+        </thead>
+        <tbody>
+            @foreach (var prop in props) {
+                <tr>
+                    <td style="padding-right: 8px;">
+                        <span>@prop.GetFriendlyName()</span>
+                        @if (Nullable.GetUnderlyingType(prop.PropertyType) is not null) {
+                            <span style="color: red;">*</span>
+                        }
+                    </td>
+                    @{
+                        var getter = prop.GetGetMethod();
+                        var setter = prop.GetSetMethod();
+                    }
+                    @switch (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) {
+                        case Type t when t == typeof(string):
+                            <FancyTextBox Value="@(getter?.Invoke(policyData, null) as string)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(policyData, [e]); StateHasChanged(); })"></FancyTextBox>
+                            break;
+                        default:
+                            <p style="color: red;">Unsupported type: @prop.PropertyType</p>
+                            break;
+                    }
+                </tr>
+            }
+        </tbody>
+    </table>
+    <br/>
+    <pre>
+        @PolicyEvent.ToJson(true, false)
+    </pre>
+    <LinkButton OnClick="@(() => { OnClose.Invoke(); return Task.CompletedTask; })">Cancel</LinkButton>
+    <LinkButton OnClick="@(() => { OnSave.Invoke(PolicyEvent); return Task.CompletedTask; })">Save</LinkButton>
+    @* <span>Target entity: </span> *@
+    @* <FancyTextBox @bind-Value="@policyData.Entity"></FancyTextBox><br/> *@
+    @* <span>Reason: </span> *@
+    @* <FancyTextBox @bind-Value="@policyData.Reason"></FancyTextBox> *@
+</ModalWindow>
+
+@code {
+
+    [Parameter]
+    public StateEventResponse? PolicyEvent { get; set; }
+
+    [Parameter]
+    public required Action OnClose { get; set; }
+    
+    [Parameter]
+    public required Action<StateEventResponse> OnSave { get; set; }
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+}
\ No newline at end of file