Deepen PracticeOrdered learning track

Permission-Aware Components

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 101

Permission-aware components as capability-driven UI boundaries: visibility, disabled affordance, auditability, command gating, and the hard security boundary between frontend hints and backend authorization.

7 min read1305 words
PrevNext
Lesson 101123 lesson track68–101 Deepen Practice
#react#authorization#permissions#component-architecture+3 more

Part 101 — Permission-Aware Components

Permission-aware component bukan komponen yang "mengamankan" aplikasi.

Permission-aware component adalah UI boundary yang membuat aplikasi:

  1. tidak menampilkan aksi yang tidak relevan,
  2. menjelaskan kenapa aksi tidak tersedia,
  3. tidak memanggil command yang tidak boleh dijalankan,
  4. tetap menjaga accessibility,
  5. tetap bisa diaudit,
  6. tetap memindahkan keputusan security final ke backend.

Kalimat paling penting:

Frontend permission adalah UX and intent guard. Backend authorization adalah security guard.

Jika tombol Delete Case disembunyikan di UI tetapi endpoint DELETE /cases/:id tidak mengecek authorization, sistem tetap vulnerable.

Permission-aware UI harus membantu user dan mengurangi accidental misuse, tetapi tidak boleh menjadi satu-satunya tempat enforcement.


1. Masalah Nyata yang Ingin Diselesaikan

Di aplikasi enterprise, regulatory system, case management, dan workflow approval, permission bukan sekadar:

{role === "admin" && <Button>Delete</Button>}

Itu terlalu dangkal.

Permission nyata biasanya bergantung pada kombinasi:

subject/user
+ role
+ permission/capability
+ resource
+ resource state
+ ownership
+ assignment
+ tenant
+ workflow step
+ legal hold
+ risk flag
+ time window
+ feature flag
+ delegation
+ policy version

Contoh:

User boleh approve enforcement action jika:
- user punya capability enforcement.approve
- case berada di tenant yang sama
- case.status == READY_FOR_APPROVAL
- user bukan drafter action tersebut
- action tidak sedang under legal hold
- approval window belum expired
- user punya current delegation jika acting on behalf

Maka permission-aware component harus bisa bekerja dengan capability, bukan hanya role string.


2. Permission UI Decision Is Not Binary

Permission sering dianggap boolean:

canDelete: true | false

Untuk UI production, itu kurang.

UI butuh menjawab:

can the user perform this action?
why or why not?
should the action be hidden, disabled, or visible with warning?
is the denial final or recoverable?
can user request access?
should attempt be logged?
should command still be submitted and let backend decide?

Model yang lebih kuat:

type PermissionDecision =
  | {
      allowed: true;
      capability: string;
      reason?: undefined;
      evidence?: PermissionEvidence;
    }
  | {
      allowed: false;
      capability: string;
      reason: DenialReason;
      explanation: string;
      recoverable: boolean;
      visibility: "hidden" | "disabled" | "readonly";
      evidence?: PermissionEvidence;
    };

type DenialReason =
  | "missing_capability"
  | "resource_state"
  | "ownership_conflict"
  | "tenant_boundary"
  | "workflow_step"
  | "legal_hold"
  | "feature_disabled"
  | "unknown";

Ini membuat UI lebih defensible.

Tombol tidak hilang secara misterius. Sistem bisa menjelaskan:

Approve is unavailable because the case is still in Draft.

atau:

Delete is unavailable because this case is under legal hold.

3. Mental Model: Permission-Aware UI sebagai Capability Boundary

Ada dua lapisan:

  1. UI permission decision: menentukan affordance dan command intent.
  2. Server authorization: menentukan apakah command benar-benar boleh terjadi.

UI tidak boleh menganggap dirinya authoritative.


4. Role, Permission, Capability, Policy

Istilah ini sering dicampur. Pisahkan dengan jelas.

KonsepArtiContohCocok untuk UI?
RolePaket tanggung jawabcase_manager, supervisorKadang, tapi kasar
PermissionHak spesifikcase.read, case.updateYa
CapabilityPermission + konteks siap pakaicanApprove(case)Sangat cocok
PolicyAturan evaluasisubject-resource-action-contextBiasanya di backend/policy layer
DecisionHasil evaluasiallowed/denied + reasonSangat cocok untuk UI

