Skip to content

Commit 23f3ef9

Browse files
committed
Add PrivilegeButton component
1 parent a1ebba2 commit 23f3ef9

File tree

2 files changed

+756
-0
lines changed

2 files changed

+756
-0
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
using Microsoft.AspNetCore.Components;
2+
using Microsoft.AspNetCore.Components.Rendering;
3+
using Microsoft.AspNetCore.Components.Web;
4+
5+
namespace Privileged.Components;
6+
7+
/// <summary>
8+
/// A button component that integrates privilege-based access control and busy state management.
9+
/// This component automatically manages authorization checks, busy states during async operations,
10+
/// and provides declarative permission-based rendering through a cascading <see cref="PrivilegeContext"/>.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// The <see cref="PrivilegeButton"/> component provides a comprehensive solution for secure user interactions
15+
/// by combining authorization checks with modern user experience patterns such as busy indicators and
16+
/// disabled states during async operations.
17+
/// </para>
18+
/// <para>
19+
/// The component requires a cascading <see cref="PrivilegeContext"/> parameter to function properly.
20+
/// This context contains the privilege rules and performs the actual authorization checks that determine
21+
/// whether the button should be enabled, disabled, or hidden from the user interface.
22+
/// </para>
23+
/// <para>
24+
/// Button state precedence:
25+
/// <list type="number">
26+
/// <item>Hidden: If <see cref="HideForbidden"/> is <c>true</c> and the user lacks permission.</item>
27+
/// <item>Disabled: If <see cref="Disabled"/> is <c>true</c>, the button is busy, or the user lacks permission.</item>
28+
/// <item>Busy: When <see cref="Busy"/> is <c>true</c> or the <see cref="Trigger"/> callback is executing.</item>
29+
/// <item>Normal: When the user has permission and the button is not disabled or busy.</item>
30+
/// </list>
31+
/// </para>
32+
/// </remarks>
33+
/// <seealso cref="PrivilegeContext"/>
34+
/// <seealso cref="PrivilegeView"/>
35+
public class PrivilegeButton : ComponentBase
36+
{
37+
/// <summary>
38+
/// Gets or sets the cascading privilege context used to evaluate access permissions.
39+
/// This parameter is required for the component to function and must be provided
40+
/// by a parent component or through dependency injection.
41+
/// </summary>
42+
/// <value>
43+
/// The <see cref="PrivilegeContext"/> instance that contains the rules and logic
44+
/// for evaluating user privileges. This parameter is required and must be provided
45+
/// as a cascading parameter from a parent component.
46+
/// </value>
47+
/// <exception cref="InvalidOperationException">
48+
/// Thrown if this parameter is not available when the component initializes.
49+
/// </exception>
50+
[CascadingParameter]
51+
protected PrivilegeContext? PrivilegeContext { get; set; }
52+
53+
/// <summary>
54+
/// Gets or sets the action to authorize for this button.
55+
/// This parameter is required and defines what operation the user is attempting to perform.
56+
/// </summary>
57+
/// <value>
58+
/// The action name to check permissions for (e.g., "read", "write", "delete", "create").
59+
/// This parameter is required and must be specified.
60+
/// </value>
61+
/// <remarks>
62+
/// <para>
63+
/// Actions typically represent verbs describing what operation the user wants to perform.
64+
/// Common actions include "read", "create", "update", "delete", or custom business-specific
65+
/// actions like "publish", "approve", or "archive". The action is evaluated against the
66+
/// current user's privileges to determine if the button should be enabled.
67+
/// </para>
68+
/// </remarks>
69+
[Parameter, EditorRequired]
70+
public required string Action { get; set; }
71+
72+
/// <summary>
73+
/// Gets or sets the subject to authorize for this button.
74+
/// The subject typically represents a resource, entity type, or domain object.
75+
/// </summary>
76+
/// <value>
77+
/// The subject name representing the resource or entity to check permissions for (e.g., "Post", "User", "Order").
78+
/// Can be <c>null</c> for context-free actions.
79+
/// </value>
80+
/// <remarks>
81+
/// <para>
82+
/// The subject represents the target of the action - what the user is trying to perform the action on.
83+
/// For instance, in a content management system, subjects might include "Post", "Page", "User", or "Comment".
84+
/// </para>
85+
/// </remarks>
86+
[Parameter, EditorRequired]
87+
public required string Subject { get; set; }
88+
89+
/// <summary>
90+
/// Gets or sets an optional qualifier that provides additional scoping for the privilege evaluation.
91+
/// This allows for fine-grained permission control at the field, property, or operation level.
92+
/// </summary>
93+
/// <value>
94+
/// An optional qualifier string that further scopes the privilege check
95+
/// (e.g., a field name, category, or specific identifier). Can be <c>null</c>.
96+
/// </value>
97+
/// <remarks>
98+
/// <para>
99+
/// The qualifier parameter allows for granular permission control beyond the action-subject level.
100+
/// For instance, a user might have permission to "update" a "Post" but only specific fields like
101+
/// "title" and "summary", not sensitive fields like "metadata" or "internalNotes".
102+
/// </para>
103+
/// </remarks>
104+
[Parameter]
105+
public string? Qualifier { get; set; }
106+
107+
/// <summary>
108+
/// Gets or sets a value indicating whether the button is in a busy state.
109+
/// When <c>true</c>, the busy indicator is shown and the button is disabled.
110+
/// </summary>
111+
/// <value>
112+
/// <c>true</c> if the button should display busy state and be disabled; otherwise, <c>false</c>.
113+
/// Defaults to <c>false</c>.
114+
/// </value>
115+
/// <remarks>
116+
/// <para>
117+
/// This parameter allows external control of the button's busy state, independent of the
118+
/// internal <see cref="Executing"/> state that is managed automatically during <see cref="Trigger"/> execution.
119+
/// </para>
120+
/// </remarks>
121+
[Parameter]
122+
public bool Busy { get; set; }
123+
124+
/// <summary>
125+
/// Gets or sets a value indicating whether the button is disabled.
126+
/// When <c>true</c>, the button cannot be clicked and appears visually disabled.
127+
/// </summary>
128+
/// <value>
129+
/// <c>true</c> if the button should be disabled; otherwise, <c>false</c>.
130+
/// Defaults to <c>false</c>.
131+
/// </value>
132+
/// <remarks>
133+
/// <para>
134+
/// This parameter provides explicit control over the button's disabled state, independent of
135+
/// privilege checks, busy states, or other automatic disabling conditions.
136+
/// </para>
137+
/// <para>
138+
/// The final disabled state of the button is determined by combining this parameter with
139+
/// other conditions: the button is disabled if any of the following are true:
140+
/// <list type="bullet">
141+
/// <item><description>This <see cref="Disabled"/> parameter is <c>true</c></description></item>
142+
/// <item><description>The button is busy (see <see cref="IsBusy"/>)</description></item>
143+
/// <item><description>The user lacks the required permission (see <see cref="HasPermission"/>)</description></item>
144+
/// </list>
145+
/// </para>
146+
/// </remarks>
147+
[Parameter]
148+
public bool Disabled { get; set; }
149+
150+
/// <summary>
151+
/// Gets or sets the text to display when the button is busy.
152+
/// This text is shown in the default busy template if no custom <see cref="BusyTemplate"/> is provided.
153+
/// </summary>
154+
/// <value>
155+
/// The text to display during busy state. Defaults to "Processing...".
156+
/// </value>
157+
[Parameter]
158+
public string BusyText { get; set; } = "Processing...";
159+
160+
/// <summary>
161+
/// Gets or sets a custom template to display when the button is busy.
162+
/// When provided, this template overrides the default busy display that uses <see cref="BusyText"/>.
163+
/// </summary>
164+
/// <value>
165+
/// A <see cref="RenderFragment"/> that defines the content to render when the button is busy.
166+
/// Can be <c>null</c> to use the default busy template.
167+
/// </value>
168+
[Parameter]
169+
public RenderFragment? BusyTemplate { get; set; }
170+
171+
/// <summary>
172+
/// Gets or sets the content to display inside the button when it is not busy.
173+
/// This represents the normal, interactive state of the button.
174+
/// </summary>
175+
/// <value>
176+
/// A <see cref="RenderFragment"/> that defines the content to render when the button is in its normal state.
177+
/// Can be <c>null</c> if the button should be empty when not busy.
178+
/// </value>
179+
[Parameter]
180+
public RenderFragment? ChildContent { get; set; }
181+
182+
/// <summary>
183+
/// Gets or sets additional attributes to be applied to the button element.
184+
/// This allows for full customization of the HTML button's appearance and behavior.
185+
/// </summary>
186+
/// <value>
187+
/// A dictionary containing HTML attribute names and their corresponding values.
188+
/// Defaults to an empty dictionary.
189+
/// </value>
190+
[Parameter(CaptureUnmatchedValues = true)]
191+
public Dictionary<string, object> AdditionalAttributes { get; set; } = [];
192+
193+
/// <summary>
194+
/// Gets or sets the event callback to trigger when the button is clicked.
195+
/// The button automatically manages busy state during callback execution.
196+
/// </summary>
197+
/// <value>
198+
/// An <see cref="EventCallback"/> that is invoked when the button is clicked.
199+
/// Can be empty if the button should not perform any action.
200+
/// </value>
201+
/// <remarks>
202+
/// <para>
203+
/// This parameter defines the action to be performed when the user clicks the button.
204+
/// The callback can be synchronous or asynchronous, and the component automatically
205+
/// manages the busy state during execution.
206+
/// </para>
207+
/// </remarks>
208+
[Parameter]
209+
public EventCallback Trigger { get; set; }
210+
211+
/// <summary>
212+
/// Gets or sets a value indicating whether buttons should be hidden when the user
213+
/// lacks the required permissions instead of being displayed in a disabled state.
214+
/// </summary>
215+
/// <value>
216+
/// <c>true</c> to hide the button when permission is denied; <c>false</c> to show
217+
/// the button in a disabled state. Defaults to <c>false</c>.
218+
/// </value>
219+
[Parameter]
220+
public bool HideForbidden { get; set; }
221+
222+
/// <summary>
223+
/// Gets a value indicating whether the button is currently executing the <see cref="Trigger"/> callback.
224+
/// This is an internal state managed automatically by the component.
225+
/// </summary>
226+
/// <value>
227+
/// <c>true</c> if the trigger callback is currently executing; otherwise, <c>false</c>.
228+
/// </value>
229+
private bool Executing { get; set; }
230+
231+
/// <summary>
232+
/// Gets a value indicating whether the user has permission to access this button
233+
/// based on the specified <see cref="Action"/>, <see cref="Subject"/>, and optional <see cref="Qualifier"/>.
234+
/// </summary>
235+
/// <value>
236+
/// <c>true</c> if the user has the required permission for the specified parameters;
237+
/// otherwise, <c>false</c>.
238+
/// </value>
239+
protected bool HasPermission { get; set; }
240+
241+
/// <summary>
242+
/// Gets a value indicating whether the button is currently in a busy state.
243+
/// This combines both external busy state and internal execution state.
244+
/// </summary>
245+
/// <value>
246+
/// <c>true</c> if the button is busy due to the <see cref="Busy"/> parameter being <c>true</c>
247+
/// or the <see cref="Trigger"/> callback is currently executing; otherwise, <c>false</c>.
248+
/// </value>
249+
protected bool IsBusy => Busy || Executing;
250+
251+
/// <summary>
252+
/// Builds the render tree for the PrivilegeButton component, creating the button element
253+
/// with appropriate attributes, event handlers, and content based on the current state.
254+
/// </summary>
255+
/// <param name="builder">The <see cref="RenderTreeBuilder"/> used to build the component's render tree.</param>
256+
protected override void BuildRenderTree(RenderTreeBuilder builder)
257+
{
258+
// Do not render if the user does not have permission and HideForbidden is true
259+
if (HideForbidden && !HasPermission)
260+
return;
261+
262+
builder.OpenElement(0, "button");
263+
builder.AddMultipleAttributes(1, AdditionalAttributes);
264+
builder.AddAttribute(2, "disabled", Disabled || IsBusy || !HasPermission);
265+
266+
if (Trigger.HasDelegate)
267+
{
268+
builder.AddAttribute(3, "type", "button");
269+
builder.AddAttribute(4, "onclick", EventCallback.Factory.Create<MouseEventArgs>(this, ExecuteTrigger));
270+
}
271+
272+
if (IsBusy && BusyTemplate != null)
273+
builder.AddContent(5, BusyTemplate);
274+
else
275+
builder.AddContent(6, ChildContent);
276+
277+
builder.CloseElement(); // button
278+
}
279+
280+
/// <summary>
281+
/// Called when the component's parameters are set or updated. This method validates the component
282+
/// configuration, evaluates user privileges, and initializes default templates.
283+
/// </summary>
284+
/// <exception cref="InvalidOperationException">
285+
/// Thrown if the required <see cref="PrivilegeContext"/> cascading parameter is not provided or is <c>null</c>.
286+
/// </exception>
287+
protected override void OnParametersSet()
288+
{
289+
base.OnParametersSet();
290+
291+
if (PrivilegeContext == null)
292+
throw new InvalidOperationException("Component requires a cascading parameter of type PrivilegeContext.");
293+
294+
BusyTemplate ??= builder => builder.AddContent(0, BusyText);
295+
296+
HasPermission = PrivilegeContext.Allowed(Action, Subject, Qualifier);
297+
}
298+
299+
/// <summary>
300+
/// Executes the <see cref="Trigger"/> callback and manages the busy state during execution.
301+
/// This method provides automatic busy state management and exception handling.
302+
/// </summary>
303+
/// <returns>
304+
/// A <see cref="Task"/> representing the asynchronous operation.
305+
/// </returns>
306+
private async Task ExecuteTrigger()
307+
{
308+
if (!Trigger.HasDelegate)
309+
return;
310+
311+
try
312+
{
313+
Executing = true;
314+
await Trigger.InvokeAsync();
315+
}
316+
finally
317+
{
318+
Executing = false;
319+
}
320+
}
321+
}

0 commit comments

Comments
 (0)