Understanding Access Restriction

Access restriction refers to controlling how different users interact with a platform. Certain users may have permission to view specific pages while others do not; authenticated users might access some parts of an app but be blocked from others. On a finer level, restriction can involve disabling sections within otherwise available pages, or limiting certain actions without removing them entirely. This article explores how to implement these patterns efficiently with NgRx in Angular applications.

Available Tools

Angular itself provides solid built-in capabilities for access restriction. Let's review them:

  1. Guards — classes that control route access based on given conditions
  2. Interceptors — not specifically designed for access restriction, but capable of modifying or blocking network requests
  3. NgIf directive — primary tool for granular access control
  4. NgSwitch directive — ideal for rendering different components based on user roles

Rather than replacing these tools with NgRx, our goal is to make the reactive store work alongside them, producing a clean, concise, and high-quality user experience.

Our Use Cases

We'll demonstrate access restriction through three primary scenarios:

  1. Authentication implemented with NgRx
  2. Permission management
  3. Feature visibility determined by roles or permissions held in the NgRx Store

Starting with authorization.

Authorization with NgRx

In apps leveraging NgRx, avoiding data duplication is essential, along with keeping the Store interface simple and efficient. Clear understanding of the situation we're dealing with helps here. For this example, our authorization scheme works as follows:

  1. Relies on a JSON Web Token
  2. The token resides in cookies
  3. User information is fetched through a dedicated endpoint using the token
  4. The authentication state is checked
  5. User details are retrieved (for a profile view or for display elsewhere)

Though this scenario is fairly tailored, it maps well to alternative approaches, such as employing localStorage rather than cookies (notably, localStorage is regarded as insecure), or using a fundamentally different authorization method.

Let's determine what data belongs in the Store. At first glance, these candidates come to mind:

  • Current user data
  • A flag signaling whether the user is authenticated
  • The authentication token

Reasonable, right? Not quite! Only the user data itself needs to reside in the Store, because:

  1. Presence of user data already indicates that the user is authenticated
  2. The token carries user information within itself—just decode it
  3. The API call happens right at application startup

Consequently, we store only the user data in the auth slice of the Store. Several selectors will then expose login state, the token, and the decoded user details. The resulting AuthState is remarkably compact:

export interface AuthState {
  token: string;
  user: User;
}

export const initialState: AuthState = {
  token: "",
  user: null,
};
Enter fullscreen mode Exit fullscreen mode

Now, we require actions for placing the token into the state, removing it, and similar operations:

import { createAction, props } from "@ngrx/store";

export const setToken = createAction(
  "[Auth] Set Token",
  props<{ token: string }>()
);

export const setUser = createAction(
  "[Auth] Set user",
  props<{ user: User }>(),
);

export const removeToken = createAction("[Auth] Remove Token");
Enter fullscreen mode Exit fullscreen mode

Followed by a minimal reducer that processes these interactions:

import { createReducer, on } from "@ngrx/store";
import { removeToken, setToken } from "./actions";
import { AuthState, initialState } from "./state";

export const authReducer = createReducer(
  initialState,
  on(setToken, (state, { token }): AuthState => ({ ...state, token })),
  on(removeToken, (state): AuthState => ({ ...state, token: "" })),
  on(setUser, (state, { user }): AuthState => ({ ...state, user }))
);
Enter fullscreen mode Exit fullscreen mode

Let's now set this up inside the AppModule:

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";