UI component sebaiknya bergantung pada capability/decision, bukan role mentah.

Bad:

{user.role === "supervisor" && <ApproveButton />}

Better:

<PermissionGate capability="case.approve" resource={caseRecord}>
  <ApproveButton />
</PermissionGate>

Best untuk complex UI:

const approve = usePermission("case.approve", caseRecord);

<ApproveButton
  disabled={!approve.allowed}
  disabledReason={!approve.allowed ? approve.explanation : undefined}
  onApprove={approve.allowed ? approveCase : undefined}
/>

5. Permission Decision Shape

Permission decision harus cukup kaya untuk UI.

type Capability =
  | "case.read"
  | "case.update"
  | "case.assign"
  | "case.approve"
  | "case.close"
  | "case.delete"
  | "evidence.upload"
  | "evidence.delete"
  | "comment.internal.create";

type PermissionContext = {
  userId: string;
  tenantId: string;
  roles: string[];
  capabilities: Capability[];
  delegation?: {
    principalUserId: string;
    delegatedBy: string;
    expiresAt: string;
  };
};

type ResourceContext = {
  id: string;
  tenantId: string;
  ownerId?: string;
  assigneeId?: string;
  status?: string;
  legalHold?: boolean;
  lockedBy?: string | null;
};

type PermissionDecision = {
  allowed: boolean;
  capability: Capability;
  reason?: string;
  explanation?: string;
  visibility?: "visible" | "hidden" | "disabled" | "readonly";
  evidence?: Record<string, unknown>;
};

Decision harus pure terhadap input:

function canApproveCase(
  subject: PermissionContext,
  resource: ResourceContext
): PermissionDecision {
  if (!subject.capabilities.includes("case.approve")) {
    return {
      allowed: false,
      capability: "case.approve",
      reason: "missing_capability",
      explanation: "You do not have permission to approve cases.",
      visibility: "hidden",
    };
  }

  if (subject.tenantId !== resource.tenantId) {
    return {
      allowed: false,
      capability: "case.approve",
      reason: "tenant_boundary",
      explanation: "This case belongs to a different tenant.",
      visibility: "hidden",
    };
  }

  if (resource.status !== "READY_FOR_APPROVAL") {
    return {
      allowed: false,
      capability: "case.approve",
      reason: "resource_state",
      explanation: "Only cases ready for approval can be approved.",
      visibility: "disabled",
    };
  }

  if (resource.legalHold) {
    return {
      allowed: false,
      capability: "case.approve",
      reason: "legal_hold",
      explanation: "This case is under legal hold.",
      visibility: "disabled",
    };
  }

  return {
    allowed: true,
    capability: "case.approve",
    visibility: "visible",
  };
}

Perhatikan: evaluator ini hanya untuk UI decision. Server tetap harus mengecek ulang.


6. Provider Design untuk Permission

Permission context harus membawa policy snapshot/capability service, bukan semua object user global yang bocor ke mana-mana.

import { createContext, useContext, useMemo } from "react";

type PermissionService = {
  decide: (capability: Capability, resource?: ResourceContext) => PermissionDecision;
};

const PermissionContext = createContext<PermissionService | null>(null);

export function PermissionProvider({
  subject,
  children,
}: {
  subject: PermissionContext;
  children: React.ReactNode;
}) {
  const service = useMemo<PermissionService>(() => {
    return {
      decide(capability, resource) {
        return decidePermission(subject, capability, resource);
      },
    };
  }, [subject]);

  return (
    <PermissionContext.Provider value={service}>
      {children}
    </PermissionContext.Provider>
  );
}

export function usePermission(
  capability: Capability,
  resource?: ResourceContext
): PermissionDecision {
  const service = useContext(PermissionContext);

  if (!service) {
    throw new Error("usePermission must be used inside PermissionProvider");
  }

  return service.decide(capability, resource);
}

Namun hati-hati: jika subject object berubah identity setiap render, semua consumer context akan rerender.

Better:

