diff --git a/apps/concierge/src/app/app.config.ts b/apps/concierge/src/app/app.config.ts index a0686ee4608..d4272dd654a 100644 --- a/apps/concierge/src/app/app.config.ts +++ b/apps/concierge/src/app/app.config.ts @@ -30,6 +30,8 @@ import { routes } from './app.routes'; import * as Sentry from '@sentry/angular'; +import { PLACEOS_APP_ACCESS } from '@placeos/components'; + import { LocaleService, registerActiveLocale, @@ -74,6 +76,15 @@ export const appConfig: ApplicationConfig = { useFactory: (localeService: LocaleService) => localeService.locale, }, { provide: TitleStrategy, useClass: SettingsTitleStrategy }, + { + provide: PLACEOS_APP_ACCESS, + useValue: { + // Concierge shows everyone's bookings, visitors and the staff + // directory, so without `app.allow_access_groups` it is for + // admin and support users only. + default_groups: ['placeos_admin', 'placeos_support'], + }, + }, provideHttpClient(withXhr(), withInterceptorsFromDi()), ], }; diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index 09406b293dd..6dbeff1d2bb 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -57,6 +57,7 @@ Each locale entry requires an `id` and `name`: | `features` | string[] | All features enabled (see below) | List of features enabled in the app. Available values: `spaces`, `zones`, `desks`, `parking`, `visitors`, `assets`, `facilities`, `internal-users`, `points`, `surveys`, `catering`, `events`, `booking-report`, `attendance-report`, `contact-tracing-report`, `signage`, `email-templates`. | | `feature_groups` | object | – | Maps feature names to the user groups allowed to access them, e.g. `parking-requests`. Features without an entry are available to all users. | | `admin_group` | string | `"concierge_admins"` | Name of the user group that grants access to admin sections of the app. Members of `placeos_admin` and `placeos_support` are always allowed. | +| `allow_access_groups` | string[] | – | User groups allowed to open the app at all. When unset, only members of `placeos_admin` and `placeos_support` (admin and support users) are admitted; set it to open the app to a front-desk group. | | `delegated` | boolean | `false` | Set when your PlaceOS instance uses delegated calendar access. Hides event actions from the event details view. | | `has_uploads` | boolean | `true` | Whether file upload support (S3/cloud storage) is available in the app. | | `bulk_actions` | boolean | `false` | Let staff select several rows and apply one action to all of them. Desk, parking and locker bookings, parking requests and asset requests get approve and reject actions. Catering orders get a status action. | diff --git a/libs/components/src/lib/authorised-user.guard.ts b/libs/components/src/lib/authorised-user.guard.ts index 4b9453079a7..ab569129aab 100644 --- a/libs/components/src/lib/authorised-user.guard.ts +++ b/libs/components/src/lib/authorised-user.guard.ts @@ -56,7 +56,13 @@ function resolvedWithin(promise: Promise, delay: number) { } export abstract class PLACEOS_APP_ACCESS { - public readonly group: string; + /** A single group that gates the app, regardless of settings. */ + public readonly group?: string; + /** + * Groups that gate the app while `app.allow_access_groups` is not set. + * Without them an unconfigured app admits every signed-in user. + */ + public readonly default_groups?: string[]; } @Injectable({ @@ -98,9 +104,13 @@ export class AuthorisedUserGuard { ]), ); if (!state_ready) return this.offlineAccess(); + const configured: string[] = + this._settings.get('app.allow_access_groups') || []; const groups = this._access?.group ? [this._access.group] - : this._settings.get('app.allow_access_groups') || []; + : configured.length + ? configured + : this._access?.default_groups || []; const use_group_subsystem_access = await this.useGroupSubsystemAccess(); let can_activate = false; if (use_group_subsystem_access) { diff --git a/libs/components/src/tests/authorised-user.guard.spec.ts b/libs/components/src/tests/authorised-user.guard.spec.ts index efa938cacbf..25d602697b9 100644 --- a/libs/components/src/tests/authorised-user.guard.spec.ts +++ b/libs/components/src/tests/authorised-user.guard.spec.ts @@ -25,7 +25,9 @@ import { describe('AuthorisedUserGuard', () => { let spectator: SpectatorService; - const access_mock = { group: '' }; + const access_mock: { group: string; default_groups?: string[] } = { + group: '', + }; const settings_mock = { app_name: 'workplace', get: vi.fn((key: string): any => []), @@ -54,6 +56,7 @@ describe('AuthorisedUserGuard', () => { beforeEach(() => { vi.clearAllMocks(); access_mock.group = ''; + delete access_mock.default_groups; settings_mock.app_name = 'workplace'; settings_mock.get.mockImplementation((key) => key === 'app.allow_access_groups' ? [] : undefined, @@ -184,6 +187,31 @@ describe('AuthorisedUserGuard', () => { access_mock.group = ''; }); + it('should fall back to the app default groups when none are configured', async () => { + access_mock.default_groups = ['placeos_admin', 'placeos_support']; + const router = spectator.inject(Router); + + setCurrentUser({ groups: [] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/unauthorised']); + + setCurrentUser({ groups: ['placeos_support'] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(true); + }); + + it('should prefer configured access groups over the app default groups', async () => { + access_mock.default_groups = ['placeos_admin']; + settings_mock.get.mockImplementation((key) => + key === 'app.allow_access_groups' ? ['front_desk'] : undefined, + ); + + setCurrentUser({ groups: ['front_desk'] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(true); + + setCurrentUser({ groups: ['placeos_admin'] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(false); + }); + it('waits for user groups and app settings to load', async () => { common_lib.user_groups_loaded.set(false); let resolve_settings: () => void;