import { AppComponent } from "./app.component";
import {
  UserDashboardComponent,
} from "./user-dashboard/user-dashboard.component";
import { AppRoutingModule } from "./routing.module";
import { LoginComponent } from "./login/login.component";
import { StoreModule } from "@ngrx/store";
import { authReducer } from "./store/reducer";
import { CommonModule } from "@angular/common";

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    AppRoutingModule,
    CommonModule,
    StoreModule.forRoot({ auth: authReducer }),
  ],
  declarations: [AppComponent, UserDashboardComponent, LoginComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

With the state in place, we'll write selectors that retrieve the required data:

import { createFeatureSelector, createSelector } from "@ngrx/store";
import { AuthState } from "./state";
import { decode } from "some-jwt-library";

export const authFeature = createFeatureSelector<AuthState>("auth");

export const selectToken = createSelector(
  authFeature,
  (state) => state.token,
);
export const selectIsAuth = createSelector(
  authFeature,
  (state) => !!state.token
);
export const selectUserData = createSelector(
  authFeature,
  (state) => state.user
);
Enter fullscreen mode Exit fullscreen mode

That completes the foundational setup—we can now store user data and retrieve it safely. The next question is: how does data get into the Store? Three effects handle this:

  1. Sign in: sends credentials to the backend, receives a token, saves it to cookies and the Store
  2. Sign out: deletes the token and navigates to the login page
  3. Token restoration: at application start, checks cookies for a token and loads it into the Store

Let's add the necessary actions first:

export const login = createAction(
  "[Auth] Login",
  props<{ email: string; password: string }>()
);

export const loginError = createAction(
  "[Auth] Login",
  props<{ message: string }>()
);

export const logout = createAction("[Auth] Log Out");
Enter fullscreen mode Exit fullscreen mode

And now the effects themselves—pay particular attention to the final one:

@Injectable()
export class AuthEffects {
  // on login, send auth data to backend,
  // get the token and put into the store and cookies
  login$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(login),
      mergeMap(({ email, password }) => {
        return this.authService.login(email, password).pipe(
          tap(({ token }) => this.cookieService.set("token", token)),
          map(({ token }) => setToken({ token })),
          catchError(() => of(loginError({ message: "Login failed" })))
        );
      })
    );
  });

  // on logout, just remove the token
  // and navigate to login page
  // no need to dispatch any actions after that
  logout$ = createEffect(
    () => {
      return this.actions$.pipe(
        ofType(logout),
        tap(() => {
          this.cookieService.remove("token");
          this.router.navigateByUrl("/login");
        })
      );
    },
    { dispatch: false }
  );

  // when app has started, get the user data
  // using the token from cookies
  // and put into the store
  init$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(ROOT_EFFECTS_INIT),
      mergeMap(({ email, password }) => {
        return this.authService.getCurrentUser().pipe(
          map(({ token }) => setUser({ user })),
          catchError(() => of(setUserError({ message: "Error" })))
        );
      })
    );
  });

  constructor(
    private readonly actions$: Actions,
    private readonly authService: AuthService,
    private readonly router: Router,
    private readonly cookieService: CookieService
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

The first two effects are quite simple, so let's examine the last one closely. Here, we leverage the built-in ROOT_EFFECTS_INIT action—NgRx dispatches this automatically when it subscribes to our effects, effectively telling us that the app is starting. We then write the user data back into the state, essentially rehydrating it.

With auth fully wired through NgRx, the final piece is a guard that verifies the existence of the auth token:

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly store: Store,
    private readonly router: Router,
  ) {}

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot,
  ) {
    return this.store.select(selectIsAuth).pipe(
      map((isAuth) => {
        return isAuth ? true : this.router.parseUrl("/login");
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In essence, all authorization logic now lives in the NgRx Store, and the guard merely consults the Store to decide whether to proceed.

That covers the authorization side. Next, let's move on to permission-based access restrictions.

Implementing Permissions With NgRx

Applications vary widely in how they manage permissions—where they store them and how they confirm their presence. The advantage of NgRx here is that the underlying structure of permissions doesn't matter. We can craft selectors that expose a consistent interface for verifying permissions and granting access, no matter the shape of the data.

To keep this example straightforward, let's assume our permissions live inside the JWT token alongside user information. They're stored as an array of strings—if the permission name appears in the array, the user has it; otherwise, they don't. So, the user object might resemble this:

interface UserData {
  firstName: string;
  lastName: string;
  permissions: string[];
}
Enter fullscreen mode Exit fullscreen mode

Next, we introduce the selectors:

export const selectPermissions = createSelector(
  selectUserData,
  (userData) => userData?.permissions ?? []
);
Enter fullscreen mode Exit fullscreen mode

This gives us the raw array, but often we need to verify the presence of a specific permission. For that, we can write a selector that accepts an argument:

export const selectHasPermission = (permission: string) =>
  createSelector(selectPermissions, (permissions) =>
    permissions.includes(permission)
  );
Enter fullscreen mode Exit fullscreen mode

Our components can then tap into this selector to determine whether the user is authorized for a given action:

export class UserDashboardComponent implements OnInit {
  canCreateOrder$ = this.store.select(
    selectHasPermission("CreateOrder"),
  );
  constructor(private readonly store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

And in the template:

<button *ngIf="canCreateOrder$ | async" (click)="createOrder()">
  Create Order
</button>
Enter fullscreen mode Exit fullscreen mode

With Angular 14 and above, the inject function allows us to build a reusable functional guard for authorization checks:

export function hasPermissionGuard(permission: string) {
  return function () {
    const store = inject(Store);
    return store.select(selectHasPermission(permission));
  };
}
Enter fullscreen mode Exit fullscreen mode

We can then apply this guard to our routes:

const routes: Routes = [
  {
    path: "orders",
    component: OrdersComponent,
    canActivate: [hasPermissionGuard("ViewOrders")],
  },
  {
    path: "orders/create",
    component: CreateOrderComponent,
    canActivate: [hasPermissionGuard("CreateOrder")],
  },
];
Enter fullscreen mode Exit fullscreen mode

Note: the guard can be extended to accept multiple permissions and check if the user holds all of them, and we could also add an extra parameter to factor in redirects when necessary.

Bonus point: We now reach the point where we restrict access to distinct areas of the interface:

Fine-Grained Control Over UI Components

For this, we can put together a directive that hides an element when the user lacks the required permission. This is what the directive will do:

  1. Work as a structural directive — *appHasPermission="permission"
  2. Accept a string input representing the permission name
  3. Access permissions via the Store
  4. Employ the selectHasPermission selector to verify the user's authorization
  5. Conceal the element when the permission is missing
  6. Display the element when the permission is present
  7. Clean up the store subscription when the element gets destroyed

Let's implement it:

@Directive({
  selector: "[appHasPermission]",
})
export class HasPermissionDirective implements OnInit, OnDestroy {
  @Input("appHasPermission") permission: string;
  destroy$ = new Subject<void>();

  constructor(
    private readonly templateRef: TemplateRef<any>,
    private readonly viewContainer: ViewContainerRef,
    private readonly store: Store
  ) {}

  ngOnInit() {
    this.store
      .select(selectHasPermission(this.permission))
      .pipe(takeUntil(this.destroy$))
      .subscribe((hasPermission) => {
        if (hasPermission) {
          this.viewContainer.createEmbeddedView(this.templateRef);
        } else {
          this.viewContainer.clear();
        }
      });
  }

  ngOnDestroy() {
    this.destroy$.next();
  }
}
Enter fullscreen mode Exit fullscreen mode

And then in the template:

<a *appHasPermission="'CreateOrder'" routerLink="/orders/create">
  Create Order
</a>
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Controlling access plays a vital role in many applications, particularly in enterprise settings. NgRx proves to be a robust solution for managing authentication and permissions, and it offers a wide range of possibilities. We've explored the most typical scenarios here, yet countless others exist—so feel free to experiment and discover fresh approaches with NgRx.