diff --git a/src/app/administration/layout/logged-user/logged-user.component.scss b/src/app/administration/layout/logged-user/logged-user.component.scss index 112e126..5aadf0f 100644 --- a/src/app/administration/layout/logged-user/logged-user.component.scss +++ b/src/app/administration/layout/logged-user/logged-user.component.scss @@ -36,6 +36,7 @@ transition: all 150ms; padding-top: 80px; background: $backColorMediumDark; + border: 2.5px solid $backColor; color: $foreColor; transition: all 150ms; opacity: 0; diff --git a/src/app/administration/layout/nav-bar/menu-builder.ts b/src/app/administration/layout/nav-bar/menu-builder.ts index 175021b..a6cbbd2 100644 --- a/src/app/administration/layout/nav-bar/menu-builder.ts +++ b/src/app/administration/layout/nav-bar/menu-builder.ts @@ -1,16 +1,49 @@ -import { faCalendarDays, faClock, faTicket } from '@fortawesome/free-solid-svg-icons'; +import { faCalendarDay, faCalendarDays, faClock, faTicket } from '@fortawesome/free-solid-svg-icons'; import { MenuItem, MenuSubItem } from './menu-items'; import { IconDefinition } from '@fortawesome/angular-fontawesome'; +import { inject } from '@angular/core'; +import { AuthService } from 'src/app/services/auth.service'; +import { AdminModeService } from 'src/app/services/adminMode.service'; export class MenuBuilder { - constructor() { } + readonly #auth = inject(AuthService); + readonly #adminMode = inject(AdminModeService); - build(): MenuItem[] { + #defaultMenu: MenuItem[] = [ + this.#getMenuItem('My events', 'my-events', faCalendarDay, () => this.getSubItemsByPath('my-events')), + ].filter(o => o); + + ticketsMenuItem: MenuItem = this.#getMenuItem('Tickets', 'tickets', faTicket, () => this.getSubItemsByPath('tickets')); + ticketGroupsMenuItem: MenuItem = this.#getMenuItem('Ticket groups', 'ticket_groups', faClock, () => this.getSubItemsByPath('ticket_groups')); + eventsMenuItem: MenuItem = this.#getMenuItem('Events', 'events', faCalendarDays, () => this.getSubItemsByPath('events')); + #adminMenu: MenuItem[] = [ + this.ticketsMenuItem, + this.ticketGroupsMenuItem, + this.eventsMenuItem, + ]; + + build(currentUrl: string = ''): MenuItem[] { + if (this.#adminMode.status()) { + // user is admin and should have access to all things + return this.#adminMenu.filter(o => o); + } + + // user has restricted access + let menu: MenuItem[] = []; + + // add menu item when user opened some page defaultly not in menu + for (let i = 0; i < this.#adminMenu.length; i++) { + const menuItem = this.#adminMenu[i]; + if (currentUrl.startsWith(`/${menuItem.link}`)) { + menu.push(menuItem); + } + } + + // return merged menu return [ - this.#getMenuItem('Tickets', 'tickets', faTicket, () => this.getTicketsSubItems()), - this.#getMenuItem('Ticket groups', 'ticket_groups', faClock, () => this.getSubItemsByPath('ticket_groups')), - this.#getMenuItem('Events', 'events', faCalendarDays, () => this.getSubItemsByPath('events')), + ...menu, + ...this.#defaultMenu, ].filter(o => o); } @@ -19,14 +52,10 @@ export class MenuBuilder { ): MenuSubItem[] { const result: MenuSubItem[] = []; result.push(new MenuSubItem('List', `/${path}/list`)); - result.push(new MenuSubItem('New', `/${path}/add`)); - return result; - } - getTicketsSubItems(): MenuSubItem[] { - const result: MenuSubItem[] = []; - result.push(new MenuSubItem('List', '/tickets/list')); - result.push(new MenuSubItem('New', '/tickets/add')); + if (this.#adminMode.status() || this.#auth.getScopes().includes(`${path}:edit`)) { + result.push(new MenuSubItem('New', `/${path}/add`)); + } return result; } diff --git a/src/app/administration/layout/nav-bar/nav-bar.component.ts b/src/app/administration/layout/nav-bar/nav-bar.component.ts index dcefe60..607b7d4 100644 --- a/src/app/administration/layout/nav-bar/nav-bar.component.ts +++ b/src/app/administration/layout/nav-bar/nav-bar.component.ts @@ -1,8 +1,13 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; import { MenuItem } from './menu-items'; import { MenuBuilder } from './menu-builder'; import { AuthService } from 'src/app/services/auth.service'; import { faBars, faChartLine, faSignOutAlt, faTimes, faUser } from '@fortawesome/free-solid-svg-icons'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { StorageService } from 'src/app/services/storage.service'; +import { StorageKeys } from 'src/app/tokens/storage.tokens'; @Component({ selector: 'app-nav-bar', @@ -12,6 +17,9 @@ import { faBars, faChartLine, faSignOutAlt, faTimes, faUser } from '@fortawesome }) export class NavBarComponent implements OnInit { readonly #authService = inject(AuthService); + readonly #router = inject(Router); + readonly #storageService = inject(StorageService); + readonly #destroyRef = inject(DestroyRef); dashboardItem = new MenuItem('Dashboard', 'dashboard', faChartLine); userProfileItem = new MenuItem('My profile', 'profile', faUser); @@ -26,12 +34,20 @@ export class NavBarComponent implements OnInit { ngOnInit(): void { this.menuOpen = history.state.navBarVisible ?? false; - this.availableItems = this.builder.build() - this.availableItems.unshift(this.dashboardItem); - this.availableItems.push( - this.userProfileItem, - this.logoutItem - ); + this.#rebuildMenu(this.#router.url); + + this.#router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.#destroyRef), + ).subscribe(event => { + this.#rebuildMenu(event.urlAfterRedirects); + }); + + this.#storageService.storageEvent$(StorageKeys.ADMIN_MODE).pipe( + takeUntilDestroyed(this.#destroyRef), + ).subscribe(() => { + this.#rebuildMenu(this.#router.url); + }); } toggle(): void { @@ -45,4 +61,13 @@ export class NavBarComponent implements OnInit { logout(): void { this.#authService.logout(); } + + #rebuildMenu(currentUrl: string): void { + this.availableItems = this.builder.build(currentUrl); + this.availableItems.unshift(this.dashboardItem); + this.availableItems.push( + this.userProfileItem, + this.logoutItem + ); + } } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d8bf00d..42f0274 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -15,6 +15,30 @@ import { EventsFormComponent } from './administration/events/form/events-form.co import { authGuard } from './guards/auth.guard'; import { logoutGuard } from './guards/logout.guard'; +let eventsChildren: Routes = [ + { + path: '', + pathMatch: 'full', + redirectTo: 'list', + }, + { + path: 'list', + component: EventsListComponent + }, + { + path: 'add', + component: EventsFormComponent + }, + { + path: 'edit/:id', + component: EventsFormComponent + }, + { + path: 'detail/:id', + component: EventsFormComponent + }, +]; + export let APP_ROUTES: Routes = [ { path: '', @@ -96,31 +120,13 @@ export let APP_ROUTES: Routes = [ } ] }, + { + path: 'my-events', + children: eventsChildren, + }, { path: 'events', - children: [ - { - path: '', - pathMatch: 'full', - redirectTo: 'list', - }, - { - path: 'list', - component: EventsListComponent - }, - { - path: 'add', - component: EventsFormComponent - }, - { - path: 'edit/:id', - component: EventsFormComponent - }, - { - path: 'detail/:id', - component: EventsFormComponent - }, - ] + children: eventsChildren, }, { path: '**', diff --git a/src/app/components/user-info/user-info.component.html b/src/app/components/user-info/user-info.component.html index b156776..00abee6 100644 --- a/src/app/components/user-info/user-info.component.html +++ b/src/app/components/user-info/user-info.component.html @@ -2,6 +2,11 @@
{{ authService.getScopes() }}
{{ get_access_token_expiration() }} ‒ {{ time_to_access_token_expire }}
{{ refresh_token_expire() }} ‒ {{ time_to_refresh_token_expire }} +
+
{{ adminModeService.status() ? "ON" : "OFF" }}
+
@if (usersService.user.isLoading()) { diff --git a/src/app/components/user-info/user-info.component.scss b/src/app/components/user-info/user-info.component.scss index 2d15902..113d29c 100644 --- a/src/app/components/user-info/user-info.component.scss +++ b/src/app/components/user-info/user-info.component.scss @@ -31,4 +31,14 @@ } } } +} + +.admin-mode-container { + display: inline-flex; + + button { + margin: auto 5px; + padding: 0px 10px; + min-width: 0px; + } } \ No newline at end of file diff --git a/src/app/components/user-info/user-info.component.ts b/src/app/components/user-info/user-info.component.ts index f753bf0..e70c1c7 100644 --- a/src/app/components/user-info/user-info.component.ts +++ b/src/app/components/user-info/user-info.component.ts @@ -1,4 +1,6 @@ import { Component, computed, booleanAttribute, inject, input, OnDestroy, OnInit } from "@angular/core"; +import { faRepeat } from "@fortawesome/free-solid-svg-icons"; +import { AdminModeService } from "src/app/services/adminMode.service"; import { AuthService } from "src/app/services/auth.service"; import { UsersService } from "src/app/services/users.service"; @@ -11,6 +13,9 @@ import { UsersService } from "src/app/services/users.service"; export class UserInfoComponent implements OnInit, OnDestroy { protected readonly authService = inject(AuthService); protected readonly usersService = inject(UsersService); + protected readonly adminModeService = inject(AdminModeService); + + protected readonly toggleIcon = faRepeat; readonly show_favorite_events = input(true, { transform: booleanAttribute, diff --git a/src/app/services/adminMode.service.ts b/src/app/services/adminMode.service.ts new file mode 100644 index 0000000..a75c88f --- /dev/null +++ b/src/app/services/adminMode.service.ts @@ -0,0 +1,42 @@ +import { inject, Injectable } from '@angular/core'; +import { StorageService } from './storage.service'; +import { LoggingService } from './logging.service'; +import { StorageKeys } from '../tokens/storage.tokens'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs'; +import { mapBoolean } from '../utils/booleanMapper'; + +@Injectable({ + providedIn: 'root', +}) +export class AdminModeService { + readonly #logging = inject(LoggingService); + readonly #storageService = inject(StorageService); + + readonly status = toSignal(this.#storageService.storageEvent$(StorageKeys.ADMIN_MODE).pipe( + map(v => mapBoolean(v.currentValue)) + ), { + initialValue: this.#storageService.getBoolean(StorageKeys.ADMIN_MODE) , + }); + + constructor() { + this.#storageService.setIfNull(StorageKeys.ADMIN_MODE, "false", () => { + this.#logging.log("adminMode", `Set default initial value. Current value: ${this.status()}.`) + }); + } + + on(): void { + this.#storageService.set(StorageKeys.ADMIN_MODE, "true"); + this.#logging.log("adminMode", `Force ON. Current value: ${this.status()}.`); + } + + off(): void { + this.#storageService.set(StorageKeys.ADMIN_MODE, "false"); + this.#logging.log("adminMode", `Force OFF. Current value: ${this.status()}.`); + } + + toggle(): void { + this.#storageService.set(StorageKeys.ADMIN_MODE, String(!this.status())); + this.#logging.log("adminMode", `Toggle. Current value: ${this.status()}.`); + } +} diff --git a/src/app/services/logging.service.ts b/src/app/services/logging.service.ts index f67491d..99984f3 100644 --- a/src/app/services/logging.service.ts +++ b/src/app/services/logging.service.ts @@ -3,7 +3,7 @@ // import { Injectable, isDevMode } from "@angular/core"; import { Injectable } from "@angular/core"; -const interestedFields = ["auth", "ws", "swUpdate", "user", "dashboardEventLoad", "userInfo"] as const; +const interestedFields = ["auth", "ws", "swUpdate", "user", "dashboardEventLoad", "userInfo", "adminMode"] as const; export type Fields = typeof interestedFields[number]; @@ -13,7 +13,7 @@ export type Fields = typeof interestedFields[number]; export class LoggingService { readonly #output = true; // readonly #output = !isDevMode(); - readonly #interestedFields = new Set(["auth", "ws", "swUpdate", "user", "dashboardEventLoad", "userInfo"]); + readonly #interestedFields = new Set(["auth", "ws", "swUpdate", "user", "dashboardEventLoad", "userInfo", "adminMode"]); log(field: Fields, message?: any, ...optionalParams: any[]) { if (this.#output && this.#interestedFields.has(field)) { diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts index 1869824..3f05f30 100644 --- a/src/app/services/storage.service.ts +++ b/src/app/services/storage.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@angular/core"; import { fromEvent, filter, map, type Observable, merge, Subject } from "rxjs"; import { StorageKeys } from "../tokens/storage.tokens"; +import { mapBoolean } from "../utils/booleanMapper"; type CustomStorageEvent = { readonly currentValue: string | null; @@ -48,13 +49,10 @@ export class StorageService { } getBoolean(key: StorageKeys, fallback = false): boolean { - const v = this.get(key); - if (typeof v === 'boolean') return v; - if (typeof v === 'number') return v !== 0; + const v = this.get(key); if (typeof v === 'string') { const s = v.trim().toLowerCase(); - if (['true', '1', 'yes', 'y', 'on'].includes(s)) return true; - if (['false', '0', 'no', 'n', 'off', ''].includes(s)) return false; + return mapBoolean(v) || fallback; } return fallback; } @@ -72,9 +70,12 @@ export class StorageService { return this; } - setIfNull(key: StorageKeys, value: T): this { + setIfNull(key: StorageKeys, value: T, cb?: () => void): this { if (this.get(key) === null) { this.set(key, value); + if (cb) { + cb(); + } } return this; } diff --git a/src/app/tokens/storage.tokens.ts b/src/app/tokens/storage.tokens.ts index 1c52d26..d7fe6ef 100644 --- a/src/app/tokens/storage.tokens.ts +++ b/src/app/tokens/storage.tokens.ts @@ -7,6 +7,7 @@ export const StorageKeys = { THEME: "theme", BROWSER_CORE_CHECK: "browser_core_check", SUDO_PASSWORD_MODE: "sudo_password_mode", + ADMIN_MODE: "admin_mode", } as const; // TODO diff --git a/src/app/utils/booleanMapper.ts b/src/app/utils/booleanMapper.ts new file mode 100644 index 0000000..c1d67c9 --- /dev/null +++ b/src/app/utils/booleanMapper.ts @@ -0,0 +1,6 @@ +export function mapBoolean(v: string | number | null): boolean { + if (typeof v === "string") { + return ['true', '1', 'yes', 'y', 'on'].includes(v); + } + return Boolean(v); +}