Skip to content

Commit

Permalink
Merge pull request #258 from e-picsa/feat/dashboard-user-roles
Browse files Browse the repository at this point in the history
Feat(dashboard) role-based user permissions
  • Loading branch information
chrismclarke authored Apr 2, 2024
2 parents 13f0d2c + 3f2b012 commit fd3b6d9
Show file tree
Hide file tree
Showing 43 changed files with 1,701 additions and 875 deletions.
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ libs/webcomponents/src/components/enketo-webform/libs
.vercel
apps/_deprecated
apps/picsa-apps/extension-app-native
# Invalid token versions
apps/picsa-server/supabase/types/index.ts
26 changes: 6 additions & 20 deletions apps/picsa-apps/dashboard/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,16 @@
</button>
<dashboard-deployment-select />
<span style="flex: 1; text-align: center;">PICSA Dashboard</span>
@if(supabaseService.auth.authUser(); as user){
<div style="position: relative">
<button mat-button (click)="supabaseService.auth.signOut()" style="margin-top: 16px">
<mat-icon>person</mat-icon>
Sign Out
</button>
<div style="position: absolute; top: 4px; right: 0; font-size: 12px">
{{ user.email }}
</div>
</div>

} @else {
<button mat-button (click)="supabaseService.auth.signInPrompt()" style="margin: 8px 0">
<mat-icon>person</mat-icon>
Sign In
</button>
}
<dashboard-profile-menu />

</mat-toolbar>

