Add icon component, update logo handling, and enhance user profile display

This commit is contained in:
Christian Werner 2025-10-17 23:17:38 +02:00
parent d0fd155ea3
commit 6db373573c
23 changed files with 1712 additions and 4236 deletions

View File

@ -18,7 +18,7 @@
"prefix": "app", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular/build:application",
"options": { "options": {
"outputPath": "dist/frontend", "outputPath": "dist/frontend",
"index": "src/index.html", "index": "src/index.html",
@ -64,7 +64,7 @@
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "frontend:build:production" "buildTarget": "frontend:build:production"
@ -79,10 +79,10 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n" "builder": "@angular/build:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": [ "polyfills": [
"zone.js", "zone.js",
@ -104,5 +104,31 @@
} }
} }
} }
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
} }
} }

View File

@ -10,24 +10,24 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^19.1.0", "@angular/animations": "^20.3.6",
"@angular/common": "^19.1.0", "@angular/common": "^20.3.6",
"@angular/compiler": "^19.1.0", "@angular/compiler": "^20.3.6",
"@angular/core": "^19.1.0", "@angular/core": "^20.3.6",
"@angular/forms": "^19.1.0", "@angular/forms": "^20.3.6",
"@angular/platform-browser": "^19.1.0", "@angular/platform-browser": "^20.3.6",
"@angular/platform-browser-dynamic": "^19.1.0", "@angular/platform-browser-dynamic": "^20.3.6",
"@angular/platform-server": "^19.1.0", "@angular/platform-server": "^20.3.6",
"@angular/router": "^19.1.0", "@angular/router": "^20.3.6",
"@angular/ssr": "^19.2.14", "@angular/ssr": "^20.3.6",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.1.7", "@angular/build": "^20.3.6",
"@angular/cli": "^19.1.7", "@angular/cli": "^20.3.6",
"@angular/compiler-cli": "^19.1.0", "@angular/compiler-cli": "^20.3.6",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0", "@types/node": "^18.18.0",
"jasmine-core": "~5.5.0", "jasmine-core": "~5.5.0",
@ -36,6 +36,6 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "typescript": "~5.9.3"
} }
} }

File diff suppressed because it is too large Load Diff

BIN
src/angular/frontend/public/full_logo.svg (Stored with Git LFS)

Binary file not shown.

View File

