blob: 89e08940ac3e1a5ac3eb5c2d417ceceb4bcd70f3 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 { AfterViewInit, Component, ElementRef, computed, inject, viewChild } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { IonButton, IonIcon } from '@ionic/angular/standalone';
import { TranslatePipe } from '../../core/adapters';
import { REQUIRED_PERMISSIONS_PARAM } from '../../core/guards/permission.guard';
import { PermissionSummaryPipe } from '../../shared/pipes/permission-summary.pipe';
/**
* The page a user lands on when `permissionGuard` refuses a route.
*
* Refusal has to be legible. The alternative the router offers — letting the match fail and
* falling through to the wildcard — drops the user on the dashboard with no indication that
* anything was denied, which reads as a broken link rather than a decision. So this page names
* what happened, says who to ask, and offers the one action that is certain to work.
*
* It names the permissions the screen wanted, when the guard passed them along. Everyone who
* reaches this page is authenticated back-office staff, so the codes are not a hint to an
* outsider — they are the one piece of information that lets the user tell an administrator
* exactly what to grant, instead of opening a support conversation to find out.
*
* Accessibility carries the page. A guard redirect is a navigation the user did not ask for, so
* the heading takes focus on activation — otherwise focus stays wherever the previous page left
* it and a screen-reader user is given no reason for the change — and the region announces
* itself politely rather than interrupting.
*/
@Component({
selector: 'app-access-denied',
standalone: true,
imports: [RouterLink, IonButton, IonIcon, TranslatePipe, PermissionSummaryPipe],
template: `
<div class="access-denied" role="alert" aria-live="polite">
<ion-icon name="lock-closed-outline" class="access-denied__icon" aria-hidden="true" />
<!--
tabindex="-1" makes the heading programmatically focusable without adding it to the tab
order; the focus call below is the only thing that ever puts focus here.
-->
<h1 #heading tabindex="-1" class="access-denied__title">
{{ 'ACCESS_DENIED.TITLE' | appTranslate }}
</h1>
<p class="access-denied__message">{{ 'ACCESS_DENIED.MESSAGE' | appTranslate }}</p>
@if (requiredPermissions().length) {
<p class="access-denied__required" data-testid="access-denied-required">
{{ 'ACCESS_DENIED.REQUIRES' | appTranslate }}
<!--
Both forms, deliberately. The sentence is what the user reads; the code is what they
quote to an administrator, and it is the only one of the two that is unambiguous.
-->
<span class="access-denied__required-plain">
{{ requiredPermissions() | permissionSummary }}
</span>
<code>{{ requiredPermissions().join(', ') }}</code>
</p>
}
<p class="access-denied__hint">{{ 'ACCESS_DENIED.HINT' | appTranslate }}</p>
<ion-button routerLink="/dashboard" data-testid="access-denied-dashboard">
{{ 'ACCESS_DENIED.BACK_TO_DASHBOARD' | appTranslate }}
</ion-button>
</div>
`,
styles: [
`
.access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
}
.access-denied__icon {
font-size: 56px;
color: var(--ion-color-medium);
}
.access-denied__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
/* Focus lands here by script, not by tabbing, so the ring is the only cue that it moved. */
.access-denied__title:focus-visible {
outline: 2px solid var(--ion-color-primary);
outline-offset: 4px;
}
.access-denied__message {
margin: 0;
max-width: 44ch;
}
.access-denied__required {
margin: 0;
max-width: 44ch;
font-size: 0.9rem;
}
.access-denied__required-plain {
display: block;
}
.access-denied__required code {
font-family: monospace;
word-break: break-word;
}
.access-denied__hint {
margin: 0 0 8px;
max-width: 44ch;
color: var(--ion-color-medium);
font-size: 0.9rem;
}
`,
],
})
export class AccessDeniedComponent implements AfterViewInit {
private readonly heading = viewChild.required<ElementRef<HTMLHeadingElement>>('heading');
private readonly queryParams = toSignal(inject(ActivatedRoute).queryParamMap);
/** Codes the refused route declared, as the guard passed them on. Empty when it did not. */
readonly requiredPermissions = computed(() =>
(this.queryParams()?.get(REQUIRED_PERMISSIONS_PARAM) ?? '')
.split(',')
.map((code) => code.trim())
.filter(Boolean),
);
ngAfterViewInit(): void {
this.heading().nativeElement.focus();
}
}