feat(portal): /api/me/capabilities + cross-app menu links + real role label
PR 3 of 3 — final piece of the user-menu / profile / cross-app
chantier.
BFF
* New `MeModule` ships `GET /api/me/capabilities`, a curated view
derived from the active user-portal session. Returns
`{ canAccessAdmin: boolean }` today; the shape is deliberately
binary so the SPA cannot reconstruct the raw `roles` claim. 401
on missing session, consistent posture with `/auth/me`.
* ADR-0009 amended: new "Curated public view" section codifies
the "no raw roles on `/auth/me`, capabilities is the release
valve" stance; the routes table grows a row for the endpoint.
portal-shell
* New `CapabilitiesService` (app-local) calls the BFF on auth-state
flip and exposes `canAccessAdmin` as a signal. Anonymous
sessions short-circuit to the all-false default without firing
a request.
* Header's `userMenuItems` is now a `computed` — the "Open Portal
Admin" entry appears in the dropdown only when
`canAccessAdmin()` is true, pointing at
`environment.adminAppUrl`.
* Sidebar role widget reads the auth + capabilities signals to
render one of three labels ("Anonymous" / "User" /
"Administrator") in place of the hardcoded "Anonymous".
portal-admin
* Symmetric: unconditional "Open Portal Shell" entry in the admin
user menu, pointing at `environment.shellAppUrl`. Anyone who
can reach portal-admin can reach portal-shell, so no
capabilities check needed.
i18n
* `header.userMenu.openAdmin`, `sidebar.role.{administrator,user}`,
`sidebar.role.aria` reshaped to take the role as a placeholder.
This commit is contained in:
@@ -95,7 +95,7 @@
|
||||
[displayName]="state.user.displayName"
|
||||
[username]="state.user.username"
|
||||
[initials]="initials()"
|
||||
[items]="userMenuItems"
|
||||
[items]="userMenuItems()"
|
||||
[signedInAsLabel]="signedInAsLabel"
|
||||
[signOutLabel]="signOutLabel"
|
||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Header } from './header';
|
||||
|
||||
const BFF_BASE = 'http://bff.test/api';
|
||||
const ME_URL = `${BFF_BASE}/auth/me`;
|
||||
const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`;
|
||||
|
||||
const USER: CurrentUser = {
|
||||
oid: 'user-oid',
|
||||
@@ -15,7 +16,10 @@ const USER: CurrentUser = {
|
||||
displayName: 'Jane Doe',
|
||||
};
|
||||
|
||||
async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave' }) {
|
||||
async function setup(opts?: {
|
||||
onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave';
|
||||
canAccessAdmin?: boolean;
|
||||
}) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Header],
|
||||
providers: [
|
||||
@@ -35,6 +39,14 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err
|
||||
const req = httpCtrl.expectOne(ME_URL);
|
||||
if (intent === 'authenticated') {
|
||||
req.flush(USER);
|
||||
// Multiple change-detection rounds + microtasks let Angular's
|
||||
// signal scheduler dispatch the `effect()` in
|
||||
// `CapabilitiesService` so its GET request is queued before
|
||||
// we look it up below.
|
||||
await flushPendingEffects(fixture);
|
||||
httpCtrl.expectOne(CAPABILITIES_URL).flush({
|
||||
canAccessAdmin: opts?.canAccessAdmin ?? false,
|
||||
});
|
||||
} else if (intent === 'anonymous') {
|
||||
req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
|
||||
} else {
|
||||
@@ -42,11 +54,18 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err
|
||||
}
|
||||
await Promise.resolve();
|
||||
}
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await flushPendingEffects(fixture);
|
||||
return { fixture, httpCtrl };
|
||||
}
|
||||
|
||||
async function flushPendingEffects(fixture: ReturnType<typeof TestBed.createComponent>) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -151,6 +170,35 @@ describe('Header', () => {
|
||||
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('omits the "Open Portal Admin" entry when canAccessAdmin is false', async () => {
|
||||
const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: false });
|
||||
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
|
||||
);
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const items = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('.user-menu__panel .user-menu__item'),
|
||||
);
|
||||
expect(items.some((el) => el.textContent?.includes('Open Portal Admin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('includes the "Open Portal Admin" entry pointing at the admin app when canAccessAdmin is true', async () => {
|
||||
const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: true });
|
||||
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
|
||||
);
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const adminLink = document.body.querySelector<HTMLAnchorElement>(
|
||||
'.user-menu__panel a.user-menu__item[href^="http"]',
|
||||
);
|
||||
expect(adminLink).not.toBeNull();
|
||||
expect(adminLink?.textContent).toContain('Open Portal Admin');
|
||||
});
|
||||
|
||||
it('shows the error chip when /me fails with a non-401', async () => {
|
||||
const { fixture } = await setup({ onBootstrap: 'error' });
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { RouterLink } from '@angular/router';
|
||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||
import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||
export class Header {
|
||||
private readonly layout = inject(LayoutStateService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly capabilities = inject(CapabilitiesService);
|
||||
|
||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||
|
||||
@@ -45,22 +48,34 @@ export class Header {
|
||||
return user ? initialsFor(user) : '';
|
||||
});
|
||||
|
||||
// Static menu rows for the authenticated user. Sign out is NOT in
|
||||
// this list — it's the dedicated row at the bottom of the panel,
|
||||
// emitted via the menu's `signOut` output.
|
||||
protected readonly userMenuItems: readonly UserMenuItem[] = [
|
||||
{
|
||||
label: $localize`:@@header.userMenu.profile:Profile`,
|
||||
icon: 'user',
|
||||
routerLink: '/profile',
|
||||
},
|
||||
{
|
||||
label: $localize`:@@header.userMenu.settings:Settings`,
|
||||
icon: 'settings',
|
||||
disabled: true,
|
||||
badge: $localize`:@@common.badge.soon:Soon`,
|
||||
},
|
||||
];
|
||||
// Menu rows are recomputed when capabilities flip so the "Open
|
||||
// Portal Admin" entry appears as soon as the BFF confirms admin
|
||||
// access for the current session. Sign out is NOT in this list —
|
||||
// it's the dedicated row at the bottom of the panel, emitted via
|
||||
// the menu's `signOut` output.
|
||||
protected readonly userMenuItems = computed<readonly UserMenuItem[]>(() => {
|
||||
const items: UserMenuItem[] = [
|
||||
{
|
||||
label: $localize`:@@header.userMenu.profile:Profile`,
|
||||
icon: 'user',
|
||||
routerLink: '/profile',
|
||||
},
|
||||
{
|
||||
label: $localize`:@@header.userMenu.settings:Settings`,
|
||||
icon: 'settings',
|
||||
disabled: true,
|
||||
badge: $localize`:@@common.badge.soon:Soon`,
|
||||
},
|
||||
];
|
||||
if (this.capabilities.canAccessAdmin()) {
|
||||
items.push({
|
||||
label: $localize`:@@header.userMenu.openAdmin:Open Portal Admin`,
|
||||
icon: 'wrench',
|
||||
href: environment.adminAppUrl,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`;
|
||||
protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`;
|
||||
|
||||
@@ -60,12 +60,11 @@
|
||||
@if (!collapsed()) {
|
||||
<p
|
||||
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
||||
i18n-aria-label="@@sidebar.role.aria"
|
||||
aria-label="Current role: anonymous (signed-out)"
|
||||
[attr.aria-label]="roleAriaLabel()"
|
||||
>
|
||||
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
|
||||
>Anonymous</span
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200" data-testid="sidebar-role"
|
||||
>{{ roleLabel() }}</span
|
||||
>
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,72 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Sidebar],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
const BFF_BASE = 'http://bff.test/api';
|
||||
const ME_URL = `${BFF_BASE}/auth/me`;
|
||||
const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`;
|
||||
|
||||
interface SetupOpts {
|
||||
user?: CurrentUser | null;
|
||||
canAccessAdmin?: boolean;
|
||||
}
|
||||
|
||||
async function setup(opts: SetupOpts = {}) {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Sidebar],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(Sidebar);
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
|
||||
// AuthService fires `GET /auth/me` from its constructor; flush
|
||||
// synchronously so the auth-state signal settles before any
|
||||
// template assertion. `CapabilitiesService` then fires
|
||||
// `GET /me/capabilities` via its `effect` once `currentUser`
|
||||
// flips — running detectChanges + whenStable gives Angular's
|
||||
// scheduler a chance to dispatch the effect before we look up
|
||||
// the second request.
|
||||
const meReq = http.expectOne(ME_URL);
|
||||
if (opts.user) {
|
||||
meReq.flush(opts.user);
|
||||
// Two microtask rounds: the first lets `AuthService.refresh()`
|
||||
// resume past `await firstValueFrom(...)` and call
|
||||
// `this._state.set(...)`; the second lets `CapabilitiesService`'s
|
||||
// effect dispatch and queue its own GET. detectChanges in
|
||||
// between forces signal-driven effect scheduling under
|
||||
// zoneless change detection.
|
||||
await flushPendingEffects(fixture);
|
||||
const capsReq = http.expectOne(CAPABILITIES_URL);
|
||||
capsReq.flush({ canAccessAdmin: opts.canAccessAdmin ?? false });
|
||||
} else {
|
||||
meReq.flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
}
|
||||
await flushPendingEffects(fixture);
|
||||
return { fixture, http };
|
||||
}
|
||||
|
||||
async function flushPendingEffects(fixture: ReturnType<typeof TestBed.createComponent>) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('renders the three v1 menu groups', async () => {
|
||||
const fixture = TestBed.createComponent(Sidebar);
|
||||
await fixture.whenStable();
|
||||
const { fixture } = await setup();
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
expect(text).toContain('General');
|
||||
expect(text).toContain('Establishments');
|
||||
@@ -21,17 +74,14 @@ describe('Sidebar', () => {
|
||||
});
|
||||
|
||||
it('does not render the accessibility links (now lives in the footer)', async () => {
|
||||
const fixture = TestBed.createComponent(Sidebar);
|
||||
await fixture.whenStable();
|
||||
const { fixture } = await setup();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
|
||||
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
|
||||
const fixture = TestBed.createComponent(Sidebar);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const { fixture } = await setup();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const aside = root.querySelector('aside');
|
||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
|
||||
@@ -48,10 +98,50 @@ describe('Sidebar', () => {
|
||||
|
||||
it('restores the collapsed state from localStorage on init', async () => {
|
||||
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
||||
const fixture = TestBed.createComponent(Sidebar);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const { fixture } = await setup();
|
||||
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
|
||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true);
|
||||
});
|
||||
|
||||
describe('role label', () => {
|
||||
it('renders "Anonymous" when the session is missing', async () => {
|
||||
const { fixture } = await setup();
|
||||
const label = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="sidebar-role"]',
|
||||
);
|
||||
expect(label?.textContent?.trim()).toBe('Anonymous');
|
||||
});
|
||||
|
||||
it('renders "User" when the session is authenticated without admin', async () => {
|
||||
const { fixture } = await setup({
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 'tenant',
|
||||
username: 'jane.doe@apf.example',
|
||||
displayName: 'Jane Doe',
|
||||
},
|
||||
canAccessAdmin: false,
|
||||
});
|
||||
const label = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="sidebar-role"]',
|
||||
);
|
||||
expect(label?.textContent?.trim()).toBe('User');
|
||||
});
|
||||
|
||||
it('renders "Administrator" when capabilities flag canAccessAdmin', async () => {
|
||||
const { fixture } = await setup({
|
||||
user: {
|
||||
oid: 'admin-oid',
|
||||
tid: 'tenant',
|
||||
username: 'admin@apf.example',
|
||||
displayName: 'Admin Smith',
|
||||
},
|
||||
canAccessAdmin: true,
|
||||
});
|
||||
const label = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="sidebar-role"]',
|
||||
);
|
||||
expect(label?.textContent?.trim()).toBe('Administrator');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from 'feature-auth';
|
||||
import { Icon, type IconName } from 'shared-ui';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||
|
||||
/**
|
||||
* Primary side navigation.
|
||||
@@ -80,10 +82,32 @@ const MENU: readonly MenuGroup[] = [
|
||||
})
|
||||
export class Sidebar {
|
||||
private readonly layout = inject(LayoutStateService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly capabilities = inject(CapabilitiesService);
|
||||
|
||||
protected readonly menu = MENU;
|
||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||
|
||||
// Role label rendered next to the toggle, three states:
|
||||
// - "Anonymous" when no session
|
||||
// - "Administrator" when the session carries `Portal.Admin`
|
||||
// - "User" otherwise (signed-in non-admin)
|
||||
// Derived from `AuthService` + `CapabilitiesService`; the latter
|
||||
// populates `canAccessAdmin` from `GET /api/me/capabilities` so the
|
||||
// SPA never sees the raw `roles` claim per ADR-0009.
|
||||
protected readonly roleLabel = computed(() => {
|
||||
if (!this.auth.currentUser()) {
|
||||
return $localize`:@@sidebar.role.anonymous:Anonymous`;
|
||||
}
|
||||
return this.capabilities.canAccessAdmin()
|
||||
? $localize`:@@sidebar.role.administrator:Administrator`
|
||||
: $localize`:@@sidebar.role.user:User`;
|
||||
});
|
||||
|
||||
protected readonly roleAriaLabel = computed(
|
||||
() => $localize`:@@sidebar.role.aria:Current role: ${this.roleLabel()}:role:`,
|
||||
);
|
||||
|
||||
protected readonly toggleAriaLabel = computed(() =>
|
||||
this.collapsed()
|
||||
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { AUTH_BFF_BASE_URL, AuthService } from 'feature-auth';
|
||||
|
||||
/**
|
||||
* Curated capabilities view served by the BFF at
|
||||
* `GET /api/me/capabilities` (ADR-0009 amendment in this PR).
|
||||
* Mirrors the BFF's `CapabilitiesView` interface 1:1.
|
||||
*/
|
||||
export interface Capabilities {
|
||||
readonly canAccessAdmin: boolean;
|
||||
}
|
||||
|
||||
/** Anonymous default — used while auth state is loading or anonymous. */
|
||||
const ANONYMOUS_CAPABILITIES: Capabilities = { canAccessAdmin: false };
|
||||
|
||||
/**
|
||||
* `CapabilitiesService` — fetches `/api/me/capabilities` whenever the
|
||||
* `AuthService` flips into `authenticated`, exposes the resulting
|
||||
* booleans as signals for the header / sidebar to read.
|
||||
*
|
||||
* Per ADR-0009 §"curated public view" the user-portal `/me`
|
||||
* deliberately omits the raw `roles` claim; the SPA derives binary
|
||||
* UX flags from this companion endpoint instead. Today the only
|
||||
* flag is `canAccessAdmin` — drives the "Open Portal Admin" entry
|
||||
* in the user menu and the "Role: Administrator" label in the
|
||||
* sidebar widget.
|
||||
*
|
||||
* Anonymous traffic doesn't trigger a fetch (the BFF would 401);
|
||||
* the signal stays at the all-false default. The service re-fetches
|
||||
* automatically when sign-in completes thanks to the `effect` on
|
||||
* `auth.currentUser`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CapabilitiesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||
|
||||
private readonly _capabilities = signal<Capabilities>(ANONYMOUS_CAPABILITIES);
|
||||
|
||||
readonly capabilities = this._capabilities.asReadonly();
|
||||
readonly canAccessAdmin = computed(() => this._capabilities().canAccessAdmin);
|
||||
|
||||
constructor() {
|
||||
// Reactive plumbing: whenever the authenticated user signal
|
||||
// changes (initial bootstrap, post-login redirect return, sign-
|
||||
// out), re-resolve the capabilities. The effect resets the
|
||||
// payload to the anonymous default before fetching so a stale
|
||||
// "yes admin" from a previous user session doesn't leak into
|
||||
// the new one.
|
||||
effect(() => {
|
||||
const user = this.auth.currentUser();
|
||||
if (user) {
|
||||
void this.refresh();
|
||||
} else {
|
||||
this._capabilities.set(ANONYMOUS_CAPABILITIES);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const caps = await firstValueFrom(
|
||||
this.http.get<Capabilities>(`${this.bffBaseUrl}/me/capabilities`),
|
||||
);
|
||||
this._capabilities.set(caps);
|
||||
} catch (err) {
|
||||
// 401 here means the session expired between the auth state
|
||||
// bootstrap and our fetch — collapse to anonymous. Any other
|
||||
// error is rare enough (network blip, BFF 500) that conservative
|
||||
// "no extra UI" is the right default; the `AuthService`'s own
|
||||
// 401 interceptor will re-bootstrap the state on the next call.
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
this._capabilities.set(ANONYMOUS_CAPABILITIES);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,4 +40,14 @@ export const environment = {
|
||||
* `infra/local/otel-collector.yaml`.
|
||||
*/
|
||||
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
||||
|
||||
/**
|
||||
* Origin of the sibling `portal-admin` SPA — driver of the
|
||||
* cross-app "Open Portal Admin" entry in the user menu (gated on
|
||||
* `CapabilitiesService.canAccessAdmin`). Per ADR-0020 the two
|
||||
* SPAs live on distinct origins / cookies / sessions, so this is
|
||||
* a raw cross-origin `<a href>` jump, not an Angular route.
|
||||
* Per-env siblings override the host.
|
||||
*/
|
||||
adminAppUrl: 'http://localhost:4300',
|
||||
};
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
<source>Sign out</source>
|
||||
<target>Se déconnecter</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.userMenu.openAdmin" datatype="html">
|
||||
<source>Open Portal Admin</source>
|
||||
<target>Ouvrir Administration APF Portal</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.userMenu.trigger.aria" datatype="html">
|
||||
<source>User menu — signed in as <x id="displayName" equiv-text="displayName"/></source>
|
||||
<target>Menu utilisateur — connecté en tant que <x id="displayName" equiv-text="displayName"/></target>
|
||||
@@ -150,9 +154,17 @@
|
||||
<source>Anonymous</source>
|
||||
<target>Anonyme</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.administrator" datatype="html">
|
||||
<source>Administrator</source>
|
||||
<target>Administrateur</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.user" datatype="html">
|
||||
<source>User</source>
|
||||
<target>Utilisateur</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.aria" datatype="html">
|
||||
<source>Current role: anonymous (signed-out)</source>
|
||||
<target>Rôle actuel : anonyme (non connecté)</target>
|
||||
<source>Current role: <x id="role" equiv-text="role"/></source>
|
||||
<target>Rôle actuel : <x id="role" equiv-text="role"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.label" datatype="html">
|
||||
<source>Role:</source>
|
||||
|
||||
Reference in New Issue
Block a user