<mat-sidenav-container style="flex: 1">
<mat-sidenav #sidenav mode="side" opened [fixedInViewport]="true" fixedTopGap="64">
<mat-nav-list>
@for (link of navLinks; track link.href) { @if(link.children){
<!-- Nested nav items -->
<mat-expansion-panel class="mat-elevation-z0" [expanded]="rla.isActive">
<mat-expansion-panel class="mat-elevation-z0" [expanded]="rla.isActive" *roleRequired="link.roleRequired">
<mat-expansion-panel-header
style="padding: 0 16px"
[routerLink]="link.href"
Expand All @@ -46,6 +31,7 @@
mat-list-item
[routerLink]="link.href + child.href"
routerLinkActive="mdc-list-item--activated active-link"
*roleRequired="link.roleRequired"
>
{{ child.label }}</a
>
Expand All @@ -61,7 +47,7 @@
<div mat-subheader>Global Admin</div>
<mat-divider></mat-divider>
@for (link of globalLinks; track link.href) {
<a mat-list-item [routerLink]="link.href" routerLinkActive="mdc-list-item--activated active-link">
<a mat-list-item [routerLink]="link.href" routerLinkActive="mdc-list-item--activated active-link" *roleRequired="link.roleRequired">
<ng-container *ngTemplateOutlet="linkTemplate; context: { $implicit: link }"></ng-container>
</a>
}
Expand All @@ -74,7 +60,7 @@
</div>

<ng-template #linkTemplate let-link>
<div class="nav-item">
<div class="nav-item" >
@if(link.matIcon){
<mat-icon style="margin-right: 8px">{{ link.matIcon }}</mat-icon>
}
Expand Down
32 changes: 16 additions & 16 deletions apps/picsa-apps/dashboard/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,37 @@ import { AfterViewInit, Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SupabaseService } from '@picsa/shared/services/core/supabase';

import { DASHBOARD_NAV_LINKS, INavLink } from './data';
import { DASHBOARD_NAV_LINKS, GLOBAL_NAV_LINKS } from './data';
import { DashboardMaterialModule } from './material.module';
import { AuthRoleRequiredDirective } from './modules/auth';
import { DeploymentSelectComponent } from './modules/deployment/components';
import { DeploymentDashboardService } from './modules/deployment/deployment.service';
import { ProfileMenuComponent } from './modules/profile/components/profile-menu/profile-menu.component';

@Component({
standalone: true,
imports: [RouterModule, DashboardMaterialModule, DeploymentSelectComponent, CommonModule],
imports: [
RouterModule,
DashboardMaterialModule,
DeploymentSelectComponent,
CommonModule,
ProfileMenuComponent,
AuthRoleRequiredDirective,
],
selector: 'dashboard-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent implements AfterViewInit {
title = 'picsa-apps-dashboard';
navLinks = DASHBOARD_NAV_LINKS;
globalLinks = GLOBAL_NAV_LINKS;

globalLinks: INavLink[] = [
{
label: 'Deployments',
href: '/deployment',
matIcon: 'apps',
},
// {
// label: 'Users',
// href: '/users',
// },
];

constructor(public supabaseService: SupabaseService) {}
constructor(public supabaseService: SupabaseService, private deploymentService: DeploymentDashboardService) {}

async ngAfterViewInit() {
// eagerly initialise supabase and deployment services to ensure available
await this.supabaseService.ready();
await this.supabaseService.auth.signInDefaultUser();
await this.deploymentService.ready();
}
}
4 changes: 4 additions & 0 deletions apps/picsa-apps/dashboard/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const appRoutes: Route[] = [
path: 'monitoring',
loadChildren: () => import('./modules/monitoring/monitoring-forms.module').then((m) => m.MonitoringFormsPageModule),
},
{
path: 'profile',
loadChildren: () => import('./modules/profile/profile.module').then((m) => m.ProfileModule),
},
{
path: '**',
redirectTo: 'home',
Expand Down
14 changes: 13 additions & 1 deletion apps/picsa-apps/dashboard/src/app/data/navLinks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { IAuthRole } from '@picsa/shared/services/core/supabase/services/supabase-auth.service';

export interface INavLink {
label: string;
href: string;
matIcon?: string;
children?: INavLink[];
roleRequired?: IAuthRole;
}

export const DASHBOARD_NAV_LINKS = [
export const DASHBOARD_NAV_LINKS: INavLink[] = [
{
label: 'Home',
href: '/home',
Expand Down Expand Up @@ -47,3 +50,12 @@ export const DASHBOARD_NAV_LINKS = [
matIcon: 'translate',
},
];

export const GLOBAL_NAV_LINKS: INavLink[] = [
{
label: 'Deployments',
href: '/deployment',
matIcon: 'apps',
roleRequired: 'deployments.admin',
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Directive, effect, inject, input, TemplateRef, ViewContainerRef } from '@angular/core';
import { IAuthRole } from '@picsa/shared/services/core/supabase/services/supabase-auth.service';

import { DashboardAuthService } from '../services/auth.service';

/**
* Structural directive used to show/hide UI content based on required deployment auth roles
* https://angular.io/guide/structural-directives#creating-a-structural-directive
*
* @example
* ```html
* <div *roleRequired="'resources.viewer'">
* ```
*/
// eslint-disable-next-line @angular-eslint/directive-selector
@Directive({ selector: '[roleRequired]', standalone: true })
export class AuthRoleRequiredDirective {
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);

/** Track if template currently has view populated */
private hasView = false;

/** Input signal to track role required for view */
public roleRequired = input<IAuthRole>();

constructor(service: DashboardAuthService) {
// recalcuate user view permissions whenever requiredRole or deploymentRoles change
effect(() => {
const requiredRole = this.roleRequired();
const deploymentRoles = service.authRoles();
const canView = this.doesUserHaveRole(requiredRole, deploymentRoles);
this.setViewContent(canView);
});
}

private doesUserHaveRole(requiredRole?: IAuthRole, deploymentRoles?: IAuthRole[]) {
if (!requiredRole) return true;
if (!deploymentRoles) return false;
return deploymentRoles.includes(requiredRole);
}

/** Dynamically populate or remove associated view content depending on view permissions */
private setViewContent(canView: boolean) {
if (canView && !this.hasView) {
if (this.viewContainer && this.templateRef) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
}
} else if (!canView && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './authRoleRequired.directive';
1 change: 1 addition & 0 deletions apps/picsa-apps/dashboard/src/app/modules/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './directives';
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { computed, Injectable } from '@angular/core';
import { PicsaAsyncService } from '@picsa/shared/services/asyncService.service';
import {
IAuthRole,
IAuthUser,
SupabaseAuthService,
} from '@picsa/shared/services/core/supabase/services/supabase-auth.service';

import { DeploymentDashboardService } from '../../deployment/deployment.service';
import { IDeploymentRow } from '../../deployment/types';

/**
* Authentication and user permission handling
* Adapts Supabase auth to include deployment-specific user role-based-access controls
*/
@Injectable({ providedIn: 'root' })
export class DashboardAuthService extends PicsaAsyncService {
public authUser = this.supabaseAuthService.authUser;

public readonly authRoles = computed<IAuthRole[]>(() => {
const deployment = this.deploymentService.activeDeployment();
const user = this.supabaseAuthService.authUser();
return this.getAuthRoles(deployment, user);
});

constructor(private deploymentService: DeploymentDashboardService, private supabaseAuthService: SupabaseAuthService) {
super();
}

public override async init() {
await this.supabaseAuthService.ready();
await this.deploymentService.ready();
}

private getAuthRoles(deployment: IDeploymentRow | null, user: IAuthUser | undefined) {
if (!deployment) return [];
if (!user) return [];
const authRoles = user.picsa_roles[deployment.id] || [];
// assign default roles to all deployments
const defaultRoles: IAuthRole[] = ['resources.viewer', 'translations.viewer'];
const implicitRoles: IAuthRole[] = [];
for (const role of authRoles) {
const [feature, level] = role.split('.');
// assign implicit auth roles (anything lower than current level)
if (level === 'admin') {
implicitRoles.push(`${feature}.author` as IAuthRole);
}
if (level === 'admin' || level === 'author') {
implicitRoles.push(`${feature}.viewer` as IAuthRole);
}
}
const uniqueRoles = new Set([...defaultRoles, ...authRoles, ...implicitRoles]);
return [...uniqueRoles];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div style="display: flex; align-items: center">
@if(deployment.icon_path){
<img [src]="deployment.icon_path | storagePath" class="deployment-image" />
}
<span>{{ deployment.label }}</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
:host {
display: block;
}
.deployment-image {
height: 30px;
width: 30px;
object-fit: contain;
margin-right: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { DeploymentItemComponent } from './deployment-item.component';

describe('DeploymentItemComponent', () => {
let component: DeploymentItemComponent;
let fixture: ComponentFixture<DeploymentItemComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeploymentItemComponent],
}).compileComponents();

fixture = TestBed.createComponent(DeploymentItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { StoragePathPipe } from '@picsa/shared/services/core/supabase';

import { IDeploymentRow } from '../../types';

/** UI deployment display, consisting of icon and label */
@Component({
selector: 'dashboard-deployment-item',
standalone: true,
imports: [CommonModule, StoragePathPipe],
templateUrl: './deployment-item.component.html',
styleUrl: './deployment-item.component.scss',
})
export class DeploymentItemComponent {
@Input() deployment: IDeploymentRow;
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,17 @@
<button mat-button [matMenuTriggerFor]="menu" [style.visibility]="deployments.length > 0 ? 'visible' : 'hidden'">
<button mat-button [matMenuTriggerFor]="menu" [style.visibility]="userDeployments().length > 0 ? 'visible' : 'hidden'">
@if(service.activeDeployment(); as deployment){
<ng-container *ngTemplateOutlet="deploymentSummary; context: { $implicit: deployment }"></ng-container>
<dashboard-deployment-item [deployment]="deployment" />
} @else {
<div>Select Deployment</div>
}
<mat-icon iconPositionEnd>unfold_more</mat-icon>
</button>

<mat-menu #menu="matMenu">
@for(deployment of deployments; track deployment.id){
@for(deployment of userDeployments(); track deployment.id){
<button mat-menu-item class="menu-button" (click)="service.setActiveDeployment(deployment.id)">
<ng-container *ngTemplateOutlet="deploymentSummary; context: { $implicit: deployment }"></ng-container>
<dashboard-deployment-item [deployment]="deployment" />
</button>

}
</mat-menu>

<ng-template #deploymentSummary let-deployment>
<div style="display: flex; align-items: center">
@if(deployment.icon_path){
<img
[src]="deployment.icon_path | storagePath"
style="height: 30px; width: 30px; object-fit: contain; margin-right: 8px"
/>
}
<span>{{ deployment.label }}</span>
</div>
</ng-template>
Loading

0 comments on commit fd3b6d9

Please sign in to comment.