@ -3,6 +3,7 @@ import {LoginComponent} from './components/login/login.component';
import {HomeComponent} from './components/home/home.component'; import {HomeComponent} from './components/home/home.component';
import {authGuard} from './guards/auth.guard'; import {authGuard} from './guards/auth.guard';
import {ShellComponent} from './components/shell/shell.component'; import {ShellComponent} from './components/shell/shell.component';
import {subjectResolver} from './resolvers/subject.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -21,6 +22,9 @@ export const routes: Routes = [
}, },
], ],
canActivate: [authGuard], canActivate: [authGuard],
resolve: {
user: subjectResolver,
}
}, },
{ {
path: '**', path: '**',

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import {GandalfClient} from '../gandalf-client'; import {GandalfClient} from '../gandalf-client';
import {lastValueFrom, Observable, ObservableInput} from 'rxjs'; import {lastValueFrom, Observable} from 'rxjs';
export interface ValidateCredentialsCommand { export interface ValidateCredentialsCommand {
usernameOrEmail: string; usernameOrEmail: string;
@ -31,4 +31,13 @@ export class AuthService extends GandalfClient {
return lastValueFrom(this.login$(command, onError)); return lastValueFrom(this.login$(command, onError));
} }
logout$(onError?: (error: any) => void): Observable<void> {
return this.handleRequest(this.http.get<void>(this.base + '/logout'), onError);
}
logoutAsync(onError?: (error: any) => void): Promise<void> {
return lastValueFrom(this.logout$(onError));
}
} }

View File

@ -0,0 +1,19 @@
import {Injectable} from "@angular/core";
import {GandalfClient} from "../gandalf-client";
import {lastValueFrom, Observable} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class ProxyService extends GandalfClient {
private base = '/api/appproxy/q5lBLamL6rKy';
me$(onError?: (error: any) => void): Observable<{ "name": string }> {
return this.handleRequest(this.http.get<{ "name": string }>(this.base + '/api/subject/me'), onError);
}
meAsync(onError?: (error: any) => void): Promise<{ "name": string }> {
return lastValueFrom(this.me$(onError));
}
}

View File

@ -0,0 +1,5 @@
@if (path()) {
<div class="icon masked" style='--mask-path: url("{{path()}}")'></div>
} @else {
<div class="icon"></div>
}

View File

@ -0,0 +1,10 @@
.icon {
width: 100%;
height: 100%;
&.masked {
mask: var(--mask-path) no-repeat;
mask-size: contain;
mask-position: center;
background-color: var(--text-color);
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IconComponent } from './icon.component';
describe('IconComponent', () => {
let component: IconComponent;
let fixture: ComponentFixture<IconComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IconComponent]
})
.compileComponents();
fixture = TestBed.createComponent(IconComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,11 @@
import {Component, input} from '@angular/core';
@Component({
selector: 'app-icon',
imports: [],
templateUrl: './icon.component.html',
styleUrl: './icon.component.scss'
})
export class IconComponent {
public path = input<string>('');
}

View File

@ -1 +1,5 @@
<p>nav-profile works!</p> <div class="user-info">
<span>{{user()}}</span>
<a href="" (click)="logout($event)">Logout</a>
</div>
<div class="profile-picture neutral-30"></div>

View File

@ -0,0 +1,19 @@
:host {
display: flex;
.user-info {
display: flex;
flex-direction: column;
a {
margin-left: auto;
}
}
.profile-picture {
margin-left: 1rem;
width: 3rem;
height: 3rem;
overflow: hidden;
background-color: var(--bg-color);
}
}

View File

@ -1,4 +1,7 @@
import { Component } from '@angular/core'; import {Component, computed, inject} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {toSignal} from '@angular/core/rxjs-interop';
import {AuthService} from '../../../clients/gandalf/mithrandir/auth.service';
@Component({ @Component({
selector: 'app-nav-profile', selector: 'app-nav-profile',
@ -8,5 +11,17 @@ import { Component } from '@angular/core';
standalone: true, standalone: true,
}) })
export class NavProfileComponent { export class NavProfileComponent {
private route = inject(ActivatedRoute);
private authService = inject(AuthService); // Replace 'any' with the actual type of AuthService
private router = inject(Router); // Replace 'any' with the actual type of AuthService
private data = toSignal(this.route.data);
user = computed(() => this.data()?.["user"] as string | undefined ?? 'Unknown');
async logout($event: PointerEvent) {
$event.preventDefault();
await this.authService.logoutAsync();
await this.router.navigate(['/login']);
}
} }

View File

@ -1,4 +1,4 @@
<app-panel class="shell-nav-bar primary-50"> <app-panel class="shell-nav-bar neutral-20">
<img class="logo" src="/full_logo.svg" alt="logo"> <app-icon path="/full_logo.svg" class="logo primary-icon-50"></app-icon>
<app-nav-profile></app-nav-profile> <app-nav-profile></app-nav-profile>
</app-panel> </app-panel>

View File

@ -1,9 +1,10 @@
.shell-nav-bar { .shell-nav-bar {
.logo { .logo {
height: 3rem; $height: 3rem;
height: $height;
width: #{$height * 3.356876171875};
} }
app-nav-profile { app-nav-profile {
margin-left: auto; margin-left: auto;
} }
} }

View File

@ -1,14 +1,16 @@
import { Component } from '@angular/core'; import {Component, computed, inject} from '@angular/core';
import {NavProfileComponent} from './nav-profile/nav-profile.component'; import {NavProfileComponent} from './nav-profile/nav-profile.component';
import {RouterOutlet} from '@angular/router'; import {ActivatedRoute, RouterOutlet} from '@angular/router';
import {PanelComponent} from '../panel/panel.component'; import {PanelComponent} from '../panel/panel.component';
import {IconComponent} from '../icon/icon.component';
import {toSignal} from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'app-shell', selector: 'app-shell',
imports: [ imports: [
NavProfileComponent, NavProfileComponent,
RouterOutlet, PanelComponent,
PanelComponent IconComponent
], ],
templateUrl: './shell.component.html', templateUrl: './shell.component.html',
styleUrl: './shell.component.scss', styleUrl: './shell.component.scss',

View File

@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { ResolveFn } from '@angular/router';
import { subjectResolver } from './subject.resolver';
describe('subjectResolver', () => {
const executeResolver: ResolveFn<boolean> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => subjectResolver(...resolverParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import {ResolveFn} from '@angular/router';
import {inject} from '@angular/core';
import {ProxyService} from '../clients/gandalf/mithrandir/proxy.service';
import {map} from 'rxjs';
export const subjectResolver: ResolveFn<string> = (route, state) => {
const proxyService = inject(ProxyService);
return proxyService.me$().pipe(
map(x => {
console.log(x);
return x.name;
}
)
);
};

View File

@ -14,12 +14,6 @@ $danger-color: hsl(10, 80%, 50%);
} }
} }
@mixin set-neutral-colors($step: 10) {
@for $i from 0 through 100/$step {
--neutral-#{$i * $step}: hsl(0, 0%, #{$i * $step}%);
}
}
@mixin set-common-colors() { @mixin set-common-colors() {
--primary-color: #{$primary-color}; --primary-color: #{$primary-color};
--secondary-color: #{$secondary-color}; --secondary-color: #{$secondary-color};
@ -29,21 +23,39 @@ $danger-color: hsl(10, 80%, 50%);
--danger-color: #{$danger-color}; --danger-color: #{$danger-color};
} }
@mixin set-color-classes($name, $color, $step: 10, $padded: false) { @mixin set-color-classes($name, $color, $step: 10, $padded: false, $text-color-swap-step: 30, $text-color-swap-light: var(--neutral-10), $text-color-swap-dark: var(--neutral-90)) {
$from: if($padded, 0, 1); $from: 1;
$to: if($padded, 100/$step, (100/$step)-1); $to: 100/$step;
@if $padded {
$from: 0;
}
@if not $padded {
$to: $to - 1;
}
@for $i from $from through $to { @for $i from $from through $to {
.#{$name}-#{$i * $step} { .#{$name}-#{$i * $step} {
--bg-color: hsl(#{color.hue($color)}, #{color.saturation($color)}, #{$i * $step}%); --bg-color: hsl(#{color.hue($color)}, #{color.saturation($color)}, #{$i * $step}%);
--text-color: var(--neutral-10);
@if $i * $step <= $text-color-swap-step {
--text-color: #{$text-color-swap-dark};
} @else {
--text-color: #{$text-color-swap-light};
}
}
}
@for $i from $from through $to {
.#{$name}-icon-#{$i * $step} {
--text-color: hsl(#{color.hue($color)}, #{color.saturation($color)}, #{$i * $step}%);
} }
} }
} }
:root { :root {
@include set-neutral-colors(); @include set-color-shades("neutral", hsl(0, 0%, 0%), 10);
@include set-common-colors(); @include set-common-colors();
@include set-color-shades("primary", $primary-color, 10); @include set-color-shades("primary", $primary-color, 10);
@include set-color-shades("secondary", $secondary-color, 10); @include set-color-shades("secondary", $secondary-color, 10);
@ -59,8 +71,18 @@ $danger-color: hsl(10, 80%, 50%);
} }
} }
@include set-color-classes("primary", $primary-color, 10, true); a {
@include set-color-classes("secondary", $secondary-color, 10); color: var(--secondary-70);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
@include set-color-classes("neutral", hsl(0, 0%, 0%), 10, true);
@include set-color-classes("primary", $primary-color, 10, false);
@include set-color-classes("secondary", $secondary-color, 10, false, 60);

View File

@ -15,8 +15,3 @@ body {
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-color); color: var(--text-color);
} }
#path1 { fill: black; }
@media (prefers-color-scheme: dark) {
#path1 { fill: white; }
}

View File

@ -110,6 +110,21 @@ public class AuthController : ControllerBase
return Ok(true); return Ok(true);
} }
[HttpGet("[action]")]
public async Task<IActionResult> Me()
{
var sessionExists = Request.Cookies.ContainsKey("MithrandirSession");
if (!sessionExists)
{
return Unauthorized("Session expired.");
}
return Ok(sessionExists);
}
private string GetCacheKey(string subjectId, string appId, string tokenType) private string GetCacheKey(string subjectId, string appId, string tokenType)
{ {
return $"{subjectId}:{appId}:{tokenType}"; return $"{subjectId}:{appId}:{tokenType}";

View File

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Suspectus.Gandalf.Palantir.Abstractions;
using Suspectus.Gandalf.Palantir.Data.Database;
namespace Suspectus.Gandalf.Palantir.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SubjectController(InvokerContext invokerContext, ApplicationContext context) : ControllerBase
{
[HttpGet("me")]
public async Task<IActionResult> GetSubject()
{
var subject = await context.Subjects.Where(x => x.Id == invokerContext.Invoker!.SubjectId).Select(x => x.Name).SingleAsync();
return Ok(new { Name = subject });
}
}