diff --git a/.github/workflows/portal-frontend-ci.yml b/.github/workflows/portal-frontend-ci.yml
index 3a5851d6..54e4e35e 100644
--- a/.github/workflows/portal-frontend-ci.yml
+++ b/.github/workflows/portal-frontend-ci.yml
@@ -4,11 +4,13 @@ on:
pull_request:
paths:
- 'portal/frontend/**'
+ - 'portal/backend/openapi/portal-backend.yaml'
- '.github/workflows/portal-frontend-ci.yml'
push:
branches: [ main ]
paths:
- 'portal/frontend/**'
+ - 'portal/backend/openapi/portal-backend.yaml'
- '.github/workflows/portal-frontend-ci.yml'
jobs:
diff --git a/portal/backend/.env.example b/portal/backend/.env.example
index c95f6104..ab90aeb6 100644
--- a/portal/backend/.env.example
+++ b/portal/backend/.env.example
@@ -30,8 +30,9 @@ BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"]
# Placeholder identity mode for local development (must be false in production)
BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false
-BFF_PROXY__PLACEHOLDER_USER_ID=
-BFF_PROXY__PLACEHOLDER_ORG_ID=
+BFF_PROXY__PLACEHOLDER_USER_ID=user1@example.com
+BFF_PROXY__PLACEHOLDER_ORG_ID=ORG-001
+BFF_PROXY__PLACEHOLDER_SCOPES=portal:consents:read:self portal:consents:write:self
# OIDC confidential-client authentication
BFF_AUTH__ENABLED=false
@@ -42,7 +43,7 @@ BFF_AUTH__CLIENT_SECRET=
BFF_AUTH__PORTAL_URL=http://localhost:5173/consents
BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback
BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
-BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write
+BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:consents:read:any portal:consents:write:any portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write
BFF_AUTH__RESOURCE_AUDIENCE=
BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256
BFF_AUTH__HTTP_TIMEOUT=5s
@@ -50,6 +51,8 @@ BFF_AUTH__REFRESH_TIMEOUT=10s
BFF_AUTH__CLOCK_SKEW=30s
BFF_AUTH__SCOPE_CLAIM=scope
BFF_AUTH__ORG_ID_CLAIM=org_id
+# Overrides the organization claim when non-empty. Leave empty to use BFF_AUTH__ORG_ID_CLAIM.
+BFF_AUTH__ORG_ID_OVERRIDE=ORG-001
BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false
BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type
BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token
diff --git a/portal/backend/README.md b/portal/backend/README.md
index a7091320..f5ddf5b8 100644
--- a/portal/backend/README.md
+++ b/portal/backend/README.md
@@ -128,14 +128,11 @@ Common error codes:
- `BFF_PROXY__PLACEHOLDER_MODE_ENABLED=true` is blocked when `BFF_ENV=production`
- `BFF_PROXY__PLACEHOLDER_USER_ID` must be empty if placeholder mode is disabled
- `BFF_PROXY__PLACEHOLDER_ORG_ID` must be empty if placeholder mode is disabled
+- `BFF_PROXY__PLACEHOLDER_SCOPES` is a space-separated list of portal scope. Must be empty if placeholder mode is disabled
+- Empty placeholder scopes are allowed and produce an authenticated principal with no portal permissions
## AI Instructions
-This repository uses VS Code Copilot instruction files to keep AI-generated changes aligned with project and organization standards.
+This repository uses AGENTS.md files to keep AI-generated changes aligned with project and organization standards.
- Backend standards: `portal/backend/AGENTS.md`
-- Copilot workspace entrypoint (repo root): `.github/copilot-instructions.md`
-- Scoped instructions folder (repo root): `.github/instructions/`
-- Backend scope mapping: `portal/backend/**` -> `.github/instructions/portal-backend.instructions.md`
-
-Copilot instructions are discovered automatically and scoped using `applyTo` patterns.
diff --git a/portal/backend/docs/README.md b/portal/backend/docs/README.md
index f1db9eee..17722d8d 100644
--- a/portal/backend/docs/README.md
+++ b/portal/backend/docs/README.md
@@ -32,7 +32,7 @@ React frontend ─────► Portal Backend ─────► OpenFGC
| Component | Responsibility |
| --- | --- |
-| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, and renders identity claims for display. It does not make authorization decisions. |
+| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, renders ID-token claims for display, and uses the effective scopes returned by `GET /me` for route and element visibility. These UI decisions improve the user experience; Portal Backend scope enforcement remains the security boundary. |
| Portal Backend | Acts as the OIDC confidential client and protected API boundary. It validates tokens, creates the trusted principal, enforces route policy, and maps allowed requests to OpenFGC. |
| Identity provider | Authenticates users, exposes OIDC discovery and JWKS metadata, and issues signed ID, access, and refresh tokens. |
| OpenFGC | Performs consent operations behind the Portal Backend. It receives identity and organization context derived by the Portal Backend. |
@@ -87,6 +87,9 @@ The frontend deduplicates concurrent refresh attempts within the current browser
| `GET /health`, `/health/liveness`, `/health/readiness` | Public |
| `GET /auth/login`, `GET /auth/callback`, `POST /auth/refresh` | Public protocol endpoints; login and refresh still require their corresponding split transaction or token material. |
| `POST /auth/logout` | Authenticated |
+| `GET /me` | Authenticated without a business-scope requirement; returns the validated user ID, organization ID, and effective portal scopes. |
+
+`GET /me` returns only scopes in the canonical portal registry, preserving registry order. OIDC scopes such as `openid`, `profile`, and `email` are excluded. An authenticated principal without portal permissions receives `200 OK` with an empty `scopes` array.
### Current-user consent routes
@@ -131,9 +134,11 @@ Configuration is grouped into:
- `BFF_LOG__*`: log level.
- `BFF_CORS__*`: exact browser origins, methods, headers, and credential support.
- `BFF_AUTH__*`: OIDC client, token validation, claims, timeouts, cookies, and token size limits.
-- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity.
+- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity and scopes.
+
+Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies the configured user and organization without an identity provider. `BFF_PROXY__PLACEHOLDER_SCOPES` is a space-separated list of canonical portal scopes granted to that principal. Unknown or duplicate scopes are rejected, and an empty list creates an authenticated principal without portal permissions. Placeholder identity and scope values must be empty when placeholder mode is disabled.
-Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies a configured user and organization with all portal scopes for development without an identity provider.
+For authenticated deployments, the organization normally comes from the access-token claim named by `BFF_AUTH__ORG_ID_CLAIM`. Set `BFF_AUTH__ORG_ID_OVERRIDE` to force a fixed organization ID instead; a non-empty override takes precedence and the token does not need the organization claim. Leave the override empty to keep claim-based resolution.
### Local topology
@@ -150,6 +155,6 @@ The backend Docker Compose file builds and publishes the Portal Backend on port
## Testing
-Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the auth client, refresh retry behavior, profile rendering, API integration, and production CSP generation.
+Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, the `GET /me` session response, placeholder-scope filtering, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the `/me` session gate, scope-based routes and elements, sidebar visibility, authorization redirects, auth-client refresh behavior, profile rendering, API integration, and production CSP generation. A frontend test parses the backend OpenAPI document and detects missing, extra, empty, or duplicate portal scopes relative to the centralized `PORTAL_SCOPES` registry.
Use the commands in the [backend README](../README.md) and [frontend README](../../frontend/README.md) to run their respective checks. Changes to routes or scopes must also update and validate the [OpenAPI specification](../openapi/portal-backend.yaml).
diff --git a/portal/backend/internal/me/handler.go b/portal/backend/internal/me/handler.go
index bfea6921..151b1d98 100644
--- a/portal/backend/internal/me/handler.go
+++ b/portal/backend/internal/me/handler.go
@@ -29,6 +29,7 @@ import (
"strings"
"github.com/wso2/openfgc/portal/backend/internal/proxy"
+ "github.com/wso2/openfgc/portal/backend/internal/system/auth"
"github.com/wso2/openfgc/portal/backend/internal/system/config"
systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
)
@@ -46,6 +47,12 @@ type errorResponse struct {
Message string `json:"message"`
}
+type currentUserResponse struct {
+ UserID string `json:"userId"`
+ OrganizationID string `json:"organizationId"`
+ Scopes []string `json:"scopes"`
+}
+
var errRequestBodyTooLarge = errors.New("request body too large")
var consentIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
@@ -58,6 +65,24 @@ func NewHandler(cfg config.ProxyConfig) (*Handler, error) {
return &Handler{svc: svc, cfg: cfg}, nil
}
+// CurrentUser handles GET /me.
+func (h *Handler) CurrentUser(w http.ResponseWriter, r *http.Request) {
+ principal, ok := systemcontext.PrincipalFromContext(r.Context())
+ if !ok {
+ writeJSONError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
+ return
+ }
+
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(currentUserResponse{
+ UserID: principal.UserID,
+ OrganizationID: principal.OrgID,
+ Scopes: auth.GrantedPortalScopes(principal.Scopes),
+ })
+}
+
// Consents handles GET /me/consents.
func (h *Handler) Consents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
diff --git a/portal/backend/internal/me/handler_test.go b/portal/backend/internal/me/handler_test.go
new file mode 100644
index 00000000..d9dc89ef
--- /dev/null
+++ b/portal/backend/internal/me/handler_test.go
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package me
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/wso2/openfgc/portal/backend/internal/system/auth"
+ systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
+)
+
+func TestCurrentUserReturnsCanonicalPortalScopes(t *testing.T) {
+ principal := systemcontext.Principal{
+ UserID: "user-1",
+ OrgID: "org-1",
+ Scopes: map[string]struct{}{
+ "openid": {},
+ auth.ScopePurposesWrite: {},
+ auth.ScopeConsentsReadSelf: {},
+ },
+ }
+ req := httptest.NewRequest(http.MethodGet, "/me", nil)
+ req = req.WithContext(systemcontext.WithPrincipal(req.Context(), principal))
+ recorder := httptest.NewRecorder()
+
+ (&Handler{}).CurrentUser(recorder, req)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
+ }
+ if got := recorder.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("Cache-Control = %q, want no-store", got)
+ }
+ var response currentUserResponse
+ if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response.UserID != "user-1" || response.OrganizationID != "org-1" {
+ t.Fatalf("unexpected identity: %+v", response)
+ }
+ wantScopes := []string{auth.ScopeConsentsReadSelf, auth.ScopePurposesWrite}
+ if !reflect.DeepEqual(response.Scopes, wantScopes) {
+ t.Fatalf("scopes = %v, want %v", response.Scopes, wantScopes)
+ }
+}
+
+func TestCurrentUserRequiresPrincipal(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ (&Handler{}).CurrentUser(recorder, httptest.NewRequest(http.MethodGet, "/me", nil))
+
+ if recorder.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
+ }
+}
diff --git a/portal/backend/internal/me/init.go b/portal/backend/internal/me/init.go
index 8b53bb85..116a3e07 100644
--- a/portal/backend/internal/me/init.go
+++ b/portal/backend/internal/me/init.go
@@ -32,6 +32,7 @@ func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager
return err
}
+ mux.Handle("GET /me", authManager.Require(http.HandlerFunc(handler.CurrentUser)))
mux.Handle("GET /me/consents", authManager.Require(http.HandlerFunc(handler.Consents), auth.ScopeConsentsReadSelf))
mux.Handle("GET /me/consents/{consentId}", authManager.Require(http.HandlerFunc(handler.ConsentByID), auth.ScopeConsentsReadSelf))
mux.Handle("POST /me/consents/{consentId}/approve", authManager.Require(http.HandlerFunc(handler.ConsentApprove), auth.ScopeConsentsWriteSelf))
diff --git a/portal/backend/internal/system/auth/handlers.go b/portal/backend/internal/system/auth/handlers.go
index fc8745c2..6fba9797 100644
--- a/portal/backend/internal/system/auth/handlers.go
+++ b/portal/backend/internal/system/auth/handlers.go
@@ -50,7 +50,11 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request) {
return
}
m.setLoginTransactionCookies(w, state, verifier)
- authorizationURL := m.oauthConfig.AuthCodeURL(state, pkceAuthorizationOption(verifier))
+ authorizationURL := m.oauthConfig.AuthCodeURL(
+ state,
+ pkceAuthorizationOption(verifier),
+ oauth2.SetAuthURLParam("audience", m.cfg.ResourceAudience),
+ )
http.Redirect(w, r, authorizationURL, http.StatusFound)
}
diff --git a/portal/backend/internal/system/auth/handlers_test.go b/portal/backend/internal/system/auth/handlers_test.go
index 9edba2c4..c5e842be 100644
--- a/portal/backend/internal/system/auth/handlers_test.go
+++ b/portal/backend/internal/system/auth/handlers_test.go
@@ -115,6 +115,9 @@ func TestOIDCLoginCallbackRefreshAndLogout(t *testing.T) {
}
state := authorizationURL.Query().Get("state")
expectedPKCEChallenge = authorizationURL.Query().Get("code_challenge")
+ if audience := authorizationURL.Query().Get("audience"); audience != cfg.ResourceAudience {
+ t.Fatalf("unexpected authorization audience: %q", audience)
+ }
if state == "" || expectedPKCEChallenge == "" || authorizationURL.Query().Get("code_challenge_method") != "S256" {
t.Fatalf("login redirect missing state or S256 PKCE parameters: %s", authorizationURL.RawQuery)
}
diff --git a/portal/backend/internal/system/auth/manager.go b/portal/backend/internal/system/auth/manager.go
index 5db7d00e..b87387e5 100644
--- a/portal/backend/internal/system/auth/manager.go
+++ b/portal/backend/internal/system/auth/manager.go
@@ -23,6 +23,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"log/slog"
"net/http"
"strings"
@@ -53,6 +54,11 @@ type Manager struct {
func NewManager(ctx context.Context, cfg config.AuthConfig, proxyCfg config.ProxyConfig, log *slog.Logger) (*Manager, error) {
m := &Manager{cfg: cfg, proxyCfg: proxyCfg, log: log}
if !cfg.Enabled {
+ if proxyCfg.PlaceholderModeEnabled {
+ if err := validatePlaceholderScopes(proxyCfg.PlaceholderScopes); err != nil {
+ return nil, err
+ }
+ }
return m, nil
}
if err := validateConfiguredScopes(cfg.Scopes); err != nil {
@@ -110,6 +116,24 @@ func validateConfiguredScopesWithPrefix(configured []string, prefix string, port
return nil
}
+func validatePlaceholderScopes(configured []string) error {
+ canonical := make(map[string]struct{}, len(AllPortalScopes))
+ for _, scope := range AllPortalScopes {
+ canonical[scope] = struct{}{}
+ }
+ seen := make(map[string]struct{}, len(configured))
+ for _, scope := range configured {
+ if _, ok := canonical[scope]; !ok {
+ return fmt.Errorf("proxy placeholder scopes contain unknown portal scope %q", scope)
+ }
+ if _, duplicate := seen[scope]; duplicate {
+ return fmt.Errorf("proxy placeholder scopes contain duplicate scope %q", scope)
+ }
+ seen[scope] = struct{}{}
+ }
+ return nil
+}
+
// RegisterRoutes registers browser-facing authentication endpoints.
func (m *Manager) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /auth/login", m.Login)
@@ -164,8 +188,8 @@ func (m *Manager) authenticate(r *http.Request) (systemcontext.Principal, error)
if userID == "" || orgID == "" {
return systemcontext.Principal{}, errInvalidCredentials
}
- scopes := make(map[string]struct{}, len(AllPortalScopes))
- for _, scope := range AllPortalScopes {
+ scopes := make(map[string]struct{}, len(m.proxyCfg.PlaceholderScopes))
+ for _, scope := range m.proxyCfg.PlaceholderScopes {
scopes[scope] = struct{}{}
}
return systemcontext.Principal{UserID: userID, OrgID: orgID, Scopes: scopes}, nil
@@ -197,7 +221,10 @@ func (m *Manager) validateAccessToken(ctx context.Context, raw string) (systemco
return systemcontext.Principal{}, time.Time{}, errInvalidCredentials
}
subject := strings.TrimSpace(verified.Subject)
- orgID, err := stringClaim(claims, m.cfg.OrgIDClaim)
+ orgID := strings.TrimSpace(m.cfg.OrgIDOverride)
+ if orgID == "" {
+ orgID, err = stringClaim(claims, m.cfg.OrgIDClaim)
+ }
if err != nil || subject == "" || orgID == "" {
return systemcontext.Principal{}, time.Time{}, errInvalidCredentials
}
diff --git a/portal/backend/internal/system/auth/manager_test.go b/portal/backend/internal/system/auth/manager_test.go
index 149a4bf4..906de933 100644
--- a/portal/backend/internal/system/auth/manager_test.go
+++ b/portal/backend/internal/system/auth/manager_test.go
@@ -38,6 +38,7 @@ func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) {
PlaceholderModeEnabled: true,
PlaceholderUserID: " user-1 ",
PlaceholderOrgID: " org-1 ",
+ PlaceholderScopes: []string{ScopeConsentsReadSelf, ScopeElementsRead},
},
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
@@ -56,11 +57,14 @@ func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) {
if principal.UserID != "user-1" || principal.OrgID != "org-1" {
t.Errorf("unexpected placeholder principal: %#v", principal)
}
- for _, scope := range AllPortalScopes {
+ for _, scope := range []string{ScopeConsentsReadSelf, ScopeElementsRead} {
if _, ok := principal.Scopes[scope]; !ok {
t.Errorf("placeholder principal missing scope %q", scope)
}
}
+ if _, ok := principal.Scopes[ScopeConsentsWriteSelf]; ok {
+ t.Errorf("placeholder principal unexpectedly has unconfigured scope %q", ScopeConsentsWriteSelf)
+ }
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(recorder, request)
@@ -68,3 +72,64 @@ func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) {
t.Fatalf("unexpected response status: %d", recorder.Code)
}
}
+
+func TestPlaceholderModeRejectsInvalidScopes(t *testing.T) {
+ tests := []struct {
+ name string
+ scopes []string
+ }{
+ {name: "unknown", scopes: []string{"portal:unknown"}},
+ {name: "duplicate", scopes: []string{ScopeElementsRead, ScopeElementsRead}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := NewManager(
+ context.Background(),
+ config.AuthConfig{},
+ config.ProxyConfig{
+ PlaceholderModeEnabled: true,
+ PlaceholderUserID: "user-1",
+ PlaceholderOrgID: "org-1",
+ PlaceholderScopes: tt.scopes,
+ },
+ slog.New(slog.NewTextHandler(io.Discard, nil)),
+ )
+ if err == nil {
+ t.Fatal("expected invalid placeholder scopes to be rejected")
+ }
+ })
+ }
+}
+
+func TestPlaceholderModeAllowsNoScopes(t *testing.T) {
+ manager, err := NewManager(
+ context.Background(),
+ config.AuthConfig{},
+ config.ProxyConfig{
+ PlaceholderModeEnabled: true,
+ PlaceholderUserID: "user-1",
+ PlaceholderOrgID: "org-1",
+ },
+ slog.New(slog.NewTextHandler(io.Discard, nil)),
+ )
+ if err != nil {
+ t.Fatalf("new manager: %v", err)
+ }
+
+ recorder := httptest.NewRecorder()
+ manager.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ principal, ok := systemcontext.PrincipalFromContext(r.Context())
+ if !ok {
+ t.Fatal("expected placeholder principal")
+ }
+ if len(principal.Scopes) != 0 {
+ t.Fatalf("expected no placeholder scopes, got %#v", principal.Scopes)
+ }
+ w.WriteHeader(http.StatusNoContent)
+ })).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/protected", nil))
+
+ if recorder.Code != http.StatusNoContent {
+ t.Fatalf("unexpected response status: %d", recorder.Code)
+ }
+}
diff --git a/portal/backend/internal/system/auth/scopes.go b/portal/backend/internal/system/auth/scopes.go
index 50fb6a57..808f9d16 100644
--- a/portal/backend/internal/system/auth/scopes.go
+++ b/portal/backend/internal/system/auth/scopes.go
@@ -47,6 +47,18 @@ var AllPortalScopes = []string{
ScopePurposesRead, ScopePurposesWrite,
}
+// GrantedPortalScopes returns the canonical portal scopes present in granted,
+// preserving the stable order of AllPortalScopes.
+func GrantedPortalScopes(granted map[string]struct{}) []string {
+ result := make([]string, 0, len(AllPortalScopes))
+ for _, scope := range AllPortalScopes {
+ if _, ok := granted[scope]; ok {
+ result = append(result, scope)
+ }
+ }
+ return result
+}
+
type apiScopePolicy struct {
method, pattern, scope string
}
diff --git a/portal/backend/internal/system/auth/scopes_test.go b/portal/backend/internal/system/auth/scopes_test.go
index 6e620511..605a414b 100644
--- a/portal/backend/internal/system/auth/scopes_test.go
+++ b/portal/backend/internal/system/auth/scopes_test.go
@@ -68,6 +68,25 @@ func TestCanonicalScopesAreUniqueAndPrefixed(t *testing.T) {
}
}
+func TestGrantedPortalScopesFiltersAndPreservesCanonicalOrder(t *testing.T) {
+ granted := map[string]struct{}{
+ "openid": {},
+ ScopePurposesWrite: {},
+ ScopeConsentsReadSelf: {},
+ "portal:future:scope": {},
+ }
+ want := []string{ScopeConsentsReadSelf, ScopePurposesWrite}
+ got := GrantedPortalScopes(granted)
+ if len(got) != len(want) {
+ t.Fatalf("got %v; want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("got %v; want %v", got, want)
+ }
+ }
+}
+
func TestEveryAPIRouteHasCanonicalScopePolicy(t *testing.T) {
tests := []struct {
method string
@@ -192,6 +211,11 @@ func TestOpenAPIScopesMatchCanonicalRoutePolicies(t *testing.T) {
continue
}
want = policy
+ case route == "/me":
+ if len(scopes) != 0 {
+ t.Errorf("%s %s documents %v; want no business scope", upperMethod, route, scopes)
+ }
+ continue
case strings.HasPrefix(route, "/me/"):
if upperMethod == "GET" {
want = ScopeConsentsReadSelf
diff --git a/portal/backend/internal/system/auth/validation_test.go b/portal/backend/internal/system/auth/validation_test.go
index b6bd3ef1..509a4ab5 100644
--- a/portal/backend/internal/system/auth/validation_test.go
+++ b/portal/backend/internal/system/auth/validation_test.go
@@ -198,6 +198,26 @@ func TestAccessTokenArrayScopeClaim(t *testing.T) {
}
}
+func TestAccessTokenOrganizationOverride(t *testing.T) {
+ p := newOIDCValidationProvider(t)
+ manager := p.manager(t, func(cfg *config.AuthConfig) {
+ cfg.OrgIDOverride = " ORG-001 "
+ })
+ now := time.Now()
+ raw := signTestToken(t, p.key1, "key-1", map[string]any{
+ "iss": p.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(),
+ "scope": ScopeConsentsReadSelf,
+ })
+
+ principal, _, err := manager.validateAccessToken(context.Background(), raw)
+ if err != nil {
+ t.Fatalf("expected token without organization claim to use override: %v", err)
+ }
+ if principal.OrgID != "ORG-001" {
+ t.Fatalf("unexpected organization ID: %q", principal.OrgID)
+ }
+}
+
func TestIDTokenValidationFailures(t *testing.T) {
p := newOIDCValidationProvider(t)
manager := p.manager(t, nil)
diff --git a/portal/backend/internal/system/config/config.go b/portal/backend/internal/system/config/config.go
index 314b6c65..a90c8b27 100644
--- a/portal/backend/internal/system/config/config.go
+++ b/portal/backend/internal/system/config/config.go
@@ -60,6 +60,7 @@ type AuthConfig struct {
RefreshTimeout time.Duration `koanf:"refresh_timeout"`
ScopeClaim string `koanf:"scope_claim"`
OrgIDClaim string `koanf:"org_id_claim"`
+ OrgIDOverride string `koanf:"org_id_override"`
RequireAccessTokenType bool `koanf:"require_access_token_type"`
AccessTokenTypeClaim string `koanf:"access_token_type_claim"`
AccessTokenTypeValue string `koanf:"access_token_type_value"`
@@ -113,6 +114,7 @@ type ProxyConfig struct {
PlaceholderModeEnabled bool `koanf:"placeholder_mode_enabled"`
PlaceholderUserID string `koanf:"placeholder_user_id"`
PlaceholderOrgID string `koanf:"placeholder_org_id"`
+ PlaceholderScopes []string `koanf:"placeholder_scopes"`
AllowedPassthrough []string `koanf:"allowed_passthrough_methods"`
}
@@ -167,6 +169,9 @@ func Load() (*Config, error) {
if rawScopes := os.Getenv("BFF_AUTH__SCOPES"); rawScopes != "" {
cfg.Auth.Scopes = strings.Fields(rawScopes)
}
+ if rawScopes := os.Getenv("BFF_PROXY__PLACEHOLDER_SCOPES"); rawScopes != "" {
+ cfg.Proxy.PlaceholderScopes = strings.Fields(rawScopes)
+ }
if rawAlgorithms := os.Getenv("BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS"); rawAlgorithms != "" {
cfg.Auth.AllowedSigningAlgorithms = ParseCSV(rawAlgorithms)
}
@@ -238,6 +243,9 @@ func setDefaults(k *koanf.Koanf) error {
if err := k.Set("auth.org_id_claim", "org_id"); err != nil {
return err
}
+ if err := k.Set("auth.org_id_override", ""); err != nil {
+ return err
+ }
if err := k.Set("auth.require_access_token_type", false); err != nil {
return err
}
@@ -310,6 +318,9 @@ func setDefaults(k *koanf.Koanf) error {
if err := k.Set("proxy.placeholder_org_id", ""); err != nil {
return err
}
+ if err := k.Set("proxy.placeholder_scopes", []string{}); err != nil {
+ return err
+ }
if err := k.Set("proxy.allowed_passthrough_methods", []string{"GET", "POST", "PUT", "DELETE"}); err != nil {
return err
}
@@ -387,6 +398,9 @@ func validate(cfg Config) error {
if !cfg.Proxy.PlaceholderModeEnabled && cfg.Proxy.PlaceholderOrgID != "" {
return fmt.Errorf("proxy.placeholder_org_id must be empty when placeholder mode is disabled")
}
+ if !cfg.Proxy.PlaceholderModeEnabled && len(cfg.Proxy.PlaceholderScopes) != 0 {
+ return fmt.Errorf("proxy.placeholder_scopes must be empty when placeholder mode is disabled")
+ }
if len(cfg.Proxy.AllowedPassthrough) == 0 {
return fmt.Errorf("proxy.allowed_passthrough_methods must not be empty")
}
@@ -409,7 +423,9 @@ func validateAuth(cfg Config) error {
"auth.post_logout_redirect_uri": cfg.Auth.PostLogoutRedirectURI,
"auth.resource_audience": cfg.Auth.ResourceAudience,
"auth.scope_claim": cfg.Auth.ScopeClaim,
- "auth.org_id_claim": cfg.Auth.OrgIDClaim,
+ }
+ if strings.TrimSpace(cfg.Auth.OrgIDOverride) == "" {
+ required["auth.org_id_claim"] = cfg.Auth.OrgIDClaim
}
for name, value := range required {
if strings.TrimSpace(value) == "" {
diff --git a/portal/backend/internal/system/config/config_test.go b/portal/backend/internal/system/config/config_test.go
index 047f106a..3580f8d5 100644
--- a/portal/backend/internal/system/config/config_test.go
+++ b/portal/backend/internal/system/config/config_test.go
@@ -21,6 +21,7 @@ package config
import (
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
@@ -55,6 +56,28 @@ func TestLoadFromEnv(t *testing.T) {
}
}
+func TestOrgIDOverrideFromEnv(t *testing.T) {
+ t.Setenv("BFF_AUTH__ORG_ID_OVERRIDE", "ORG-001")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("expected config to load, got error: %v", err)
+ }
+ if cfg.Auth.OrgIDOverride != "ORG-001" {
+ t.Fatalf("unexpected organization override: %q", cfg.Auth.OrgIDOverride)
+ }
+}
+
+func TestOrgIDClaimCanBeEmptyWhenOverrideIsConfigured(t *testing.T) {
+ cfg := validAuthValidationConfig("development")
+ cfg.Auth.OrgIDClaim = ""
+ cfg.Auth.OrgIDOverride = "ORG-001"
+
+ if err := validateAuth(cfg); err != nil {
+ t.Fatalf("expected organization override to replace claim requirement: %v", err)
+ }
+}
+
func TestInvalidCORSOriginRejected(t *testing.T) {
tests := []struct {
name string
@@ -130,6 +153,7 @@ func TestPlaceholderValuesRejectedWhenModeDisabled(t *testing.T) {
}{
{name: "user id", envName: "BFF_PROXY__PLACEHOLDER_USER_ID", errText: "proxy.placeholder_user_id must be empty"},
{name: "org id", envName: "BFF_PROXY__PLACEHOLDER_ORG_ID", errText: "proxy.placeholder_org_id must be empty"},
+ {name: "scopes", envName: "BFF_PROXY__PLACEHOLDER_SCOPES", errText: "proxy.placeholder_scopes must be empty"},
}
for _, tt := range tests {
@@ -148,6 +172,22 @@ func TestPlaceholderValuesRejectedWhenModeDisabled(t *testing.T) {
}
}
+func TestPlaceholderScopesEnv(t *testing.T) {
+ t.Setenv("BFF_PROXY__PLACEHOLDER_MODE_ENABLED", "true")
+ t.Setenv("BFF_PROXY__PLACEHOLDER_USER_ID", "local-user")
+ t.Setenv("BFF_PROXY__PLACEHOLDER_ORG_ID", "local-org")
+ t.Setenv("BFF_PROXY__PLACEHOLDER_SCOPES", "portal:consents:read:self portal:elements:read")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("expected config to load, got error: %v", err)
+ }
+ want := []string{"portal:consents:read:self", "portal:elements:read"}
+ if !reflect.DeepEqual(cfg.Proxy.PlaceholderScopes, want) {
+ t.Fatalf("unexpected placeholder scopes: %#v", cfg.Proxy.PlaceholderScopes)
+ }
+}
+
func TestAllowedPassthroughMethodsEnvJSON(t *testing.T) {
t.Setenv("BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS", `["get", "put"]`)
diff --git a/portal/backend/openapi/portal-backend.yaml b/portal/backend/openapi/portal-backend.yaml
index 8cc17f39..9b575028 100644
--- a/portal/backend/openapi/portal-backend.yaml
+++ b/portal/backend/openapi/portal-backend.yaml
@@ -107,6 +107,28 @@ paths:
schema: { $ref: '#/components/schemas/LogoutResponse' }
'401': { $ref: '#/components/responses/AuthError' }
+ /me:
+ get:
+ tags: [User]
+ summary: Get the authenticated portal user
+ description: >-
+ Returns identity and canonical portal authorization scopes derived from
+ the locally validated access token. OIDC-only scopes are omitted.
+ security:
+ - PortalOAuthDocumentation: []
+ AccessTokenPart2: []
+ responses:
+ '200':
+ description: Authenticated portal user.
+ headers:
+ Cache-Control:
+ description: Prevents authenticated user data from being cached.
+ schema: { type: string, example: no-store }
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/CurrentUser' }
+ '401': { $ref: '#/components/responses/AuthError' }
+
/me/consents:
get:
tags: [User]
@@ -617,6 +639,26 @@ components:
ElementID: { in: path, name: elementId, required: true, schema: { type: string } }
Version: { in: path, name: version, required: true, schema: { type: string, pattern: '^v[1-9][0-9]*$' } }
schemas:
+ CurrentUser:
+ type: object
+ required: [userId, organizationId, scopes]
+ properties:
+ userId: { type: string }
+ organizationId: { type: string }
+ scopes:
+ type: array
+ uniqueItems: true
+ items:
+ type: string
+ enum:
+ - portal:consents:read:self
+ - portal:consents:write:self
+ - portal:consents:read:any
+ - portal:consents:write:any
+ - portal:elements:read
+ - portal:elements:write
+ - portal:purposes:read
+ - portal:purposes:write
LogoutResponse:
type: object
required: [logoutUrl]
diff --git a/portal/backend/tests/integration/auth_integration_test.go b/portal/backend/tests/integration/auth_integration_test.go
index 894d517c..58a3fbb5 100644
--- a/portal/backend/tests/integration/auth_integration_test.go
+++ b/portal/backend/tests/integration/auth_integration_test.go
@@ -254,6 +254,33 @@ func TestOIDCAuthLifecycleThroughAssembledBFF(t *testing.T) {
fixture := newAuthIntegrationFixture(t, strings.Join(auth.AllPortalScopes, " "))
cookies := fixture.login(t)
+ currentUserRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/me", nil)
+ addAuthIntegrationAccess(currentUserRequest, fixture.cfg.Auth, cookies)
+ currentUserResponse, err := fixture.client.Do(currentUserRequest)
+ if err != nil {
+ t.Fatalf("current user request: %v", err)
+ }
+ if currentUserResponse.StatusCode != http.StatusOK {
+ _ = currentUserResponse.Body.Close()
+ t.Fatalf("current user returned %d", currentUserResponse.StatusCode)
+ }
+ var currentUserPayload struct {
+ UserID string `json:"userId"`
+ OrganizationID string `json:"organizationId"`
+ Scopes []string `json:"scopes"`
+ }
+ if err := json.NewDecoder(currentUserResponse.Body).Decode(¤tUserPayload); err != nil {
+ _ = currentUserResponse.Body.Close()
+ t.Fatalf("decode current user: %v", err)
+ }
+ _ = currentUserResponse.Body.Close()
+ if currentUserPayload.UserID != "integration-user" || currentUserPayload.OrganizationID != "integration-org" {
+ t.Fatalf("unexpected current user: %+v", currentUserPayload)
+ }
+ if len(currentUserPayload.Scopes) != len(auth.AllPortalScopes) {
+ t.Fatalf("current user scopes = %v", currentUserPayload.Scopes)
+ }
+
apiRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/api/consents?limit=10", nil)
addAuthIntegrationAccess(apiRequest, fixture.cfg.Auth, cookies)
apiResponse, err := fixture.client.Do(apiRequest)
diff --git a/portal/backend/tests/integration/proxy_integration_test.go b/portal/backend/tests/integration/proxy_integration_test.go
index 0029b50f..2cc97c73 100644
--- a/portal/backend/tests/integration/proxy_integration_test.go
+++ b/portal/backend/tests/integration/proxy_integration_test.go
@@ -29,6 +29,7 @@ import (
"testing"
"time"
+ "github.com/wso2/openfgc/portal/backend/internal/system/auth"
"github.com/wso2/openfgc/portal/backend/internal/system/config"
)
@@ -44,6 +45,7 @@ func newPortalServer(t *testing.T, upstreamURL string, configure func(*config.Co
cfg.Proxy.PlaceholderModeEnabled = true
cfg.Proxy.PlaceholderUserID = "user@example.com"
cfg.Proxy.PlaceholderOrgID = "ORG-001"
+ cfg.Proxy.PlaceholderScopes = append([]string(nil), auth.AllPortalScopes...)
if configure != nil {
configure(cfg)
}
@@ -503,6 +505,7 @@ func TestProxyErrorAndBodyLimits(t *testing.T) {
cfg.Proxy.PlaceholderModeEnabled = true
cfg.Proxy.PlaceholderUserID = "user@example.com"
cfg.Proxy.PlaceholderOrgID = "ORG-001"
+ cfg.Proxy.PlaceholderScopes = append([]string(nil), auth.AllPortalScopes...)
h, _ := newIntegrationHandler(*cfg)
req := httptest.NewRequest(http.MethodPost, "/api/consents", nil)
req.Body = failingReadCloser{}
@@ -525,6 +528,7 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) {
cfg.Proxy.PlaceholderModeEnabled = false
cfg.Proxy.PlaceholderUserID = ""
cfg.Proxy.PlaceholderOrgID = ""
+ cfg.Proxy.PlaceholderScopes = nil
})
defer bff.Close()
diff --git a/portal/frontend/.env.example b/portal/frontend/.env.example
index ff0529f8..5fa0389a 100644
--- a/portal/frontend/.env.example
+++ b/portal/frontend/.env.example
@@ -1,5 +1,4 @@
VITE_API_BASE_URL=http://localhost:8080
-VITE_ORG_ID=ORG-001
VITE_AUTH_ENABLED=false
VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE=portal-at-p1
VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1
diff --git a/portal/frontend/README.md b/portal/frontend/README.md
index 31545cee..a9b53e2c 100644
--- a/portal/frontend/README.md
+++ b/portal/frontend/README.md
@@ -65,7 +65,6 @@ Note: If your machine cannot install Corepack shims, prefix pnpm commands with `
Alternatively, follow the other [recommended pnpm installation options](https://pnpm.io/installation).
-
## Environment
Create a local `.env` file from `.env.example` before running or building the portal.
@@ -73,7 +72,6 @@ Create a local `.env` file from `.env.example` before running or building the po
| Variable | Description | Example |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| `VITE_API_BASE_URL` | Required base URL for the OpenFGC Portal backend API. Vite embeds this value at build time. | `http://localhost:8080` |
-| `VITE_ORG_ID` | Organization ID used to identify and filter organization-wide consent purposes. | `ORG-001` |
| `VITE_AUTH_ENABLED` | Enables frontend authentication gating; set to `true` for protected deployments. | `true` |
| `VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE` | Cookie name for the readable access-token part. | `portal-at-p1` |
| `VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE` | Cookie name for the readable refresh-token part. | `portal-rt-p1` |
@@ -81,6 +79,8 @@ Create a local `.env` file from `.env.example` before running or building the po
| `VITE_AUTH_ID_TOKEN_PART2_COOKIE` | Cookie name for ID-token part 2. | `portal-id-p2` |
| `VITE_AUTH_LOGOUT_ALLOWED_ORIGINS` | Exact comma-separated origins accepted for logout navigation. Include the IdP origin when using its end-session endpoint. | `https://idp.example.com` |
+The authenticated user and organization IDs are resolved from `GET /me`; they are not frontend build-time configuration.
+
## Production security headers
`pnpm build` emits `dist/_headers`; configure the production static host to apply
@@ -151,3 +151,9 @@ function Example(): React.JSX.Element {
## CI
GitHub Actions CI runs pnpm-based install, lint, and build checks on every push and pull request on portal/frontend directory.
+
+## AI Instructions
+
+This repository uses AGENTS.md files to keep AI-generated changes aligned with project and organization standards.
+
+- Frontend standards: `portal/frontend/AGENTS.md`
diff --git a/portal/frontend/package.json b/portal/frontend/package.json
index 844f8bb2..5e8a5ad6 100644
--- a/portal/frontend/package.json
+++ b/portal/frontend/package.json
@@ -22,7 +22,7 @@
"dependencies": {
"@tanstack/react-query": "5.96.2",
"@wso2/oxygen-ui": "0.8.3",
- "@wso2/oxygen-ui-icons-react": "0.8.3",
+ "@wso2/oxygen-ui-icons-react": "0.13.0",
"i18next": "25.10.5",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -54,7 +54,8 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "7.3.5",
- "vitest": "3.2.6"
+ "vitest": "3.2.6",
+ "yaml": "2.8.1"
},
"pnpm": {
"overrides": {
diff --git a/portal/frontend/pnpm-lock.yaml b/portal/frontend/pnpm-lock.yaml
index 25574e23..2b4c65f8 100644
--- a/portal/frontend/pnpm-lock.yaml
+++ b/portal/frontend/pnpm-lock.yaml
@@ -25,10 +25,10 @@ importers:
version: 5.96.2(react@19.2.4)
'@wso2/oxygen-ui':
specifier: 0.8.3
- version: 0.8.3(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 0.8.3(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.13.0(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@wso2/oxygen-ui-icons-react':
- specifier: 0.8.3
- version: 0.8.3(react@19.2.4)
+ specifier: 0.13.0
+ version: 0.13.0(react@19.2.4)
i18next:
specifier: 25.10.5
version: 25.10.5(typescript@5.9.3)
@@ -68,10 +68,10 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: 5.2.0
- version: 5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))
+ version: 5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1))
'@vitest/coverage-v8':
specifier: 3.2.6
- version: 3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0))
+ version: 3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1))
eslint:
specifier: 9.39.4
version: 9.39.4
@@ -116,10 +116,13 @@ importers:
version: 8.57.1(eslint@9.39.4)(typescript@5.9.3)
vite:
specifier: 7.3.5
- version: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)
+ version: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)
vitest:
specifier: 3.2.6
- version: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)
+ version: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1)
+ yaml:
+ specifier: 2.8.1
+ version: 2.8.1
packages:
@@ -777,66 +780,79 @@ packages:
resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.2':
resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.2':
resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.2':
resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.2':
resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.2':
resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.2':
resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.2':
resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.2':
resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.2':
resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.2':
resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.2':
resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.2':
resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.2':
resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
@@ -1060,8 +1076,8 @@ packages:
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
- '@wso2/oxygen-ui-icons-react@0.8.3':
- resolution: {integrity: sha512-CPGEaqlM59KJUg51xP43bITbahO3BTxcEvYSZe2O+Lfpd6AyyWK9g8rZ26RuQOpdFj3kN3q1wwQcbMkJauVZVQ==}
+ '@wso2/oxygen-ui-icons-react@0.13.0':
+ resolution: {integrity: sha512-zSEtzyyZ8xuxIodg9Aq8+U9lfg+soEavQf8T+6oaJomG1yhIS96P6LV/cmXrU7BOwQ78z0r+bDqBebXx7tqjRw==}
peerDependencies:
react: 19.2.3
@@ -2026,24 +2042,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2084,8 +2104,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- lucide-react@0.545.0:
- resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==}
+ lucide-react@1.16.0:
+ resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -2812,6 +2832,11 @@ packages:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
+ yaml@2.8.1:
+ resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3684,7 +3709,7 @@ snapshots:
'@typescript-eslint/types': 8.57.1
eslint-visitor-keys: 5.0.1
- '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))':
+ '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.29.6
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.6)
@@ -3692,11 +3717,11 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)
+ vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
- '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0))':
+ '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -3711,7 +3736,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
- vitest: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)
+ vitest: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
@@ -3723,13 +3748,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
- '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))':
+ '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)
+ vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)
'@vitest/pretty-format@3.2.6':
dependencies:
@@ -3761,12 +3786,12 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
- '@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4)':
+ '@wso2/oxygen-ui-icons-react@0.13.0(react@19.2.4)':
dependencies:
- lucide-react: 0.545.0(react@19.2.4)
+ lucide-react: 1.16.0(react@19.2.4)
react: 19.2.4
- '@wso2/oxygen-ui@0.8.3(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@wso2/oxygen-ui@0.8.3(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.13.0(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)
@@ -3775,7 +3800,7 @@ snapshots:
'@mui/x-data-grid': 8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mui/x-date-pickers': 8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mui/x-tree-view': 8.14.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@wso2/oxygen-ui-icons-react': 0.8.3(react@19.2.4)
+ '@wso2/oxygen-ui-icons-react': 0.13.0(react@19.2.4)
date-fns: 4.1.0
prismjs: 1.30.0
react: 19.2.4
@@ -4969,7 +4994,7 @@ snapshots:
dependencies:
yallist: 3.1.1
- lucide-react@0.545.0(react@19.2.4):
+ lucide-react@1.16.0(react@19.2.4):
dependencies:
react: 19.2.4
@@ -5610,13 +5635,13 @@ snapshots:
dependencies:
react: 19.2.4
- vite-node@3.2.4(@types/node@24.12.0)(lightningcss@1.32.0):
+ vite-node@3.2.4(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)
+ vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -5631,7 +5656,7 @@ snapshots:
- tsx
- yaml
- vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0):
+ vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1):
dependencies:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
@@ -5643,12 +5668,13 @@ snapshots:
'@types/node': 24.12.0
fsevents: 2.3.3
lightningcss: 1.32.0
+ yaml: 2.8.1
- vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0):
+ vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
- '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))
+ '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.6
'@vitest/snapshot': 3.2.6
@@ -5666,8 +5692,8 @@ snapshots:
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)
- vite-node: 3.2.4(@types/node@24.12.0)(lightningcss@1.32.0)
+ vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)
+ vite-node: 3.2.4(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.0
@@ -5779,6 +5805,8 @@ snapshots:
yaml@1.10.3: {}
+ yaml@2.8.1: {}
+
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.4.3):
diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx
index 08c4a23d..dd949ae3 100644
--- a/portal/frontend/src/App.tsx
+++ b/portal/frontend/src/App.tsx
@@ -16,32 +16,111 @@
* under the License.
*/
+import { Box, Button, CircularProgress, Stack, Typography } from '@wso2/oxygen-ui'
+import { CircleAlert } from '@wso2/oxygen-ui-icons-react'
import { useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
import { Navigate, Route, Routes } from 'react-router-dom'
import MainLayout from './components/layout/main-layout/MainLayout'
import ElementDetailsPage from './features/catalog/ElementDetailsPage'
import ElementListPage from './features/catalog/ElementListPage'
import PurposeDetailsPage from './features/catalog/PurposeDetailsPage'
import PurposeListPage from './features/catalog/PurposeListPage'
+import AdminConsentRegistryPage from './features/admin-consents/AdminConsentRegistryPage'
import ConsentDetailsPage from './features/consent-registry/ConsentDetailsPage'
import ConsentRegistryPage from './features/consent-registry/ConsentRegistryPage'
import DashboardPage from './features/dashboard/DashboardPage'
-import { isAuthenticated, login } from './utils/authClient'
+import { AuthorizationProvider } from './features/auth/AuthorizationProvider'
+import useAuthorization from './features/auth/useAuthorization'
+import firstAuthorizedPath from './features/auth/authorizationRoutes'
+import NoAccessPage from './features/auth/NoAccessPage'
+import useCurrentUserQuery from './features/auth/hooks/useCurrentUserQuery'
+import { isAuthEnabled, isAuthenticated, login } from './utils/authClient'
+import { PORTAL_SCOPES, type PortalScope } from './utils/portalScopes'
+import { APIError } from './utils/apiClient'
function AuthenticationGate({
children,
}: {
children: React.JSX.Element
}): React.JSX.Element | null {
- const authenticated = isAuthenticated()
+ const { t } = useTranslation('common')
+ const cookieAuthenticated = isAuthenticated()
+ const currentUserQuery = useCurrentUserQuery(cookieAuthenticated)
useEffect(() => {
- if (!authenticated) {
+ if (!cookieAuthenticated) {
login()
}
- }, [authenticated])
+ }, [cookieAuthenticated])
- return authenticated ? children : null
+ if (!cookieAuthenticated) {
+ return null
+ }
+
+ if (currentUserQuery.isPending) {
+ return (
+
+
+
+ )
+ }
+
+ if (currentUserQuery.isError || !currentUserQuery.data) {
+ if (
+ isAuthEnabled() &&
+ currentUserQuery.error instanceof APIError &&
+ currentUserQuery.error.status === 401
+ ) {
+ return null
+ }
+ return (
+
+
+
+
+ {t('authorization.loadFailed')}
+
+
+
+
+ )
+ }
+
+ return (
+ {children}
+ )
+}
+
+function AuthorizedRoute({
+ scope,
+ children,
+}: {
+ scope: PortalScope
+ children: React.JSX.Element
+}): React.JSX.Element {
+ const { currentUser, hasScope } = useAuthorization()
+ if (hasScope(scope)) {
+ return children
+ }
+ const fallback = firstAuthorizedPath(currentUser.scopes)
+ return fallback ? :
+}
+
+function AuthorizedFallback(): React.JSX.Element {
+ const { currentUser } = useAuthorization()
+ const fallback = firstAuthorizedPath(currentUser.scopes)
+ return fallback ? :
}
function App(): React.JSX.Element {
@@ -49,14 +128,79 @@ function App(): React.JSX.Element {
}>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
diff --git a/portal/frontend/src/__tests__/AdminConsentFilters.test.tsx b/portal/frontend/src/__tests__/AdminConsentFilters.test.tsx
new file mode 100644
index 00000000..bd98d118
--- /dev/null
+++ b/portal/frontend/src/__tests__/AdminConsentFilters.test.tsx
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { AcrylicOrangeTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import AdminConsentFilters from '../features/admin-consents/components/AdminConsentFilters'
+import {
+ EMPTY_ADMIN_CONSENT_FILTERS,
+ normalizeAdminConsentFilters,
+} from '../features/admin-consents/utils/adminConsentFilters'
+import i18n from '../i18n/i18n'
+
+afterEach(cleanup)
+
+describe('administrative consent filters', () => {
+ it('normalizes ID lists and removes dependent versions without their identity', () => {
+ expect(
+ normalizeAdminConsentFilters({
+ ...EMPTY_ADMIN_CONSENT_FILTERS,
+ userIds: ' user-1, user-2, user-1, ,',
+ groupIds: 'group-1, group-1,group-2',
+ purposeVersion: 'v2',
+ elementVersion: 'v3',
+ }),
+ ).toMatchObject({
+ userIds: 'user-1,user-2',
+ groupIds: 'group-1,group-2',
+ purposeVersion: '',
+ elementVersion: '',
+ })
+ })
+
+ it('uses free-text element fields when ELEMENTS_READ is unavailable', () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+
+ render(
+
+
+
+
+
+
+ ,
+ )
+
+ expect(screen.getByPlaceholderText('Search by consent ID')).toBeInTheDocument()
+ expect(screen.queryByPlaceholderText('Search by purpose name')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced filters' }))
+
+ expect(screen.getByRole('textbox', { name: 'Purpose name' })).toBeEnabled()
+ const elementName = screen.getByRole('textbox', { name: 'Element name' })
+ const elementNamespace = screen.getByRole('textbox', { name: 'Element namespace' })
+ const elementVersion = screen.getByRole('textbox', { name: 'Element version' })
+ expect(elementName).toBeEnabled()
+ expect(elementNamespace).toBeEnabled()
+ expect(elementVersion).toBeDisabled()
+
+ fireEvent.change(elementName, { target: { value: 'account-number' } })
+ expect(elementVersion).toBeEnabled()
+ })
+
+ it('disables advanced filters and explains why when a Consent ID filter is active', async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+
+ render(
+
+
+
+
+
+
+ ,
+ )
+
+ const advancedFiltersButton = screen.getByRole('button', { name: 'Advanced filters' })
+ const statusSelect = screen.getByRole('combobox', { name: 'Status' })
+ expect(advancedFiltersButton).toBeDisabled()
+ expect(statusSelect).toHaveAttribute('aria-disabled', 'true')
+
+ fireEvent.mouseOver(advancedFiltersButton.parentElement as HTMLElement)
+ expect(
+ await screen.findByText('Remove the Consent ID filter to use advanced filters.'),
+ ).toBeInTheDocument()
+
+ fireEvent.mouseOver(statusSelect.closest('[aria-label]') as HTMLElement)
+ expect(
+ await screen.findByText('Remove the Consent ID filter to use the status filter.'),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/portal/frontend/src/__tests__/AdminConsentRegistryPage.test.tsx b/portal/frontend/src/__tests__/AdminConsentRegistryPage.test.tsx
new file mode 100644
index 00000000..c3861168
--- /dev/null
+++ b/portal/frontend/src/__tests__/AdminConsentRegistryPage.test.tsx
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import AdminConsentRegistryPage from '../features/admin-consents/AdminConsentRegistryPage'
+import i18n from '../i18n/i18n'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
+import TestAuthorizationProvider from './TestAuthorizationProvider'
+
+const fetchMock = vi.fn()
+
+afterEach(() => {
+ cleanup()
+ fetchMock.mockReset()
+ vi.unstubAllGlobals()
+})
+
+describe('AdminConsentRegistryPage', () => {
+ it('uses the consent details endpoint and lists its result for a Consent ID search', async () => {
+ vi.stubGlobal('fetch', fetchMock)
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: 'consent/123',
+ groupId: 'group-1',
+ type: 'accounts',
+ status: 'ACTIVE',
+ updatedTime: 1_780_000_000_000,
+ expirationTime: 0,
+ purposes: [
+ {
+ purposeId: 'purpose-1',
+ name: 'account_access',
+ displayName: 'Account access',
+ version: 'v1',
+ elements: [],
+ },
+ ],
+ }),
+ })
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+
+ render(
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Account access')).toBeInTheDocument()
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+ const [requestUrl] = fetchMock.mock.calls[0] ?? []
+ const url = new URL(String(requestUrl))
+ expect(url.pathname).toBe('/api/consents/consent%2F123')
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ details: 'true',
+ includeStatusHistory: 'true',
+ })
+ expect(screen.getByRole('link', { name: 'View' })).toHaveAttribute(
+ 'href',
+ '/administration/consents/consent%2F123',
+ )
+ })
+})
diff --git a/portal/frontend/src/__tests__/AdminConsentsApi.test.ts b/portal/frontend/src/__tests__/AdminConsentsApi.test.ts
new file mode 100644
index 00000000..5bdd9397
--- /dev/null
+++ b/portal/frontend/src/__tests__/AdminConsentsApi.test.ts
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ fetchAdminConsentByID,
+ fetchAdminConsents,
+ revokeAdminConsent,
+} from '../features/admin-consents/api/adminConsentsApi'
+
+const fetchMock = vi.fn()
+
+afterEach(() => {
+ fetchMock.mockReset()
+ vi.unstubAllGlobals()
+})
+
+function mockJSONResponse(payload: unknown = {}): void {
+ vi.stubGlobal('fetch', fetchMock)
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => payload,
+ })
+}
+
+describe('administrative consent API', () => {
+ it('passes all supported administrative filters to the BFF', async () => {
+ mockJSONResponse({ data: [], metadata: { total: 0, offset: 0, count: 0, limit: 25 } })
+
+ await fetchAdminConsents({
+ consentStatuses: 'ACTIVE',
+ userIds: 'user-1,user-2',
+ groupIds: 'group-1,group-2',
+ purposeName: 'payments',
+ purposeVersion: 'v2',
+ elementName: 'account-number',
+ elementNamespace: 'banking',
+ elementVersion: 'v3',
+ sort: 'updatedTime:desc',
+ fromTime: 1000,
+ toTime: 2000,
+ limit: 25,
+ offset: 50,
+ })
+
+ const [requestUrl, requestInit] = fetchMock.mock.calls[0] ?? []
+ const url = new URL(String(requestUrl))
+ expect(url.pathname).toBe('/api/consents')
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ consentStatuses: 'ACTIVE',
+ userIds: 'user-1,user-2',
+ groupIds: 'group-1,group-2',
+ purposeName: 'payments',
+ purposeVersion: 'v2',
+ elementName: 'account-number',
+ elementNamespace: 'banking',
+ elementVersion: 'v3',
+ sort: 'updatedTime:desc',
+ fromTime: '1000',
+ toTime: '2000',
+ limit: '25',
+ offset: '50',
+ details: 'true',
+ })
+ expect(requestInit).toMatchObject({ method: 'GET', credentials: 'include' })
+ })
+
+ it('loads encoded consent details with history', async () => {
+ mockJSONResponse({ id: 'consent/123' })
+
+ await fetchAdminConsentByID('consent/123')
+
+ const [requestUrl] = fetchMock.mock.calls[0] ?? []
+ const url = new URL(String(requestUrl))
+ expect(url.pathname).toBe('/api/consents/consent%2F123')
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ details: 'true',
+ includeStatusHistory: 'true',
+ })
+ })
+
+ it('sends the current user as actionBy when revoking', async () => {
+ mockJSONResponse()
+
+ await revokeAdminConsent('consent-123', 'user-from-me')
+
+ const [, requestInit] = fetchMock.mock.calls[0] ?? []
+ expect(requestInit).toMatchObject({
+ method: 'POST',
+ credentials: 'include',
+ body: JSON.stringify({ actionBy: 'user-from-me' }),
+ })
+ expect(new Headers(requestInit?.headers as HeadersInit).get('Content-Type')).toBe(
+ 'application/json',
+ )
+ })
+})
diff --git a/portal/frontend/src/__tests__/AppSidebar.test.tsx b/portal/frontend/src/__tests__/AppSidebar.test.tsx
index bfb808ff..7bbd0782 100644
--- a/portal/frontend/src/__tests__/AppSidebar.test.tsx
+++ b/portal/frontend/src/__tests__/AppSidebar.test.tsx
@@ -23,6 +23,8 @@ import { afterEach, describe, expect, it } from 'vitest'
import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui'
import AppSidebar from '../components/layout/sidebar/AppSidebar'
import i18n from '../i18n/i18n'
+import TestAuthorizationProvider from './TestAuthorizationProvider'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
function LocationProbe(): React.JSX.Element {
const location = useLocation()
@@ -41,17 +43,19 @@ describe('AppSidebar', () => {
-
-
-
-
- >
- }
- />
-
+
+
+
+
+
+ >
+ }
+ />
+
+
,
@@ -61,6 +65,10 @@ describe('AppSidebar', () => {
expect(screen.getByRole('navigation')).toBeInTheDocument()
expect(screen.getByText('Consent')).toBeInTheDocument()
expect(screen.getByText('/consents')).toBeInTheDocument()
+ const navigationText = screen.getByRole('navigation').textContent ?? ''
+ expect(navigationText.indexOf('Administration')).toBeLessThan(
+ navigationText.indexOf('Definitions'),
+ )
fireEvent.click(screen.getByText('Pending Consents'))
@@ -70,4 +78,55 @@ describe('AppSidebar', () => {
expect(screen.getByText('/dashboard')).toBeInTheDocument()
})
+
+ it('hides unauthorized items and empty categories', () => {
+ render(
+
+
+
+
+
+
+
+
+ ,
+ )
+
+ expect(screen.getByText('Purposes')).toBeInTheDocument()
+ expect(screen.queryByText('Consent')).not.toBeInTheDocument()
+ expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
+ expect(screen.queryByText('Elements')).not.toBeInTheDocument()
+ expect(screen.queryByText('Administration')).not.toBeInTheDocument()
+ })
+
+ it('shows administration consents independently from self-service consents', () => {
+ render(
+
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+ ,
+ )
+
+ expect(screen.getByText('Administration')).toBeInTheDocument()
+ expect(screen.getByText('Consents')).toBeInTheDocument()
+ expect(screen.queryByText('Consent')).not.toBeInTheDocument()
+ expect(screen.queryByText('All Consents')).not.toBeInTheDocument()
+ expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
+ expect(screen.getByText('/administration/consents')).toBeInTheDocument()
+ })
})
diff --git a/portal/frontend/src/__tests__/Authorization.test.tsx b/portal/frontend/src/__tests__/Authorization.test.tsx
new file mode 100644
index 00000000..7a48e409
--- /dev/null
+++ b/portal/frontend/src/__tests__/Authorization.test.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import useAuthorization from '../features/auth/useAuthorization'
+import ScopeGuard from '../features/auth/ScopeGuard'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
+import TestAuthorizationProvider from './TestAuthorizationProvider'
+import firstAuthorizedPath from '../features/auth/authorizationRoutes'
+
+function AuthorizationProbe(): React.JSX.Element {
+ const { hasScope, hasAnyScope, hasAllScopes } = useAuthorization()
+ return (
+ <>
+ {String(hasScope(PORTAL_SCOPES.ELEMENTS_READ))}
+
+ {String(hasAnyScope([PORTAL_SCOPES.ELEMENTS_WRITE, PORTAL_SCOPES.CONSENTS_READ_SELF]))}
+
+
+ {String(hasAllScopes([PORTAL_SCOPES.ELEMENTS_READ, PORTAL_SCOPES.CONSENTS_READ_SELF]))}
+
+ >
+ )
+}
+
+describe('frontend authorization', () => {
+ it('selects the first authorized destination in stable navigation order', () => {
+ expect(firstAuthorizedPath([PORTAL_SCOPES.ELEMENTS_READ, PORTAL_SCOPES.PURPOSES_READ])).toBe(
+ '/purposes',
+ )
+ expect(firstAuthorizedPath([PORTAL_SCOPES.CONSENTS_READ_SELF])).toBe('/dashboard')
+ expect(firstAuthorizedPath([PORTAL_SCOPES.CONSENTS_READ_ANY])).toBe('/administration/consents')
+ expect(firstAuthorizedPath([])).toBeUndefined()
+ })
+
+ it('provides typed single, any, and all scope checks', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getAllByText('true')).toHaveLength(3)
+ })
+
+ it('renders guarded content only with the required scope', () => {
+ const { rerender } = render(
+
+
+ Write control
+
+ ,
+ )
+ expect(screen.queryByText('Write control')).not.toBeInTheDocument()
+
+ rerender(
+
+
+ Write control
+
+ ,
+ )
+ expect(screen.getByText('Write control')).toBeInTheDocument()
+ })
+})
diff --git a/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx b/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx
index 199da37a..a2a2833a 100644
--- a/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx
+++ b/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx
@@ -25,12 +25,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import ConsentDetailsPage from '../features/consent-registry/ConsentDetailsPage'
import i18n from '../i18n/i18n'
import type { ConsentStatusAuditItem } from '../types/consent'
+import type { PortalScope } from '../utils/portalScopes'
+import TestAuthorizationProvider from './TestAuthorizationProvider'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
const fetchMock = vi.fn()
function renderConsentDetailsPage(
status: string,
statusHistory: ConsentStatusAuditItem[] = [],
+ scopes: PortalScope[] = Object.values(PORTAL_SCOPES),
): void {
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockResolvedValue({
@@ -64,9 +68,11 @@ function renderConsentDetailsPage(
-
- } />
-
+
+
+ } />
+
+
@@ -97,6 +103,15 @@ describe('ConsentDetailsPage lifecycle actions', () => {
expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument()
})
+ it('hides lifecycle actions without the consent write scope', async () => {
+ renderConsentDetailsPage('CREATED', [], [PORTAL_SCOPES.CONSENTS_READ_SELF])
+
+ expect(await screen.findByRole('heading', { name: 'Consent Details' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument()
+ })
+
it.each(['REJECTED', 'REVOKED', 'EXPIRED'])(
'shows no lifecycle action for %s consents',
async (status) => {
diff --git a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx
index 1ff7cb23..75feea49 100644
--- a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx
+++ b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx
@@ -24,6 +24,8 @@ import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-rou
import { afterEach, describe, expect, it, vi } from 'vitest'
import ConsentRegistryPage from '../features/consent-registry/ConsentRegistryPage'
import i18n from '../i18n/i18n'
+import TestAuthorizationProvider from './TestAuthorizationProvider'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
const fetchMock = vi.fn()
@@ -60,18 +62,20 @@ function renderConsentRegistryPage(queryClient: QueryClient, initialEntry = '/co
-
-
-
-
-
- >
- }
- />
-
+
+
+
+
+
+
+ >
+ }
+ />
+
+
diff --git a/portal/frontend/src/__tests__/CurrentUserApi.test.ts b/portal/frontend/src/__tests__/CurrentUserApi.test.ts
new file mode 100644
index 00000000..901d0394
--- /dev/null
+++ b/portal/frontend/src/__tests__/CurrentUserApi.test.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { fetchCurrentUser } from '../features/auth/api/currentUserApi'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
+
+const apiClientMocks = vi.hoisted(() => ({
+ apiRequest: vi.fn(),
+}))
+
+vi.mock('../utils/apiClient', () => apiClientMocks)
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('current-user API', () => {
+ it('returns a typed current user from GET /me', async () => {
+ apiClientMocks.apiRequest.mockResolvedValue({
+ userId: 'user-1',
+ organizationId: 'org-1',
+ scopes: [PORTAL_SCOPES.CONSENTS_READ_SELF],
+ })
+
+ await expect(fetchCurrentUser()).resolves.toEqual({
+ userId: 'user-1',
+ organizationId: 'org-1',
+ scopes: [PORTAL_SCOPES.CONSENTS_READ_SELF],
+ })
+ expect(apiClientMocks.apiRequest).toHaveBeenCalledWith('/me', { method: 'GET' })
+ })
+
+ it('rejects unknown scopes and malformed identity data', async () => {
+ apiClientMocks.apiRequest.mockResolvedValue({
+ userId: 'user-1',
+ organizationId: '',
+ scopes: ['portal:unknown'],
+ })
+
+ await expect(fetchCurrentUser()).rejects.toThrow('invalid current-user response')
+ })
+})
diff --git a/portal/frontend/src/__tests__/MainLayout.test.tsx b/portal/frontend/src/__tests__/MainLayout.test.tsx
index 66d7cc7e..de850f47 100644
--- a/portal/frontend/src/__tests__/MainLayout.test.tsx
+++ b/portal/frontend/src/__tests__/MainLayout.test.tsx
@@ -16,7 +16,7 @@
* under the License.
*/
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui'
import { I18nextProvider } from 'react-i18next'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
@@ -111,4 +111,31 @@ describe('MainLayout', () => {
expect(screen.getByText('consent%')).toBeInTheDocument()
})
+
+ it('renders the administration hierarchy for the administrative consent registry', () => {
+ renderHeaderBreadcrumbs('/administration/consents')
+
+ const breadcrumbs = screen.getByRole('navigation', { name: 'Breadcrumb' })
+ expect(within(breadcrumbs).getByText('Home')).toBeInTheDocument()
+ expect(within(breadcrumbs).getByRole('link', { name: 'Administration' })).toHaveAttribute(
+ 'href',
+ '/administration/consents',
+ )
+ expect(within(breadcrumbs).getByText('Consents')).toHaveAttribute('aria-current', 'page')
+ })
+
+ it('decodes administrative consent IDs in breadcrumbs', () => {
+ renderHeaderBreadcrumbs('/administration/consents/consent%2F123%3Fdraft')
+
+ const breadcrumbs = screen.getByRole('navigation', { name: 'Breadcrumb' })
+ expect(within(breadcrumbs).getByRole('link', { name: 'Administration' })).toBeInTheDocument()
+ expect(within(breadcrumbs).getByRole('link', { name: 'Consents' })).toHaveAttribute(
+ 'href',
+ '/administration/consents',
+ )
+ expect(within(breadcrumbs).getByText('consent/123?draft')).toHaveAttribute(
+ 'aria-current',
+ 'page',
+ )
+ })
})
diff --git a/portal/frontend/src/__tests__/PortalScopes.test.ts b/portal/frontend/src/__tests__/PortalScopes.test.ts
new file mode 100644
index 00000000..9222ef3e
--- /dev/null
+++ b/portal/frontend/src/__tests__/PortalScopes.test.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { parse } from 'yaml'
+import { PORTAL_SCOPES } from '../utils/portalScopes'
+
+function asRecord(value: unknown, name: string): Record {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new Error(`${name} must be an object`)
+ }
+ return value as Record
+}
+
+function documentedPortalScopes(): string[] {
+ const openAPIPath = path.resolve(process.cwd(), '../backend/openapi/portal-backend.yaml')
+ const document = asRecord(parse(readFileSync(openAPIPath, 'utf8')) as unknown, 'document')
+ const components = asRecord(document.components, 'components')
+ const securitySchemes = asRecord(components.securitySchemes, 'securitySchemes')
+ const portalOAuth = asRecord(securitySchemes.PortalOAuthDocumentation, 'PortalOAuthDocumentation')
+ const flows = asRecord(portalOAuth.flows, 'flows')
+ const authorizationCode = asRecord(flows.authorizationCode, 'authorizationCode')
+ return Object.keys(asRecord(authorizationCode.scopes, 'scopes'))
+}
+
+describe('portal scope registry', () => {
+ it('exactly matches the backend OpenAPI scope registry', () => {
+ const frontendScopes = Object.values(PORTAL_SCOPES)
+ const documentedScopes = documentedPortalScopes()
+
+ expect(frontendScopes.every((scope) => scope.trim().length > 0)).toBe(true)
+ expect(new Set(frontendScopes).size).toBe(frontendScopes.length)
+ expect(new Set(documentedScopes).size).toBe(documentedScopes.length)
+ expect([...frontendScopes].sort()).toEqual([...documentedScopes].sort())
+ })
+})
diff --git a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx
index a0ee995b..ac3b3c74 100644
--- a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx
+++ b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx
@@ -1,6 +1,19 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
- * Licensed under the Apache License, Version 2.0.
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -49,6 +62,7 @@ function renderDialog(overrides: Partial = {
open: true,
initialValue: undefined,
+ organizationId: 'test-org',
loading: false,
error: undefined,
onClose: vi.fn(),
diff --git a/portal/frontend/src/__tests__/TestAuthorizationProvider.tsx b/portal/frontend/src/__tests__/TestAuthorizationProvider.tsx
new file mode 100644
index 00000000..fb331974
--- /dev/null
+++ b/portal/frontend/src/__tests__/TestAuthorizationProvider.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { ReactNode } from 'react'
+import { AuthorizationProvider } from '../features/auth/AuthorizationProvider'
+import type { PortalScope } from '../utils/portalScopes'
+
+interface TestAuthorizationProviderProps {
+ children: ReactNode
+ scopes: PortalScope[]
+}
+
+function TestAuthorizationProvider({
+ children,
+ scopes,
+}: TestAuthorizationProviderProps): React.JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
+
+export default TestAuthorizationProvider
diff --git a/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx b/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx
index 9dab4336..109593ac 100644
--- a/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx
+++ b/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx
@@ -45,7 +45,48 @@ function buildBreadcrumbItems(
consentsLabel: string,
purposesLabel: string,
elementsLabel: string,
+ administrationLabel: string,
+ administrationConsentsLabel: string,
): BreadcrumbItem[] {
+ const adminConsentDetailsMatch = pathname.match(/^\/administration\/consents\/([^/]+)$/)
+
+ if (adminConsentDetailsMatch) {
+ return [
+ { label: homeLabel, path: '/dashboard', isCurrent: false },
+ {
+ label: administrationLabel,
+ path: '/administration/consents',
+ isCurrent: false,
+ },
+ {
+ label: administrationConsentsLabel,
+ path: '/administration/consents',
+ isCurrent: false,
+ },
+ {
+ label: safeDecodeURIComponent(adminConsentDetailsMatch[1]),
+ path: pathname,
+ isCurrent: true,
+ },
+ ]
+ }
+
+ if (pathname.startsWith('/administration/consents')) {
+ return [
+ { label: homeLabel, path: '/dashboard', isCurrent: false },
+ {
+ label: administrationLabel,
+ path: '/administration/consents',
+ isCurrent: false,
+ },
+ {
+ label: administrationConsentsLabel,
+ path: '/administration/consents',
+ isCurrent: true,
+ },
+ ]
+ }
+
const consentDetailsMatch = pathname.match(/^\/consents\/([^/]+)$/)
if (consentDetailsMatch) {
@@ -135,6 +176,8 @@ function HeaderBreadcrumbs({ currentLabel }: HeaderBreadcrumbsProps): React.JSX.
t('sidebar.allConsents'),
t('sidebar.purposes'),
t('sidebar.elements'),
+ t('sidebar.administration'),
+ t('sidebar.adminConsents'),
).map((item) => (item.isCurrent && currentLabel ? { ...item, label: currentLabel } : item))
return (
@@ -149,7 +192,7 @@ function HeaderBreadcrumbs({ currentLabel }: HeaderBreadcrumbsProps): React.JSX.
{breadcrumbItems.map((item) =>
item.isCurrent ? (
) : (
diff --git a/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx b/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx
index ae54db79..c5e1c945 100644
--- a/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx
+++ b/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx
@@ -17,9 +17,11 @@
*/
import { Sidebar } from '@wso2/oxygen-ui'
-import { Blocks, Clock3, House, ShieldCheck, Target } from '@wso2/oxygen-ui-icons-react'
+import { Blocks, Clock3, House, ShieldCheck, ShieldPlus, Target } from '@wso2/oxygen-ui-icons-react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
+import useAuthorization from '../../../features/auth/useAuthorization'
+import { PORTAL_SCOPES, type PortalScope } from '../../../utils/portalScopes'
interface AppSidebarProps {
collapsed: boolean
@@ -30,6 +32,7 @@ interface SidebarItem {
labelKey: string
path: string
icon: React.JSX.Element
+ requiredScope: PortalScope
}
const DASHBOARD_ITEMS: SidebarItem[] = [
@@ -38,6 +41,7 @@ const DASHBOARD_ITEMS: SidebarItem[] = [
labelKey: 'sidebar.dashboard',
path: '/dashboard',
icon: ,
+ requiredScope: PORTAL_SCOPES.CONSENTS_READ_SELF,
},
]
@@ -47,12 +51,14 @@ const CONSENT_ITEMS: SidebarItem[] = [
labelKey: 'sidebar.allConsents',
path: '/consents',
icon: ,
+ requiredScope: PORTAL_SCOPES.CONSENTS_READ_SELF,
},
{
id: 'pending-consents',
labelKey: 'sidebar.pendingConsents',
path: '/consents?status=Pending',
icon: ,
+ requiredScope: PORTAL_SCOPES.CONSENTS_READ_SELF,
},
]
@@ -62,18 +68,32 @@ const CATALOG_ITEMS: SidebarItem[] = [
labelKey: 'sidebar.purposes',
path: '/purposes',
icon: ,
+ requiredScope: PORTAL_SCOPES.PURPOSES_READ,
},
{
id: 'elements',
labelKey: 'sidebar.elements',
path: '/elements',
icon: ,
+ requiredScope: PORTAL_SCOPES.ELEMENTS_READ,
},
]
-const SIDEBAR_ITEMS: SidebarItem[] = [...DASHBOARD_ITEMS, ...CONSENT_ITEMS, ...CATALOG_ITEMS]
+const ADMINISTRATION_ITEMS: SidebarItem[] = [
+ {
+ id: 'administration-consents',
+ labelKey: 'sidebar.adminConsents',
+ path: '/administration/consents',
+ icon: ,
+ requiredScope: PORTAL_SCOPES.CONSENTS_READ_ANY,
+ },
+]
function mapPathToMenuId(pathname: string, search: string): string {
+ if (pathname.startsWith('/administration/consents')) {
+ return 'administration-consents'
+ }
+
if (pathname.startsWith('/dashboard')) {
return 'dashboard'
}
@@ -103,6 +123,13 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element {
const { t } = useTranslation('common')
const navigate = useNavigate()
const location = useLocation()
+ const { hasScope } = useAuthorization()
+
+ const dashboardItems = DASHBOARD_ITEMS.filter((item) => hasScope(item.requiredScope))
+ const consentItems = CONSENT_ITEMS.filter((item) => hasScope(item.requiredScope))
+ const catalogItems = CATALOG_ITEMS.filter((item) => hasScope(item.requiredScope))
+ const administrationItems = ADMINISTRATION_ITEMS.filter((item) => hasScope(item.requiredScope))
+ const visibleItems = [...dashboardItems, ...consentItems, ...catalogItems, ...administrationItems]
const activeItem = mapPathToMenuId(location.pathname, location.search)
@@ -111,7 +138,7 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element {
collapsed={collapsed}
activeItem={activeItem}
onSelect={(id) => {
- const selectedItem = SIDEBAR_ITEMS.find((item) => item.id === id)
+ const selectedItem = visibleItems.find((item) => item.id === id)
if (selectedItem) {
navigate(selectedItem.path)
@@ -120,34 +147,52 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element {
aria-label={t('sidebar.ariaLabel')}
>
-
- {DASHBOARD_ITEMS.map((item) => (
-
- {item.icon}
- {t(item.labelKey)}
-
- ))}
-
-
-
- {t('sidebar.consent')}
- {CONSENT_ITEMS.map((item) => (
-
- {item.icon}
- {t(item.labelKey)}
-
- ))}
-
-
-
- {t('sidebar.catalog')}
- {CATALOG_ITEMS.map((item) => (
-
- {item.icon}
- {t(item.labelKey)}
-
- ))}
-
+ {dashboardItems.length > 0 ? (
+
+ {dashboardItems.map((item) => (
+
+ {item.icon}
+ {t(item.labelKey)}
+
+ ))}
+
+ ) : null}
+
+ {consentItems.length > 0 ? (
+
+ {t('sidebar.consent')}
+ {consentItems.map((item) => (
+
+ {item.icon}
+ {t(item.labelKey)}
+
+ ))}
+
+ ) : null}
+
+ {administrationItems.length > 0 ? (
+
+ {t('sidebar.administration')}
+ {administrationItems.map((item) => (
+
+ {item.icon}
+ {t(item.labelKey)}
+
+ ))}
+
+ ) : null}
+
+ {catalogItems.length > 0 ? (
+
+ {t('sidebar.catalog')}
+ {catalogItems.map((item) => (
+
+ {item.icon}
+ {t(item.labelKey)}
+
+ ))}
+
+ ) : null}
)
diff --git a/portal/frontend/src/features/admin-consents/AdminConsentRegistryPage.tsx b/portal/frontend/src/features/admin-consents/AdminConsentRegistryPage.tsx
new file mode 100644
index 00000000..1fae1c75
--- /dev/null
+++ b/portal/frontend/src/features/admin-consents/AdminConsentRegistryPage.tsx
@@ -0,0 +1,243 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Box, Button, Chip, Stack, Typography } from '@wso2/oxygen-ui'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useSearchParams } from 'react-router-dom'
+import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs'
+import type {
+ AdminConsentRegistryFilters,
+ ConsentRegistrySortDirection,
+ ConsentRegistrySortField,
+} from '../../types/consent'
+import { PORTAL_SCOPES } from '../../utils/portalScopes'
+import useAuthorization from '../auth/useAuthorization'
+import ConsentRegistryTable from '../consent-registry/components/ConsentRegistryTable'
+import ConsentRevocationDialog from '../consent-registry/components/ConsentRevocationDialog'
+import { CONSENT_REGISTRY_ROWS_PER_PAGE_OPTIONS } from '../consent-registry/constants'
+import AdminConsentFilters from './components/AdminConsentFilters'
+import {
+ useAdminConsentListQuery,
+ useAdminRevokeConsentMutation,
+} from './hooks/useAdminConsentQueries'
+import {
+ EMPTY_ADMIN_CONSENT_FILTERS,
+ getAdminConsentFilters,
+ normalizeAdminConsentFilters,
+} from './utils/adminConsentFilters'
+
+const DEFAULT_PAGE = 0
+const DEFAULT_ROWS_PER_PAGE = 10
+const DEFAULT_SORT_FIELD: ConsentRegistrySortField = 'updatedTime'
+const DEFAULT_SORT_DIRECTION: ConsentRegistrySortDirection = 'desc'
+const SORT_FIELDS: ConsentRegistrySortField[] = ['status', 'updatedTime', 'validityTime']
+
+function getPage(searchParams: URLSearchParams): number {
+ const value = Number(searchParams.get('page') ?? '1')
+ return Number.isInteger(value) && value > 0 ? value - 1 : DEFAULT_PAGE
+}
+
+function getRowsPerPage(searchParams: URLSearchParams): number {
+ const value = Number(searchParams.get('rowsPerPage') ?? String(DEFAULT_ROWS_PER_PAGE))
+ return CONSENT_REGISTRY_ROWS_PER_PAGE_OPTIONS.includes(
+ value as (typeof CONSENT_REGISTRY_ROWS_PER_PAGE_OPTIONS)[number],
+ )
+ ? value
+ : DEFAULT_ROWS_PER_PAGE
+}
+
+function getSort(searchParams: URLSearchParams): {
+ field: ConsentRegistrySortField
+ direction: ConsentRegistrySortDirection
+} {
+ const value = searchParams.get('sort')
+ if (!value || value.includes(',')) {
+ return { field: DEFAULT_SORT_FIELD, direction: DEFAULT_SORT_DIRECTION }
+ }
+ const [field, direction = DEFAULT_SORT_DIRECTION, ...extra] = value.split(':')
+ return SORT_FIELDS.includes(field as ConsentRegistrySortField) &&
+ (direction === 'asc' || direction === 'desc') &&
+ extra.length === 0
+ ? { field: field as ConsentRegistrySortField, direction }
+ : { field: DEFAULT_SORT_FIELD, direction: DEFAULT_SORT_DIRECTION }
+}
+
+function toSearchParams(
+ rawFilters: AdminConsentRegistryFilters,
+ page = DEFAULT_PAGE,
+ rowsPerPage = DEFAULT_ROWS_PER_PAGE,
+ sortField = DEFAULT_SORT_FIELD,
+ sortDirection = DEFAULT_SORT_DIRECTION,
+): URLSearchParams {
+ const filters = normalizeAdminConsentFilters(rawFilters)
+ const params = new URLSearchParams()
+ Object.entries(filters).forEach(([key, value]) => {
+ if (key === 'status' ? value !== 'All' : Boolean(value)) params.set(key, value)
+ })
+ if (page > 0) params.set('page', String(page + 1))
+ if (rowsPerPage !== DEFAULT_ROWS_PER_PAGE) params.set('rowsPerPage', String(rowsPerPage))
+ if (sortField !== DEFAULT_SORT_FIELD || sortDirection !== DEFAULT_SORT_DIRECTION) {
+ params.set('sort', `${sortField}:${sortDirection}`)
+ }
+ return params
+}
+
+export default function AdminConsentRegistryPage(): React.JSX.Element {
+ const { t } = useTranslation('common')
+ const [searchParams, setSearchParams] = useSearchParams()
+ const [selectedRevocationConsentID, setSelectedRevocationConsentID] = useState()
+ const filters = useMemo(() => getAdminConsentFilters(searchParams), [searchParams])
+ const page = useMemo(() => getPage(searchParams), [searchParams])
+ const rowsPerPage = useMemo(() => getRowsPerPage(searchParams), [searchParams])
+ const sort = useMemo(() => getSort(searchParams), [searchParams])
+ const consentListQuery = useAdminConsentListQuery(
+ filters,
+ page,
+ rowsPerPage,
+ sort.field,
+ sort.direction,
+ )
+ const revokeMutation = useAdminRevokeConsentMutation()
+ const { currentUser, hasScope } = useAuthorization()
+ const canWriteAny = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_ANY)
+ const canReadElements = hasScope(PORTAL_SCOPES.ELEMENTS_READ)
+
+ const updateParams = (
+ nextFilters: AdminConsentRegistryFilters,
+ nextPage = DEFAULT_PAGE,
+ nextRowsPerPage = rowsPerPage,
+ sortField = sort.field,
+ sortDirection = sort.direction,
+ ): void => {
+ setSearchParams(
+ toSearchParams(nextFilters, nextPage, nextRowsPerPage, sortField, sortDirection),
+ { replace: true },
+ )
+ }
+
+ const activeFilters = Object.entries(filters).filter(([key, value]) => {
+ if (key === 'status') return value !== 'All'
+ return Boolean(value)
+ }) as Array<[keyof AdminConsentRegistryFilters, string]>
+
+ const filterLabels: Record = {
+ status: t('consentRegistry.filters.status'),
+ consentId: t('consentRegistry.details.consentId'),
+ purposeName: t('catalog.fields.purposeName'),
+ purposeVersion: t('catalog.fields.purposeVersion'),
+ userIds: t('adminConsents.filters.userIds'),
+ groupIds: t('adminConsents.filters.groupIds'),
+ elementName: t('catalog.fields.elementName'),
+ elementNamespace: t('catalog.fields.elementNamespace'),
+ elementVersion: t('catalog.fields.elementVersion'),
+ startDate: t('consentRegistry.filters.startDate'),
+ endDate: t('consentRegistry.filters.endDate'),
+ }
+
+ const removeFilter = (key: keyof AdminConsentRegistryFilters): void => {
+ const nextFilters = {
+ ...filters,
+ [key]: key === 'status' ? 'All' : '',
+ } as AdminConsentRegistryFilters
+ if (key === 'purposeName') nextFilters.purposeVersion = ''
+ if (key === 'elementName' && !filters.elementNamespace) nextFilters.elementVersion = ''
+ if (key === 'elementNamespace' && !filters.elementName) nextFilters.elementVersion = ''
+ updateParams(nextFilters)
+ }
+
+ return (
+
+
+
+
+
+ {t('adminConsents.title')}
+
+
+
+ updateParams(nextFilters)}
+ onClear={() => updateParams(EMPTY_ADMIN_CONSENT_FILTERS)}
+ />
+
+ {activeFilters.length > 0 ? (
+
+
+ {t('adminConsents.filters.active')}
+
+ {activeFilters.map(([key, value]) => (
+ removeFilter(key)}
+ />
+ ))}
+
+
+ ) : null}
+
+ updateParams(filters, nextPage)}
+ onRowsPerPageChange={(nextRowsPerPage) =>
+ updateParams(filters, DEFAULT_PAGE, nextRowsPerPage)
+ }
+ onSortChange={(sortField, sortDirection) =>
+ updateParams(filters, DEFAULT_PAGE, rowsPerPage, sortField, sortDirection)
+ }
+ onRetry={() => consentListQuery.refetch()}
+ onRevoke={setSelectedRevocationConsentID}
+ isMutating={revokeMutation.isPending}
+ />
+
+ {selectedRevocationConsentID ? (
+ setSelectedRevocationConsentID(undefined)}
+ onConfirm={() =>
+ revokeMutation.mutate(
+ { consentID: selectedRevocationConsentID, actionBy: currentUser.userId },
+ { onSuccess: () => setSelectedRevocationConsentID(undefined) },
+ )
+ }
+ />
+ ) : null}
+
+
+ )
+}
diff --git a/portal/frontend/src/features/admin-consents/api/adminConsentsApi.ts b/portal/frontend/src/features/admin-consents/api/adminConsentsApi.ts
new file mode 100644
index 00000000..d06abc95
--- /dev/null
+++ b/portal/frontend/src/features/admin-consents/api/adminConsentsApi.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type {
+ ConsentDetailAPI,
+ ConsentListQueryParams,
+ ConsentSearchResponse,
+} from '../../../types/consent'
+import { apiRequest } from '../../../utils/apiClient'
+
+export async function fetchAdminConsents(
+ params: ConsentListQueryParams,
+): Promise {
+ return apiRequest('/api/consents', {
+ method: 'GET',
+ query: {
+ consentStatuses: params.consentStatuses,
+ userIds: params.userIds,
+ groupIds: params.groupIds,
+ purposeName: params.purposeName,
+ purposeVersion: params.purposeVersion,
+ elementName: params.elementName,
+ elementNamespace: params.elementNamespace,
+ elementVersion: params.elementVersion,
+ sort: params.sort,
+ fromTime: params.fromTime,
+ toTime: params.toTime,
+ limit: params.limit,
+ offset: params.offset,
+ details: true,
+ },
+ })
+}
+
+export async function fetchAdminConsentByID(consentID: string): Promise {
+ return apiRequest(`/api/consents/${encodeURIComponent(consentID)}`, {
+ method: 'GET',
+ query: { details: true, includeStatusHistory: true },
+ })
+}
+
+export async function revokeAdminConsent(consentID: string, actionBy: string): Promise {
+ return apiRequest(`/api/consents/${encodeURIComponent(consentID)}/revoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ actionBy }),
+ })
+}
diff --git a/portal/frontend/src/features/admin-consents/components/AdminConsentFilters.tsx b/portal/frontend/src/features/admin-consents/components/AdminConsentFilters.tsx
new file mode 100644
index 00000000..0b17671d
--- /dev/null
+++ b/portal/frontend/src/features/admin-consents/components/AdminConsentFilters.tsx
@@ -0,0 +1,438 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ AdapterDateFns,
+ Autocomplete,
+ Box,
+ Button,
+ DatePickers,
+ FormControl,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Popover,
+ SearchBar,
+ Select,
+ Stack,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@wso2/oxygen-ui'
+import { ListFilter } from '@wso2/oxygen-ui-icons-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { AdminConsentRegistryFilters } from '../../../types/consent'
+import type { ElementSummary } from '../../../types/catalog'
+import ElementVersionSelect from '../../catalog/components/ElementVersionSelect'
+import { useElementOptionsQuery } from '../../catalog/hooks/useCatalogQueries'
+import {
+ EMPTY_ADMIN_CONSENT_FILTERS,
+ normalizeAdminConsentFilters,
+} from '../utils/adminConsentFilters'
+
+interface AdminConsentFiltersProps {
+ filters: AdminConsentRegistryFilters
+ canReadElements: boolean
+ onFilterChange: (filters: AdminConsentRegistryFilters) => void
+ onClear: () => void
+}
+
+const MAIN_FILTER_HEIGHT = 40
+
+function parseDate(value: string): Date | null {
+ if (!value) return null
+ const [year, month, day] = value.split('-').map(Number)
+ const date = new Date(year, month - 1, day)
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
+ ? date
+ : null
+}
+
+function formatDate(value: Date | null): string {
+ if (!value) return ''
+ return [
+ value.getFullYear(),
+ String(value.getMonth() + 1).padStart(2, '0'),
+ String(value.getDate()).padStart(2, '0'),
+ ].join('-')
+}
+
+function elementLabel(element: ElementSummary): string {
+ return `${element.displayName ?? element.name} (${element.namespace})`
+}
+
+export default function AdminConsentFilters({
+ filters,
+ canReadElements,
+ onFilterChange,
+ onClear,
+}: AdminConsentFiltersProps): React.JSX.Element {
+ const { t } = useTranslation('common')
+ const [draft, setDraft] = useState(filters)
+ const [filtersAnchor, setFiltersAnchor] = useState(null)
+ const filtersOpen = Boolean(filtersAnchor)
+ const elementOptionsQuery = useElementOptionsQuery(filtersOpen && canReadElements)
+ const elementOptions = elementOptionsQuery.data?.data ?? []
+ const selectedElement =
+ elementOptions.find(
+ (element) =>
+ element.name === draft.elementName && element.namespace === draft.elementNamespace,
+ ) ?? null
+ const advancedFilterCount = [
+ filters.purposeName,
+ filters.userIds,
+ filters.groupIds,
+ filters.purposeVersion,
+ filters.elementName,
+ filters.elementNamespace,
+ filters.elementVersion,
+ filters.startDate,
+ filters.endDate,
+ ].filter(Boolean).length
+
+ const applyFilters = (next: AdminConsentRegistryFilters): void => {
+ const normalized = normalizeAdminConsentFilters(next)
+ setDraft(normalized)
+ onFilterChange(normalized)
+ }
+
+ const cancelAdvancedChanges = (): void => {
+ setDraft(filters)
+ setFiltersAnchor(null)
+ }
+
+ return (
+
+
+
+ setDraft({ ...draft, consentId: event.target.value })}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') applyFilters(draft)
+ }}
+ sx={{ '& .MuiInputBase-root': { height: MAIN_FILTER_HEIGHT, pr: 6 } }}
+ />
+
+
+
+ {
+ setDraft(filters)
+ setFiltersAnchor(event.currentTarget)
+ }}
+ >
+
+
+
+
+ {advancedFilterCount > 0 ? (
+
+ {advancedFilterCount}
+
+ ) : null}
+
+
+
+
+
+
+ {t('consentRegistry.filters.status')}
+
+
+
+
+
+
+
+
+
+
+ {t('consentRegistry.filters.advanced')}
+
+
+ setDraft({ ...draft, userIds: event.target.value })}
+ />
+ setDraft({ ...draft, groupIds: event.target.value })}
+ />
+
+
+ {
+ const purposeName = event.target.value
+ setDraft({
+ ...draft,
+ purposeName,
+ purposeVersion: purposeName.trim() ? draft.purposeVersion : '',
+ })
+ }}
+ />
+
+
+ setDraft({ ...draft, purposeVersion: event.target.value })}
+ />
+
+
+
+
+ {canReadElements ? (
+ option.elementId === value.elementId}
+ onChange={(_, selected) =>
+ setDraft({
+ ...draft,
+ elementName: selected?.name ?? '',
+ elementNamespace: selected?.namespace ?? '',
+ elementVersion: '',
+ })
+ }
+ renderInput={(params) => (
+ // Oxygen UI Autocomplete requires forwarding its generated input props.
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+ )}
+ />
+ ) : (
+ {
+ const elementName = event.target.value
+ setDraft({
+ ...draft,
+ elementName,
+ elementVersion:
+ elementName.trim() || draft.elementNamespace.trim()
+ ? draft.elementVersion
+ : '',
+ })
+ }}
+ />
+ )}
+ {
+ const elementNamespace = event.target.value
+ setDraft({
+ ...draft,
+ elementNamespace,
+ elementVersion:
+ draft.elementName.trim() || elementNamespace.trim() ? draft.elementVersion : '',
+ })
+ }}
+ />
+ {canReadElements && selectedElement ? (
+ setDraft({ ...draft, elementVersion })}
+ />
+ ) : (
+
+
+ setDraft({ ...draft, elementVersion: event.target.value })}
+ />
+
+
+ )}
+
+
+
+ setDraft({ ...draft, startDate: formatDate(value) })}
+ slotProps={{ textField: { size: 'small', fullWidth: true } }}
+ />
+ setDraft({ ...draft, endDate: formatDate(value) })}
+ slotProps={{ textField: { size: 'small', fullWidth: true } }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/portal/frontend/src/features/admin-consents/hooks/useAdminConsentQueries.ts b/portal/frontend/src/features/admin-consents/hooks/useAdminConsentQueries.ts
new file mode 100644
index 00000000..68630841
--- /dev/null
+++ b/portal/frontend/src/features/admin-consents/hooks/useAdminConsentQueries.ts
@@ -0,0 +1,220 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ keepPreviousData,
+ queryOptions,
+ type UseMutationResult,
+ type UseQueryResult,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
+import { useEffect } from 'react'
+import type {
+ AdminConsentRegistryFilters,
+ ConsentDetailAPI,
+ ConsentListQueryParams,
+ ConsentRecord,
+ ConsentRegistrySortDirection,
+ ConsentRegistrySortField,
+} from '../../../types/consent'
+import { isConsentAPIStatus } from '../../../types/consent'
+import { PORTAL_SCOPES } from '../../../utils/portalScopes'
+import {
+ toEndOfDayEpochMilliseconds,
+ toEpochMilliseconds,
+ toStartOfDayEpochMilliseconds,
+} from '../../../utils/dateTime'
+import useAuthorization from '../../auth/useAuthorization'
+import {
+ isConsentRevokableStatus,
+ normalizeConsentStatus,
+} from '../../consent-registry/utils/statusChip'
+import {
+ fetchAdminConsentByID,
+ fetchAdminConsents,
+ revokeAdminConsent,
+} from '../api/adminConsentsApi'
+
+interface AdminConsentListResult {
+ rows: ConsentRecord[]
+ total: number
+}
+
+function toListParams(
+ filters: AdminConsentRegistryFilters,
+ page: number,
+ rowsPerPage: number,
+ sortField: ConsentRegistrySortField,
+ sortDirection: ConsentRegistrySortDirection,
+): ConsentListQueryParams {
+ const statusFilterMap: Record, string> = {
+ Active: 'ACTIVE',
+ Pending: 'CREATED',
+ Rejected: 'REJECTED',
+ Revoked: 'REVOKED',
+ Expired: 'EXPIRED',
+ }
+
+ return {
+ sort: `${sortField}:${sortDirection}`,
+ consentStatuses: filters.status === 'All' ? undefined : statusFilterMap[filters.status],
+ userIds: filters.userIds || undefined,
+ groupIds: filters.groupIds || undefined,
+ purposeName: filters.purposeName.trim() || undefined,
+ purposeVersion: filters.purposeName.trim()
+ ? filters.purposeVersion.trim() || undefined
+ : undefined,
+ elementName: filters.elementName.trim() || undefined,
+ elementNamespace: filters.elementNamespace.trim() || undefined,
+ elementVersion:
+ filters.elementName.trim() || filters.elementNamespace.trim()
+ ? filters.elementVersion.trim() || undefined
+ : undefined,
+ fromTime: toStartOfDayEpochMilliseconds(filters.startDate),
+ toTime: toEndOfDayEpochMilliseconds(filters.endDate),
+ limit: rowsPerPage,
+ offset: page * rowsPerPage,
+ }
+}
+
+function toAdminConsentRow(consent: ConsentDetailAPI, canWriteAny: boolean): ConsentRecord {
+ const normalizedStatus = normalizeConsentStatus(consent.status)
+ if (!isConsentAPIStatus(normalizedStatus)) {
+ throw new Error(`Unsupported consent status received from API: ${consent.status}`)
+ }
+
+ return {
+ id: consent.id,
+ groupId: consent.groupId,
+ type: consent.type,
+ status: normalizedStatus,
+ purposes: consent.purposes.map((purpose) => purpose.displayName ?? purpose.name),
+ updatedAt: new Date(toEpochMilliseconds(consent.updatedTime) ?? 0).toISOString(),
+ expirationTime: consent.expirationTime ?? 0,
+ canApprove: false,
+ canRevoke: canWriteAny && isConsentRevokableStatus(normalizedStatus),
+ }
+}
+
+function adminConsentListQueryOptions(
+ filters: AdminConsentRegistryFilters,
+ page: number,
+ rowsPerPage: number,
+ sortField: ConsentRegistrySortField,
+ sortDirection: ConsentRegistrySortDirection,
+ canWriteAny: boolean,
+) {
+ const consentID = filters.consentId.trim()
+ const params = toListParams(filters, page, rowsPerPage, sortField, sortDirection)
+ return queryOptions({
+ queryKey: ['admin-consents', { consentID, params }, { canWriteAny }],
+ queryFn: async (): Promise => {
+ if (consentID) {
+ const consent = await fetchAdminConsentByID(consentID)
+ return {
+ rows: [toAdminConsentRow(consent, canWriteAny)],
+ total: 1,
+ }
+ }
+ const response = await fetchAdminConsents(params)
+ return {
+ rows: response.data.map((consent) => toAdminConsentRow(consent, canWriteAny)),
+ total: response.metadata.total,
+ }
+ },
+ placeholderData: keepPreviousData,
+ })
+}
+
+export function useAdminConsentListQuery(
+ filters: AdminConsentRegistryFilters,
+ page: number,
+ rowsPerPage: number,
+ sortField: ConsentRegistrySortField,
+ sortDirection: ConsentRegistrySortDirection,
+): UseQueryResult {
+ const queryClient = useQueryClient()
+ const { hasScope } = useAuthorization()
+ const canWriteAny = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_ANY)
+ const query = useQuery(
+ adminConsentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection, canWriteAny),
+ )
+
+ useEffect(() => {
+ const nextPage = page + 1
+ const hasNextPage = nextPage * rowsPerPage < (query.data?.total ?? 0)
+ if (!query.isPlaceholderData && hasNextPage) {
+ queryClient
+ .prefetchQuery(
+ adminConsentListQueryOptions(
+ filters,
+ nextPage,
+ rowsPerPage,
+ sortField,
+ sortDirection,
+ canWriteAny,
+ ),
+ )
+ .catch(() => undefined)
+ }
+ }, [
+ canWriteAny,
+ filters,
+ page,
+ query.data?.total,
+ query.isPlaceholderData,
+ queryClient,
+ rowsPerPage,
+ sortDirection,
+ sortField,
+ ])
+
+ return query
+}
+
+export function useAdminConsentDetailQuery(
+ consentID: string | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ['admin-consent', consentID],
+ queryFn: () => fetchAdminConsentByID(String(consentID)),
+ enabled: Boolean(consentID),
+ })
+}
+
+interface AdminRevokeVariables {
+ consentID: string
+ actionBy: string
+}
+
+export function useAdminRevokeConsentMutation(): UseMutationResult<
+ unknown,
+ Error,
+ AdminRevokeVariables
+> {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ consentID, actionBy }) => revokeAdminConsent(consentID, actionBy),
+ onSuccess: async (_data, variables) => {
+ await queryClient.invalidateQueries({ queryKey: ['admin-consents'] })
+ await queryClient.invalidateQueries({ queryKey: ['admin-consent', variables.consentID] })
+ },
+ })
+}
diff --git a/portal/frontend/src/features/admin-consents/utils/adminConsentFilters.ts b/portal/frontend/src/features/admin-consents/utils/adminConsentFilters.ts
new file mode 100644
index 00000000..233b0434
--- /dev/null
+++ b/portal/frontend/src/features/admin-consents/utils/adminConsentFilters.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { AdminConsentRegistryFilters } from '../../../types/consent'
+
+export const EMPTY_ADMIN_CONSENT_FILTERS: AdminConsentRegistryFilters = {
+ status: 'All',
+ consentId: '',
+ purposeName: '',
+ purposeVersion: '',
+ userIds: '',
+ groupIds: '',
+ elementName: '',
+ elementNamespace: '',
+ elementVersion: '',
+ startDate: '',
+ endDate: '',
+}
+
+const VALID_STATUSES: AdminConsentRegistryFilters['status'][] = [
+ 'All',
+ 'Active',
+ 'Pending',
+ 'Rejected',
+ 'Revoked',
+ 'Expired',
+]
+
+export function normalizeCommaSeparatedIDs(value: string): string {
+ return Array.from(
+ new Set(
+ value
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean),
+ ),
+ ).join(',')
+}
+
+export function normalizeAdminConsentFilters(
+ filters: AdminConsentRegistryFilters,
+): AdminConsentRegistryFilters {
+ const purposeName = filters.purposeName.trim()
+ const elementName = filters.elementName.trim()
+ const elementNamespace = filters.elementNamespace.trim()
+ return {
+ ...filters,
+ consentId: filters.consentId.trim(),
+ purposeName,
+ purposeVersion: purposeName ? filters.purposeVersion.trim() : '',
+ userIds: normalizeCommaSeparatedIDs(filters.userIds),
+ groupIds: normalizeCommaSeparatedIDs(filters.groupIds),
+ elementName,
+ elementNamespace,
+ elementVersion: elementName || elementNamespace ? filters.elementVersion.trim() : '',
+ }
+}
+
+export function getAdminConsentFilters(searchParams: URLSearchParams): AdminConsentRegistryFilters {
+ const status = searchParams.get('status')
+ return normalizeAdminConsentFilters({
+ status: VALID_STATUSES.includes(status as AdminConsentRegistryFilters['status'])
+ ? (status as AdminConsentRegistryFilters['status'])
+ : 'All',
+ consentId: searchParams.get('consentId') ?? '',
+ purposeName: searchParams.get('purposeName') ?? '',
+ purposeVersion: searchParams.get('purposeVersion') ?? '',
+ userIds: searchParams.get('userIds') ?? '',
+ groupIds: searchParams.get('groupIds') ?? '',
+ elementName: searchParams.get('elementName') ?? '',
+ elementNamespace: searchParams.get('elementNamespace') ?? '',
+ elementVersion: searchParams.get('elementVersion') ?? '',
+ startDate: searchParams.get('startDate') ?? '',
+ endDate: searchParams.get('endDate') ?? '',
+ })
+}
diff --git a/portal/frontend/src/features/auth/AuthorizationProvider.tsx b/portal/frontend/src/features/auth/AuthorizationProvider.tsx
new file mode 100644
index 00000000..151a7691
--- /dev/null
+++ b/portal/frontend/src/features/auth/AuthorizationProvider.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useMemo, type ReactNode } from 'react'
+import type { CurrentUser } from '../../types/auth'
+import type { PortalScope } from '../../utils/portalScopes'
+import AuthorizationContext from './authorizationContext'
+
+interface AuthorizationProviderProps {
+ currentUser: CurrentUser
+ children: ReactNode
+}
+
+export function AuthorizationProvider({
+ currentUser,
+ children,
+}: AuthorizationProviderProps): React.JSX.Element {
+ const grantedScopes = useMemo(
+ () => new Set(currentUser.scopes),
+ [currentUser.scopes],
+ )
+ const hasScope = useCallback(
+ (scope: PortalScope): boolean => grantedScopes.has(scope),
+ [grantedScopes],
+ )
+ const hasAnyScope = useCallback(
+ (scopes: readonly PortalScope[]): boolean => scopes.some((scope) => grantedScopes.has(scope)),
+ [grantedScopes],
+ )
+ const hasAllScopes = useCallback(
+ (scopes: readonly PortalScope[]): boolean => scopes.every((scope) => grantedScopes.has(scope)),
+ [grantedScopes],
+ )
+ const value = useMemo(
+ () => ({ currentUser, hasScope, hasAnyScope, hasAllScopes }),
+ [currentUser, hasAllScopes, hasAnyScope, hasScope],
+ )
+
+ return {children}
+}
diff --git a/portal/frontend/src/features/auth/NoAccessPage.tsx b/portal/frontend/src/features/auth/NoAccessPage.tsx
new file mode 100644
index 00000000..0d5611ab
--- /dev/null
+++ b/portal/frontend/src/features/auth/NoAccessPage.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Box, Stack, Typography } from '@wso2/oxygen-ui'
+import { ShieldX } from '@wso2/oxygen-ui-icons-react'
+import { useTranslation } from 'react-i18next'
+
+function NoAccessPage(): React.JSX.Element {
+ const { t } = useTranslation('common')
+
+ return (
+
+
+
+
+ {t('authorization.noAccessTitle')}
+
+ {t('authorization.noAccessDescription')}
+
+
+ )
+}
+
+export default NoAccessPage
diff --git a/portal/frontend/src/features/auth/ScopeGuard.tsx b/portal/frontend/src/features/auth/ScopeGuard.tsx
new file mode 100644
index 00000000..74058804
--- /dev/null
+++ b/portal/frontend/src/features/auth/ScopeGuard.tsx
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { PortalScope } from '../../utils/portalScopes'
+import useAuthorization from './useAuthorization'
+
+interface ScopeGuardProps {
+ scope: PortalScope
+ children: React.JSX.Element
+}
+
+function ScopeGuard({ scope, children }: ScopeGuardProps): React.JSX.Element | null {
+ const { hasScope } = useAuthorization()
+ return hasScope(scope) ? children : null
+}
+
+export default ScopeGuard
diff --git a/portal/frontend/src/features/auth/api/currentUserApi.ts b/portal/frontend/src/features/auth/api/currentUserApi.ts
new file mode 100644
index 00000000..0283ef01
--- /dev/null
+++ b/portal/frontend/src/features/auth/api/currentUserApi.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { CurrentUser } from '../../../types/auth'
+import { apiRequest } from '../../../utils/apiClient'
+import { isPortalScope } from '../../../utils/portalScopes'
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function parseCurrentUser(value: unknown): CurrentUser {
+ if (
+ !isRecord(value) ||
+ typeof value.userId !== 'string' ||
+ !value.userId.trim() ||
+ typeof value.organizationId !== 'string' ||
+ !value.organizationId.trim() ||
+ !Array.isArray(value.scopes) ||
+ !value.scopes.every(isPortalScope)
+ ) {
+ throw new Error('invalid current-user response')
+ }
+
+ return {
+ userId: value.userId,
+ organizationId: value.organizationId,
+ scopes: value.scopes,
+ }
+}
+
+export async function fetchCurrentUser(): Promise {
+ return parseCurrentUser(await apiRequest('/me', { method: 'GET' }))
+}
diff --git a/portal/frontend/src/features/auth/authorizationContext.ts b/portal/frontend/src/features/auth/authorizationContext.ts
new file mode 100644
index 00000000..916d46d1
--- /dev/null
+++ b/portal/frontend/src/features/auth/authorizationContext.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createContext } from 'react'
+import type { CurrentUser } from '../../types/auth'
+import type { PortalScope } from '../../utils/portalScopes'
+
+export interface AuthorizationContextValue {
+ currentUser: CurrentUser
+ hasScope: (scope: PortalScope) => boolean
+ hasAnyScope: (scopes: readonly PortalScope[]) => boolean
+ hasAllScopes: (scopes: readonly PortalScope[]) => boolean
+}
+
+const AuthorizationContext = createContext(undefined)
+
+export default AuthorizationContext
diff --git a/portal/frontend/src/features/auth/authorizationRoutes.ts b/portal/frontend/src/features/auth/authorizationRoutes.ts
new file mode 100644
index 00000000..7442b8f9
--- /dev/null
+++ b/portal/frontend/src/features/auth/authorizationRoutes.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { PORTAL_SCOPES, type PortalScope } from '../../utils/portalScopes'
+
+const AUTHORIZED_DESTINATIONS: ReadonlyArray<{ path: string; scope: PortalScope }> = [
+ { path: '/dashboard', scope: PORTAL_SCOPES.CONSENTS_READ_SELF },
+ { path: '/consents', scope: PORTAL_SCOPES.CONSENTS_READ_SELF },
+ { path: '/purposes', scope: PORTAL_SCOPES.PURPOSES_READ },
+ { path: '/elements', scope: PORTAL_SCOPES.ELEMENTS_READ },
+ { path: '/administration/consents', scope: PORTAL_SCOPES.CONSENTS_READ_ANY },
+]
+
+export default function firstAuthorizedPath(scopes: readonly PortalScope[]): string | undefined {
+ const granted = new Set(scopes)
+ return AUTHORIZED_DESTINATIONS.find(({ scope }) => granted.has(scope))?.path
+}
diff --git a/portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts b/portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts
new file mode 100644
index 00000000..697928ff
--- /dev/null
+++ b/portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { type UseQueryResult, useQuery } from '@tanstack/react-query'
+import type { CurrentUser } from '../../../types/auth'
+import { fetchCurrentUser } from '../api/currentUserApi'
+
+export const CURRENT_USER_QUERY_KEY = ['current-user'] as const
+
+export default function useCurrentUserQuery(enabled: boolean): UseQueryResult {
+ return useQuery({
+ queryKey: CURRENT_USER_QUERY_KEY,
+ queryFn: fetchCurrentUser,
+ enabled,
+ retry: false,
+ })
+}
diff --git a/portal/frontend/src/features/auth/useAuthorization.ts b/portal/frontend/src/features/auth/useAuthorization.ts
new file mode 100644
index 00000000..9ab8e72d
--- /dev/null
+++ b/portal/frontend/src/features/auth/useAuthorization.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useContext } from 'react'
+import AuthorizationContext, { type AuthorizationContextValue } from './authorizationContext'
+
+export default function useAuthorization(): AuthorizationContextValue {
+ const value = useContext(AuthorizationContext)
+ if (!value) {
+ throw new Error('useAuthorization must be used within AuthorizationProvider')
+ }
+ return value
+}
diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx
index 17bf1869..5c6f5e18 100644
--- a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx
+++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx
@@ -58,8 +58,10 @@ import { useTranslation } from 'react-i18next'
import { useNavigate, useParams } from 'react-router-dom'
import CopyableText from '../../components/CopyableText'
import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs'
+import useAuthorization from '../auth/useAuthorization'
import type { ElementVersionItem } from '../../types/catalog'
import { formatEpochTimestamp } from '../../utils/dateTime'
+import { PORTAL_SCOPES } from '../../utils/portalScopes'
import DeleteVersionDialog from './components/DeleteVersionDialog'
import ElementFormDialog from './components/ElementFormDialog'
import ElementTypeChip from './components/ElementTypeChip'
@@ -128,6 +130,8 @@ function ElementDetailsPage(): React.JSX.Element {
const [selectedVersion, setSelectedVersion] = useState()
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
const [deleteVersion, setDeleteVersion] = useState()
+ const { hasScope } = useAuthorization()
+ const canWriteElements = hasScope(PORTAL_SCOPES.ELEMENTS_WRITE)
const detail = detailQuery.data
const latestVersion = useMemo(
@@ -223,13 +227,15 @@ function ElementDetailsPage(): React.JSX.Element {
))}
- }
- onClick={() => setVersionDialogOpen(true)}
- >
- {t('catalog.elements.newVersion')}
-
+ {canWriteElements ? (
+ }
+ onClick={() => setVersionDialogOpen(true)}
+ >
+ {t('catalog.elements.newVersion')}
+
+ ) : null}
@@ -489,18 +495,20 @@ function ElementDetailsPage(): React.JSX.Element {
{version.description ?? '-'}
{formatEpochTimestamp(version.createdTime)}
-
- {
- event.stopPropagation()
- setDeleteVersion(version.version)
- }}
- >
-
-
-
+ {canWriteElements ? (
+
+ {
+ event.stopPropagation()
+ setDeleteVersion(version.version)
+ }}
+ >
+
+
+
+ ) : null}
))}
diff --git a/portal/frontend/src/features/catalog/ElementListPage.tsx b/portal/frontend/src/features/catalog/ElementListPage.tsx
index fc1c5d4f..55cf7adc 100644
--- a/portal/frontend/src/features/catalog/ElementListPage.tsx
+++ b/portal/frontend/src/features/catalog/ElementListPage.tsx
@@ -43,8 +43,10 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs'
+import useAuthorization from '../auth/useAuthorization'
import type { ElementFilters, ElementType } from '../../types/catalog'
import { formatEpochTimestamp } from '../../utils/dateTime'
+import { PORTAL_SCOPES } from '../../utils/portalScopes'
import ElementFormDialog from './components/ElementFormDialog'
import ElementTypeChip from './components/ElementTypeChip'
import { useCreateElementMutation, useElementsQuery } from './hooks/useCatalogQueries'
@@ -289,6 +291,8 @@ function ElementListPage(): React.JSX.Element {
const rowsPerPage = useMemo(() => getRowsPerPage(searchParams), [searchParams])
const query = useElementsQuery(filters, page, rowsPerPage)
const createMutation = useCreateElementMutation()
+ const { hasScope } = useAuthorization()
+ const canWriteElements = hasScope(PORTAL_SCOPES.ELEMENTS_WRITE)
const updateParams = (
nextFilters: ElementFilters,
@@ -343,13 +347,15 @@ function ElementListPage(): React.JSX.Element {
{t('catalog.elements.subtitle')}
- }
- onClick={() => setCreateOpen(true)}
- >
- {t('catalog.elements.add')}
-
+ {canWriteElements ? (
+ }
+ onClick={() => setCreateOpen(true)}
+ >
+ {t('catalog.elements.add')}
+
+ ) : null}
()
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
const [deleteVersion, setDeleteVersion] = useState()
+ const { currentUser, hasScope } = useAuthorization()
+ const canWritePurposes = hasScope(PORTAL_SCOPES.PURPOSES_WRITE)
const detail = detailQuery.data
const latestVersion = useMemo(
@@ -182,7 +184,7 @@ function PurposeDetailsPage(): React.JSX.Element {
)
}
- const organizationWide = Boolean(ORG_ID) && detail.groupId === ORG_ID
+ const organizationWide = detail.groupId === currentUser.organizationId
const propertyEntries = Object.entries(displayedVersion.properties ?? {})
return (
@@ -225,13 +227,15 @@ function PurposeDetailsPage(): React.JSX.Element {
))}
- }
- onClick={() => setVersionDialogOpen(true)}
- >
- {t('catalog.purposes.newVersion')}
-
+ {canWritePurposes ? (
+ }
+ onClick={() => setVersionDialogOpen(true)}
+ >
+ {t('catalog.purposes.newVersion')}
+
+ ) : null}
@@ -549,18 +553,20 @@ function PurposeDetailsPage(): React.JSX.Element {
{version.elements.length}
{formatEpochTimestamp(version.createdTime)}
-
- {
- event.stopPropagation()
- setDeleteVersion(version.version)
- }}
- >
-
-
-
+ {canWritePurposes ? (
+
+ {
+ event.stopPropagation()
+ setDeleteVersion(version.version)
+ }}
+ >
+
+
+
+ ) : null}
))}
@@ -575,6 +581,7 @@ function PurposeDetailsPage(): React.JSX.Element {
key={`purpose-version-${String(versionDialogOpen)}`}
open={versionDialogOpen}
initialValue={detail}
+ organizationId={currentUser.organizationId}
loading={createVersionMutation.isPending}
error={createVersionMutation.error?.message}
onClose={() => {
diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx
index 3958ea84..71e05f5a 100644
--- a/portal/frontend/src/features/catalog/PurposeListPage.tsx
+++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx
@@ -45,8 +45,10 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs'
+import useAuthorization from '../auth/useAuthorization'
import type { ElementSummary, PurposeFilters } from '../../types/catalog'
import { formatEpochTimestamp } from '../../utils/dateTime'
+import { PORTAL_SCOPES } from '../../utils/portalScopes'
import ElementVersionSelect from './components/ElementVersionSelect'
import PurposeFormDialog from './components/PurposeFormDialog'
import {
@@ -56,7 +58,6 @@ import {
} from './hooks/useCatalogQueries'
const ROW_OPTIONS = [10, 25, 50]
-const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim()
type PurposeScope = 'organization' | 'all'
@@ -68,18 +69,18 @@ function elementLabel(element: ElementSummary): string {
return `${element.displayName ?? element.name} (${element.namespace})`
}
-function emptyFilters(): PurposeListFilters {
+function emptyFilters(organizationId: string): PurposeListFilters {
return {
purposeName: '',
elementName: '',
elementNamespace: '',
elementVersion: '',
- groupIds: ORG_ID,
+ groupIds: organizationId,
scope: 'organization',
}
}
-function getFilters(searchParams: URLSearchParams): PurposeListFilters {
+function getFilters(searchParams: URLSearchParams, organizationId: string): PurposeListFilters {
const scope: PurposeScope = searchParams.get('scope') === 'all' ? 'all' : 'organization'
return {
@@ -87,19 +88,21 @@ function getFilters(searchParams: URLSearchParams): PurposeListFilters {
elementName: searchParams.get('elementName') ?? '',
elementNamespace: searchParams.get('elementNamespace') ?? '',
elementVersion: searchParams.get('elementVersion') ?? '',
- groupIds: scope === 'organization' ? ORG_ID : (searchParams.get('groupIds') ?? ''),
+ groupIds: scope === 'organization' ? organizationId : (searchParams.get('groupIds') ?? ''),
scope,
}
}
interface PurposeFiltersPanelProps {
initialFilters: PurposeListFilters
+ organizationId: string
onApply: (filters: PurposeListFilters) => void
onClear: () => void
}
function PurposeFiltersPanel({
initialFilters,
+ organizationId,
onApply,
onClear,
}: PurposeFiltersPanelProps): React.JSX.Element {
@@ -234,7 +237,7 @@ function PurposeFiltersPanel({
setDraft({
...draft,
scope: value,
- groupIds: value === 'organization' ? ORG_ID : '',
+ groupIds: value === 'organization' ? organizationId : '',
})
}}
aria-label={t('catalog.purposes.scopeFilter')}
@@ -341,7 +344,7 @@ function PurposeFiltersPanel({
+ {canWritePurposes ? (
+ }
+ onClick={() => setCreateOpen(true)}
+ >
+ {t('catalog.purposes.add')}
+
+ ) : null}
updateParams(nextFilters)}
onClear={() => setSearchParams({}, { replace: true })}
/>
@@ -571,7 +585,7 @@ function PurposeListPage(): React.JSX.Element {
- {purpose.groupId === ORG_ID ? (
+ {purpose.groupId === organizationId ? (
{
diff --git a/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx b/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx
index 13575245..97b9d0ac 100644
--- a/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx
+++ b/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx
@@ -1,6 +1,19 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
- * Licensed under the Apache License, Version 2.0.
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
*/
import { Chip } from '@wso2/oxygen-ui'
diff --git a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx
index 73a146f0..9a866ede 100644
--- a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx
+++ b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx
@@ -53,8 +53,6 @@ import {
import ElementVersionSelect from './ElementVersionSelect'
import PropertyEditor from './PropertyEditor'
-const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim()
-
interface PurposeElementFormRow extends PurposeElementRequest {
id: number
elementKey: string
@@ -65,6 +63,7 @@ type PurposeOwnership = 'organization' | 'group'
interface PurposeFormDialogProps {
open: boolean
initialValue: PurposeVersion | undefined
+ organizationId: string
loading: boolean
error: string | undefined
onClose: () => void
@@ -83,6 +82,7 @@ function elementLabel(element: ElementSummary): string {
function PurposeFormDialog({
open,
initialValue,
+ organizationId,
loading,
error,
onClose,
@@ -118,7 +118,7 @@ function PurposeFormDialog({
(versionMode || ownership !== 'group' || Boolean(groupId.trim())) &&
elements.length > 0 &&
elements.every((element) => Boolean(element.name.trim()))
- const organizationWide = Boolean(ORG_ID) && initialValue?.groupId === ORG_ID
+ const organizationWide = initialValue?.groupId === organizationId
const immutablePurposeMessage = organizationWide
? t('catalog.messages.immutableOrganizationPurpose', { name: initialValue?.name })
: t('catalog.messages.immutableGroupPurpose', {
diff --git a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx
index 564bc04c..38d50e28 100644
--- a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx
+++ b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx
@@ -32,6 +32,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams } from 'react-router-dom'
import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs'
+import useAuthorization from '../auth/useAuthorization'
import ConsentApprovalDialog from './components/ConsentApprovalDialog'
import ConsentRejectionDialog from './components/ConsentRejectionDialog'
import ConsentRevocationDialog from './components/ConsentRevocationDialog'
@@ -51,6 +52,15 @@ import {
isConsentRejectableStatus,
isConsentRevokableStatus,
} from './utils/statusChip'
+import { PORTAL_SCOPES } from '../../utils/portalScopes'
+import {
+ useAdminConsentDetailQuery,
+ useAdminRevokeConsentMutation,
+} from '../admin-consents/hooks/useAdminConsentQueries'
+
+interface ConsentDetailsPageProps {
+ variant?: 'self' | 'admin'
+}
function formatResourcesForModal(resources: unknown): string {
if (!resources) {
@@ -148,19 +158,28 @@ function ConsentDetailsLoading(): React.JSX.Element {
)
}
-function ConsentDetailsPage(): React.JSX.Element {
+function ConsentDetailsPage({ variant = 'self' }: ConsentDetailsPageProps): React.JSX.Element {
const { t } = useTranslation('common')
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
- const consentDetailQuery = useConsentDetailQuery(id)
+ const selfConsentDetailQuery = useConsentDetailQuery(variant === 'self' ? id : undefined)
+ const adminConsentDetailQuery = useAdminConsentDetailQuery(variant === 'admin' ? id : undefined)
const approveMutation = useApproveConsentMutation()
const rejectMutation = useRejectConsentMutation()
const revokeMutation = useRevokeConsentMutation()
+ const adminRevokeMutation = useAdminRevokeConsentMutation()
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false)
const [revocationDialogOpen, setRevocationDialogOpen] = useState(false)
const [resourcesModalOpen, setResourcesModalOpen] = useState(false)
const [selectedResourcesJson, setSelectedResourcesJson] = useState('')
+ const { currentUser, hasScope } = useAuthorization()
+ const canWriteSelf = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_SELF)
+ const canWriteAny = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_ANY)
+ const consentDetailQuery = variant === 'admin' ? adminConsentDetailQuery : selfConsentDetailQuery
+ const backPath = variant === 'admin' ? '/administration/consents' : '/consents'
+ const revokePending =
+ variant === 'admin' ? adminRevokeMutation.isPending : revokeMutation.isPending
if (!id) {
return (
@@ -170,7 +189,7 @@ function ConsentDetailsPage(): React.JSX.Element {
>
{t('consentRegistry.details.notFound')}
- navigate('/consents')}>
+ navigate(backPath)}>
{t('consentRegistry.details.back')}
@@ -179,9 +198,13 @@ function ConsentDetailsPage(): React.JSX.Element {
}
const detail = consentDetailQuery.data
- const canApprove = detail ? isConsentApprovableStatus(detail.status) : false
- const canReject = detail ? isConsentRejectableStatus(detail.status) : false
- const canRevoke = detail ? isConsentRevokableStatus(detail.status) : false
+ const canApprove =
+ variant === 'self' && detail ? canWriteSelf && isConsentApprovableStatus(detail.status) : false
+ const canReject =
+ variant === 'self' && detail ? canWriteSelf && isConsentRejectableStatus(detail.status) : false
+ const canRevoke = detail
+ ? (variant === 'admin' ? canWriteAny : canWriteSelf) && isConsentRevokableStatus(detail.status)
+ : false
if (consentDetailQuery.isLoading) {
return
@@ -195,7 +218,7 @@ function ConsentDetailsPage(): React.JSX.Element {
>
{t('consentRegistry.messages.loadFailed')}
- navigate('/consents')}>
+ navigate(backPath)}>
{t('consentRegistry.details.back')}
@@ -255,7 +278,7 @@ function ConsentDetailsPage(): React.JSX.Element {
color="error"
size="small"
startIcon={}
- disabled={revokeMutation.isPending}
+ disabled={revokePending}
onClick={() => {
setRevocationDialogOpen(true)
}}
@@ -327,20 +350,29 @@ function ConsentDetailsPage(): React.JSX.Element {
key={`revocation-${id}-${String(revocationDialogOpen)}`}
open={revocationDialogOpen}
consentId={id}
- loading={revokeMutation.isPending}
+ loading={revokePending}
onClose={() => {
setRevocationDialogOpen(false)
}}
onConfirm={() => {
- revokeMutation.mutate(id, {
+ const options = {
onSuccess: () => {
setRevocationDialogOpen(false)
},
- })
+ }
+ if (variant === 'admin') {
+ adminRevokeMutation.mutate({ consentID: id, actionBy: currentUser.userId }, options)
+ } else {
+ revokeMutation.mutate(id, options)
+ }
}}
/>
)
}
+ConsentDetailsPage.defaultProps = {
+ variant: 'self',
+}
+
export default ConsentDetailsPage
diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx
index d9acbcd9..a84eece2 100644
--- a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx
+++ b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx
@@ -63,8 +63,11 @@ interface ConsentRegistryTableProps {
onRowsPerPageChange: (rowsPerPage: number) => void
onSortChange: (field: ConsentRegistrySortField, direction: ConsentRegistrySortDirection) => void
onRetry: () => void
- onApprove: (consentID: string) => void
- onRevoke: (consentID: string) => void
+ detailBasePath?: string
+ showApproveAction?: boolean
+ showMutationActions?: boolean
+ onApprove?: (consentID: string) => void
+ onRevoke?: (consentID: string) => void
isMutating: boolean
}
@@ -92,6 +95,9 @@ export default function ConsentRegistryTable({
onRowsPerPageChange,
onSortChange,
onRetry,
+ detailBasePath = '/consents',
+ showApproveAction = true,
+ showMutationActions = true,
onApprove,
onRevoke,
isMutating,
@@ -138,10 +144,10 @@ export default function ConsentRegistryTable({
const consentID = event.currentTarget.dataset.consentId
if (consentID) {
- navigate(`/consents/${encodeURIComponent(consentID)}`)
+ navigate(`${detailBasePath}/${encodeURIComponent(consentID)}`)
}
},
- [navigate],
+ [detailBasePath, navigate],
)
const handleApproveClick = useCallback(
@@ -150,7 +156,7 @@ export default function ConsentRegistryTable({
const consentID = event.currentTarget.dataset.consentId
if (consentID) {
- onApprove(consentID)
+ onApprove?.(consentID)
}
},
[onApprove],
@@ -162,7 +168,7 @@ export default function ConsentRegistryTable({
const consentID = event.currentTarget.dataset.consentId
if (consentID) {
- onRevoke(consentID)
+ onRevoke?.(consentID)
}
},
[onRevoke],
@@ -426,14 +432,14 @@ export default function ConsentRegistryTable({
- {row.canApprove ? (
+ {showMutationActions && showApproveAction && row.canApprove ? (
- ) : (
+ ) : null}
+ {showMutationActions && !(showApproveAction && row.canApprove) ? (
- )}
+ ) : null}
@@ -562,3 +569,11 @@ export default function ConsentRegistryTable({
)
}
+
+ConsentRegistryTable.defaultProps = {
+ detailBasePath: '/consents',
+ showApproveAction: true,
+ showMutationActions: true,
+ onApprove: undefined,
+ onRevoke: undefined,
+}
diff --git a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts
index 3aae267b..53182ed8 100644
--- a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts
+++ b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts
@@ -48,6 +48,8 @@ import type {
ConsentRegistrySortField,
} from '../../../types/consent'
import { isConsentAPIStatus } from '../../../types/consent'
+import useAuthorization from '../../auth/useAuthorization'
+import { PORTAL_SCOPES } from '../../../utils/portalScopes'
import {
toEndOfDayEpochMilliseconds,
toEpochMilliseconds,
@@ -91,7 +93,7 @@ function toListParams(
}
}
-function toConsentRow(consent: ConsentDetailAPI): ConsentRecord {
+function toConsentRow(consent: ConsentDetailAPI, canWriteSelf: boolean): ConsentRecord {
const normalizedStatus = normalizeConsentStatus(consent.status)
if (!isConsentAPIStatus(normalizedStatus)) {
@@ -106,8 +108,8 @@ function toConsentRow(consent: ConsentDetailAPI): ConsentRecord {
purposes: consent.purposes.map((purpose) => purpose.displayName ?? purpose.name),
updatedAt: new Date(toEpochMilliseconds(consent.updatedTime) ?? 0).toISOString(),
expirationTime: consent.expirationTime ?? 0,
- canRevoke: isConsentRevokableStatus(normalizedStatus),
- canApprove: isConsentApprovableStatus(normalizedStatus),
+ canRevoke: canWriteSelf && isConsentRevokableStatus(normalizedStatus),
+ canApprove: canWriteSelf && isConsentApprovableStatus(normalizedStatus),
}
}
@@ -117,15 +119,16 @@ function consentListQueryOptions(
rowsPerPage: number,
sortField: ConsentRegistrySortField,
sortDirection: ConsentRegistrySortDirection,
+ canWriteSelf: boolean,
) {
const params = toListParams(filters, page, rowsPerPage, sortField, sortDirection)
return queryOptions({
- queryKey: ['consents', params],
+ queryKey: ['consents', params, { canWriteSelf }],
queryFn: async (): Promise => {
const response = await fetchMyConsents(params)
return {
- rows: response.data.map(toConsentRow),
+ rows: response.data.map((consent) => toConsentRow(consent, canWriteSelf)),
total: response.metadata.total,
}
},
@@ -141,8 +144,10 @@ export function useConsentListQuery(
sortDirection: ConsentRegistrySortDirection,
): UseQueryResult {
const queryClient = useQueryClient()
+ const { hasScope } = useAuthorization()
+ const canWriteSelf = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_SELF)
const query = useQuery(
- consentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection),
+ consentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection, canWriteSelf),
)
useEffect(() => {
@@ -152,12 +157,20 @@ export function useConsentListQuery(
if (!query.isPlaceholderData && hasNextPage) {
queryClient
.prefetchQuery(
- consentListQueryOptions(filters, nextPage, rowsPerPage, sortField, sortDirection),
+ consentListQueryOptions(
+ filters,
+ nextPage,
+ rowsPerPage,
+ sortField,
+ sortDirection,
+ canWriteSelf,
+ ),
)
.catch(() => undefined)
}
}, [
filters,
+ canWriteSelf,
page,
query.data?.total,
query.isPlaceholderData,
diff --git a/portal/frontend/src/features/dashboard/DashboardPage.tsx b/portal/frontend/src/features/dashboard/DashboardPage.tsx
index b9377878..0a783cbe 100644
--- a/portal/frontend/src/features/dashboard/DashboardPage.tsx
+++ b/portal/frontend/src/features/dashboard/DashboardPage.tsx
@@ -1,6 +1,19 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
- * Licensed under the Apache License, Version 2.0.
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
*/
import {
diff --git a/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts b/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts
index 91574537..1f1ac612 100644
--- a/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts
+++ b/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts
@@ -1,6 +1,19 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
- * Licensed under the Apache License, Version 2.0.
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
*/
import { type UseQueryResult, useQuery } from '@tanstack/react-query'
diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts
index 61d30269..14de0aae 100644
--- a/portal/frontend/src/i18n/resources/en/common.ts
+++ b/portal/frontend/src/i18n/resources/en/common.ts
@@ -20,6 +20,13 @@ const commonEn = {
app: {
title: 'Consent Portal',
},
+ authorization: {
+ loading: 'Loading your session',
+ loadFailed: 'Unable to load your session.',
+ tryAgain: 'Try again',
+ noAccessTitle: 'No portal access',
+ noAccessDescription: 'Your account does not have permission to access any portal pages.',
+ },
copyableText: {
copy: 'Copy',
copied: 'Copied',
@@ -36,6 +43,8 @@ const commonEn = {
catalog: 'Definitions',
purposes: 'Purposes',
elements: 'Elements',
+ administration: 'Administration',
+ adminConsents: 'Consents',
},
layout: {
home: 'Home',
@@ -70,6 +79,19 @@ const commonEn = {
noTypes: 'No consent types are available yet.',
viewPending: 'View all pending consents',
},
+ adminConsents: {
+ title: 'Consents',
+ filters: {
+ sectionAriaLabel: 'Administrative consent filters',
+ consentIdSearchPlaceholder: 'Search by consent ID',
+ removeConsentIdForAdvanced: 'Remove the Consent ID filter to use advanced filters.',
+ removeConsentIdForStatus: 'Remove the Consent ID filter to use the status filter.',
+ userIds: 'User IDs',
+ groupIds: 'Group IDs',
+ commaSeparatedUsers: 'Separate multiple user IDs with commas',
+ active: 'Active filters',
+ },
+ },
consentRegistry: {
title: 'All Consents',
details: {
diff --git a/portal/frontend/src/types/auth.ts b/portal/frontend/src/types/auth.ts
new file mode 100644
index 00000000..f1e650bc
--- /dev/null
+++ b/portal/frontend/src/types/auth.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { PortalScope } from '../utils/portalScopes'
+
+export interface CurrentUser {
+ userId: string
+ organizationId: string
+ scopes: PortalScope[]
+}
diff --git a/portal/frontend/src/types/consent.ts b/portal/frontend/src/types/consent.ts
index e27cb3eb..d48b35fa 100644
--- a/portal/frontend/src/types/consent.ts
+++ b/portal/frontend/src/types/consent.ts
@@ -46,14 +46,26 @@ export interface ConsentRegistryFilters {
endDate: string
}
+export interface AdminConsentRegistryFilters extends ConsentRegistryFilters {
+ consentId: string
+ userIds: string
+ purposeVersion: string
+ elementName: string
+ elementNamespace: string
+ elementVersion: string
+}
+
export type ConsentRegistrySortField = 'status' | 'updatedTime' | 'validityTime'
export type ConsentRegistrySortDirection = 'asc' | 'desc'
export interface ConsentListQueryParams {
consentStatuses?: string
+ userIds?: string
purposeName?: string
+ purposeVersion?: string
groupIds?: string
elementName?: string
+ elementNamespace?: string
elementVersion?: string
sort?: string
fromTime?: number
diff --git a/portal/frontend/src/utils/portalScopes.ts b/portal/frontend/src/utils/portalScopes.ts
new file mode 100644
index 00000000..3893d175
--- /dev/null
+++ b/portal/frontend/src/utils/portalScopes.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const PORTAL_SCOPES = {
+ CONSENTS_READ_SELF: 'portal:consents:read:self',
+ CONSENTS_WRITE_SELF: 'portal:consents:write:self',
+ CONSENTS_READ_ANY: 'portal:consents:read:any',
+ CONSENTS_WRITE_ANY: 'portal:consents:write:any',
+ ELEMENTS_READ: 'portal:elements:read',
+ ELEMENTS_WRITE: 'portal:elements:write',
+ PURPOSES_READ: 'portal:purposes:read',
+ PURPOSES_WRITE: 'portal:purposes:write',
+} as const
+
+export type PortalScope = (typeof PORTAL_SCOPES)[keyof typeof PORTAL_SCOPES]
+
+const PORTAL_SCOPE_VALUES = new Set(Object.values(PORTAL_SCOPES))
+
+export function isPortalScope(value: unknown): value is PortalScope {
+ return typeof value === 'string' && PORTAL_SCOPE_VALUES.has(value)
+}
diff --git a/portal/frontend/vitest.setup.ts b/portal/frontend/vitest.setup.ts
index 5be4162b..a9d1a118 100644
--- a/portal/frontend/vitest.setup.ts
+++ b/portal/frontend/vitest.setup.ts
@@ -21,5 +21,4 @@ import { beforeEach, vi } from 'vitest'
beforeEach(() => {
vi.stubEnv('VITE_API_BASE_URL', 'http://localhost:8080')
- vi.stubEnv('VITE_ORG_ID', 'test-org')
})