const subject = useMemo(
  () => ({
    userId: session.userId,
    tenantId: session.tenantId,
    roles: session.roles,
    capabilities: session.capabilities,
    delegation: session.delegation,
  }),
  [
    session.userId,
    session.tenantId,
    session.roles,
    session.capabilities,
    session.delegation,
  ]
);

Untuk permission yang sering berubah atau banyak resource row, jangan paksa semua lewat Context. Pakai selector/external store atau precomputed decision map.


7. PermissionGate Pattern

7.1 Basic Gate

function PermissionGate({
  capability,
  resource,
  children,
  fallback = null,
}: {
  capability: Capability;
  resource?: ResourceContext;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const decision = usePermission(capability, resource);

  if (!decision.allowed) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

Usage:

<PermissionGate capability="case.delete" resource={caseRecord}>
  <DeleteCaseButton caseId={caseRecord.id} />
</PermissionGate>

Ini cocok untuk fitur yang benar-benar tidak relevan bila user tidak punya capability.

Tapi banyak aksi lebih baik disabled with explanation, bukan hidden.


7.2 Render Decision Pattern

function PermissionDecisionView({
  capability,
  resource,
  children,
}: {
  capability: Capability;
  resource?: ResourceContext;
  children: (decision: PermissionDecision) => React.ReactNode;
}) {
  const decision = usePermission(capability, resource);
  return <>{children(decision)}</>;
}

Usage:

<PermissionDecisionView capability="case.approve" resource={caseRecord}>
  {(decision) => (
    <Button
      disabled={!decision.allowed}
      title={!decision.allowed ? decision.explanation : undefined}
      onClick={decision.allowed ? onApprove : undefined}
    >
      Approve
    </Button>
  )}
</PermissionDecisionView>

Kelebihan:

  • UI bisa memilih hidden/disabled/readonly.
  • Reason dapat ditampilkan.
  • Button tetap visible untuk discoverability.
  • Test bisa assert denial reason.

7.3 Permission-Aware Button

Untuk design system, buat primitive yang sadar permission tetapi tidak tahu domain terlalu dalam.

type PermissionButtonProps = {
  capability: Capability;
  resource?: ResourceContext;
  onAllowedClick: () => void | Promise<void>;
  children: React.ReactNode;
  deniedMode?: "hide" | "disable";
};

function PermissionButton({
  capability,
  resource,
  onAllowedClick,
  children,
  deniedMode = "disable",
}: PermissionButtonProps) {
  const decision = usePermission(capability, resource);

  if (!decision.allowed && deniedMode === "hide") {
    return null;
  }

  return (
    <button
      type="button"
      disabled={!decision.allowed}
      aria-describedby={!decision.allowed ? `${capability}-reason` : undefined}
      onClick={() => {
        if (!decision.allowed) return;
        void onAllowedClick();
      }}
    >
      {children}
      {!decision.allowed ? (
        <span id={`${capability}-reason`} hidden>
          {decision.explanation ?? "Action unavailable"}
        </span>
      ) : null}
    </button>
  );
}

Catatan penting:

  • onClick tetap guard meski button disabled.
  • disabled native button menghapus dari tab order.
  • Jika ingin tetap focusable untuk menjelaskan reason, gunakan pattern lain dengan aria-disabled dan manual event guard.

8. Hidden vs Disabled vs Readonly

Tidak semua denial harus disembunyikan.

ModeGunakan ketikaContoh
HiddenUser tidak perlu tahu aksi adaInternal admin tool action
DisabledUser perlu tahu aksi ada tapi belum tersediaApprove sebelum case ready
ReadonlyUser boleh melihat tapi tidak boleh editClosed case, archived record
Visible with warningAksi boleh, tapi high-riskDelete with confirmation
Request accessDenial recoverableNeed supervisor grant

Decision rule:

function choosePermissionPresentation(decision: PermissionDecision) {
  if (decision.allowed) return "enabled";

  switch (decision.reason) {
    case "missing_capability":
    case "tenant_boundary":
      return "hidden";

    case "resource_state":
    case "workflow_step":
    case "legal_hold":
      return "disabled-with-reason";

    case "feature_disabled":
      return "hidden";

    default:
      return "disabled-with-reason";
  }
}

Jangan pakai hidden untuk semua denial. Hidden-only UI membuat user bingung dan membuat support/debugging lebih sulit.


9. Accessibility of Disabled Permission UI

Native disabled cocok untuk control yang benar-benar tidak operable. Tetapi native disabled controls biasanya tidak focusable, sehingga user keyboard/screen reader mungkin tidak mudah menemukan alasan.

Jika reason penting, gunakan salah satu:

Option A — Disabled button + nearby explanation

<div>
  <button type="button" disabled>
    Approve
  </button>
  <p id="approve-reason">Only cases ready for approval can be approved.</p>
</div>

Option B — aria-disabled + manual guard

<button
  type="button"
  aria-disabled={!decision.allowed}
  onClick={(event) => {
    if (!decision.allowed) {
      event.preventDefault();
      return;
    }

    onApprove();
  }}
>
  Approve
</button>

Jika memakai aria-disabled, component tetap harus mencegah action secara manual. aria-disabled hanya menyampaikan state ke assistive technology; ia tidak otomatis memblokir interaction.


10. Permission-Aware Route Boundary

Route guard di frontend bukan security boundary, tetapi tetap berguna untuk UX.

function RequireCapability({
  capability,
  children,
}: {
  capability: Capability;
  children: React.ReactNode;
}) {
  const decision = usePermission(capability);

  if (!decision.allowed) {
    return (
      <AccessDenied
        title="You do not have access to this page"
        description={decision.explanation ?? "This page requires additional permission."}
      />
    );
  }

  return <>{children}</>;
}

Usage:

<Route
  path="/cases/:caseId/approval"
  element={
    <RequireCapability capability="case.approve">
      <CaseApprovalPage />
    </RequireCapability>
  }
/>

Tetap lakukan backend authorization untuk:

GET /cases/:id/approval-data
POST /cases/:id/approve

Kalau frontend route guard lolos tetapi backend menolak, tampilkan state yang eksplisit:

<AuthorizationErrorState
  title="Approval unavailable"
  description="Your permission changed or this case is no longer approvable."
/>

11. Permission-Aware Menus and Bulk Actions

Menu action sering lebih kompleks karena setiap item bisa punya capability berbeda.

type ActionItem = {
  id: string;
  label: string;
  capability: Capability;
  danger?: boolean;
  run: () => void;
};
function CaseActionMenu({ caseRecord }: { caseRecord: ResourceContext }) {
  const actions: ActionItem[] = [
    {
      id: "assign",
      label: "Assign",
      capability: "case.assign",
      run: () => openAssignModal(caseRecord.id),
    },
    {
      id: "approve",
      label: "Approve",
      capability: "case.approve",
      run: () => openApproveModal(caseRecord.id),
    },
    {
      id: "delete",
      label: "Delete",
      capability: "case.delete",
      danger: true,
      run: () => openDeleteModal(caseRecord.id),
    },
  ];

  return (
    <Menu>
      {actions.map((action) => (
        <PermissionDecisionView
          key={action.id}
          capability={action.capability}
          resource={caseRecord}
        >
          {(decision) => {
            const presentation = choosePermissionPresentation(decision);

            if (presentation === "hidden") return null;

            return (
              <MenuItem
                disabled={!decision.allowed}
                title={!decision.allowed ? decision.explanation : undefined}
                onSelect={() => {
                  if (!decision.allowed) return;
                  action.run();
                }}
              >
                {action.label}
              </MenuItem>
            );
          }}
        </PermissionDecisionView>
      ))}
    </Menu>
  );
}

Bulk action lebih sulit.

Jika user memilih 30 records, permission decision mungkin beda per record.

type BulkPermissionDecision = {
  allowedIds: string[];
  denied: Array<{
    id: string;
    reason: string;
    explanation: string;
  }>;
  partiallyAllowed: boolean;
};

UI harus jelas:

Approve selected
- 18 cases can be approved
- 12 cases cannot be approved because they are not ready

Jangan membuat bulk action silently skip denied items tanpa penjelasan.


12. Permission and Server State

Permission decision sering bergantung pada resource state dari server.

Masalah:

UI menampilkan Approve enabled
user click Approve
backend menolak karena case baru saja berubah status

Ini normal di distributed system.

UI harus punya mekanisme recovery:

async function approveCase(caseId: string) {
  try {
    await api.approveCase(caseId);
    queryClient.invalidateQueries({ queryKey: ["case", caseId] });
  } catch (error) {
    if (isAuthorizationError(error)) {
      queryClient.invalidateQueries({ queryKey: ["case", caseId] });
      showToast("Approval is no longer available for this case.");
      return;
    }

    throw error;
  }
}

Jangan treat authorization error sebagai impossible hanya karena UI sebelumnya menampilkan button enabled.

Permission snapshot bisa stale.


13. Permission and Auditability

Regulatory UI sering perlu menjelaskan:

Why was the action available?
Why was it denied?
Who attempted it?
What was the resource state?
Which policy version applied?

Untuk UI, minimal log event:

type PermissionUiEvent = {
  type: "permission_denied_visible" | "permission_denied_click_attempt";
  capability: Capability;
  resourceId?: string;
  reason?: string;
  policyVersion?: string;
};

Jangan log sensitive resource detail sembarangan ke analytics pihak ketiga. Audit/security log harus sesuai data classification.


14. Permission-Aware Forms

Field-level permission sering muncul.

Contoh:

case.title: editable by drafter while Draft
case.priority: editable by supervisor while Open
case.legalHold: editable by legal officer only
case.outcome: editable only during Resolution step

Model field permission:

type FieldPermission = {
  field: string;
  read: PermissionDecision;
  update: PermissionDecision;
};

Component:

function PermissionField({
  field,
  resource,
  label,
  value,
  onChange,
}: {
  field: string;
  resource: ResourceContext;
  label: string;
  value: string;
  onChange: (value: string) => void;
}) {
  const read = usePermission(`case.${field}.read` as Capability, resource);
  const update = usePermission(`case.${field}.update` as Capability, resource);

  if (!read.allowed) return null;

  return (
    <label>
      {label}
      <input
        value={value}
        readOnly={!update.allowed}
        aria-readonly={!update.allowed}
        onChange={(event) => {
          if (!update.allowed) return;
          onChange(event.target.value);
        }}
      />
      {!update.allowed ? <small>{update.explanation}</small> : null}
    </label>
  );
}

Field permission harus sinkron dengan backend validation. Jika backend menolak field update, tampilkan field-level error.


15. Anti-Pattern: Permission Logic in JSX Everywhere

Bad:

{user.role === "admin" ||
(user.role === "manager" && caseRecord.status !== "closed") ? (
  <Button onClick={approve}>Approve</Button>
) : null}

Masalah:

  • Rule tersebar.
  • Sulit dites.
  • Sulit audit.
  • Sulit menjelaskan denial.
  • Mudah beda dengan backend.

Better:

const approve = usePermission("case.approve", caseRecord);

return (
  <ApproveButton
    disabled={!approve.allowed}
    reason={approve.explanation}
    onApprove={approve.allowed ? approveCase : undefined}
  />
);

Best untuk use-case kompleks:

const approval = useCaseApprovalCapability(caseRecord.id);

return (
  <ApprovalPanel
    caseRecord={approval.caseRecord}
    approveDecision={approval.approveDecision}
    requestApproval={approval.requestApproval}
  />
);

16. Domain Hook Pattern

function useCaseApprovalCapability(caseId: string) {
  const { data: caseRecord } = useCaseQuery(caseId);
  const approveDecision = usePermission("case.approve", caseRecord);
  const mutation = useApproveCaseMutation(caseId);

  return {
    caseRecord,
    approveDecision,
    approve: async () => {
      if (!approveDecision.allowed) {
        logPermissionDeniedAttempt({
          capability: "case.approve",
          resourceId: caseId,
          reason: approveDecision.reason,
        });
        return;
      }

      await mutation.mutateAsync({ caseId });
    },
    isApproving: mutation.isPending,
  };
}

Keuntungan:

  • View tidak tahu detail policy.
  • Command guard terpusat.
  • Authorization error handling terpusat.
  • Audit event mudah ditambahkan.
  • Testing lebih sederhana.

17. Permission Decision from Server vs Client

Ada tiga pendekatan.

17.1 Client-evaluated

Server mengirim roles/capabilities/resource. UI mengevaluasi.

Cocok untuk:

  • simple permission,
  • fast UI affordance,
  • low-risk presentation logic.

Risiko:

  • policy drift,
  • stale rules,
  • rule duplication.

17.2 Server-provided decision

Server mengirim:

{
  "case": { "id": "C-123", "status": "READY_FOR_APPROVAL" },
  "permissions": {
    "case.approve": {
      "allowed": true
    },
    "case.delete": {
      "allowed": false,
      "reason": "legal_hold",
      "explanation": "This case is under legal hold."
    }
  }
}

Cocok untuk:

  • complex policy,
  • auditability,
  • policy consistency,
  • regulated flows.

Risiko:

  • larger payload,
  • tighter API/UI coupling,
  • need invalidation when resource changes.

17.3 Hybrid

Server mengirim coarse capability + resource flags, client membuat UX decision.

Cocok untuk banyak aplikasi enterprise.

Rule:

Server decides security.
Client decides presentation.

18. Permission-Aware Component Testing

Test bukan hanya allowed/denied.

Test matrix:

ScenarioExpected
Has capability + valid resource stateButton enabled
Missing capabilityButton hidden/disabled sesuai policy
Invalid workflow stateButton disabled with reason
Legal holdButton disabled with legal hold reason
Button clicked while deniedCommand not called
Backend rejects after allowed UIError state and cache refresh
Permission provider missingStrict hook throws
Keyboard navigationDisabled/readonly affordance understandable

Example:

it("does not call approve command when permission is denied", async () => {
  const approve = vi.fn();

  render(
    <PermissionProvider subject={subjectWithoutApprove}>
      <PermissionButton
        capability="case.approve"
        resource={draftCase}
        onAllowedClick={approve}
      >
        Approve
      </PermissionButton>
    </PermissionProvider>
  );

  await user.click(screen.getByRole("button", { name: /approve/i }));

  expect(approve).not.toHaveBeenCalled();
});

19. Failure Modes

19.1 Frontend-only authorization

Symptom:

Button hidden, but endpoint accepts direct request.

Fix:

Backend authorizes every command/query.

19.2 Role hardcoding

Symptom:

user.role === "admin"

spread across hundreds of files.

Fix:

Move to capability decision layer.

19.3 Hidden everything

Symptom:

User does not know why action is unavailable.
Support tickets increase.

Fix:

Use disabled/read-only with reason when action is contextually unavailable.

19.4 Permission drift

Symptom:

UI says allowed, backend denies.

Fix:

Treat authorization error as normal distributed-state conflict.
Refresh resource and permission snapshot.

19.5 Context fan-out

Symptom:

Updating session/permission context rerenders entire app.

Fix:

Split context, memoize value, use scoped provider, or move high-cardinality decisions to store/query cache.

19.6 Field-level permission mismatch

Symptom:

Field editable in UI, server rejects field update.

Fix:

Use server-provided field decision or shared policy contract.

20. Production Checklist

Use this checklist before approving a permission-aware UI design.

[ ] Does every sensitive backend command enforce authorization server-side?
[ ] Does UI depend on capability/decision instead of raw role strings?
[ ] Does denied UI explain why when appropriate?
[ ] Are hidden/disabled/readonly decisions intentional?
[ ] Are permission decisions testable as pure functions?
[ ] Are command handlers guarded even if UI is disabled?
[ ] Is authorization error handled after stale UI decision?
[ ] Are permission snapshots invalidated when resource/session changes?
[ ] Is field-level permission aligned with backend validation?
[ ] Is audit/telemetry safe and not leaking sensitive details?
[ ] Is accessibility preserved for disabled/readonly controls?
[ ] Is context value stable enough to avoid app-wide rerender?

21. Key Takeaways

Permission-aware component yang kuat tidak bertanya:

Is this user admin?

Ia bertanya:

Given this subject, capability, resource, workflow state, and policy snapshot,
what should the UI allow, show, disable, explain, audit, and submit?

Aturan akhirnya:

Frontend permission improves UX, clarity, and intent safety.
Backend authorization protects the system.

Keduanya harus ada. Jangan pilih salah satu.


References

Lesson Recap

You just completed lesson 101 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.