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",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
@ -64,7 +64,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
@ -79,10 +79,10 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"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,
"dependencies": {
"@angular/animations": "^19.1.0",
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.1.0",
"@angular/platform-browser": "^19.1.0",
"@angular/platform-browser-dynamic": "^19.1.0",
"@angular/platform-server": "^19.1.0",
"@angular/router": "^19.1.0",
"@angular/ssr": "^19.2.14",
"@angular/animations": "^20.3.6",
"@angular/common": "^20.3.6",
"@angular/compiler": "^20.3.6",
"@angular/core": "^20.3.6",
"@angular/forms": "^20.3.6",
"@angular/platform-browser": "^20.3.6",
"@angular/platform-browser-dynamic": "^20.3.6",
"@angular/platform-server": "^20.3.6",
"@angular/router": "^20.3.6",
"@angular/ssr": "^20.3.6",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.1.7",
"@angular/cli": "^19.1.7",
"@angular/compiler-cli": "^19.1.0",
"@angular/build": "^20.3.6",
"@angular/cli": "^20.3.6",
"@angular/compiler-cli": "^20.3.6",
"@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0",
"jasmine-core": "~5.5.0",
@ -36,6 +36,6 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.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 {authGuard} from './guards/auth.guard';
import {ShellComponent} from './components/shell/shell.component';
import {subjectResolver} from './resolvers/subject.resolver';
export const routes: Routes = [
{
@ -21,6 +22,9 @@ export const routes: Routes = [
},
],
canActivate: [authGuard],
resolve: {
user: subjectResolver,
}
},
{
path: '**',

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import {Injectable} from '@angular/core';
import {GandalfClient} from '../gandalf-client';
import {lastValueFrom, Observable, ObservableInput} from 'rxjs';
import {lastValueFrom, Observable} from 'rxjs';
export interface ValidateCredentialsCommand {
usernameOrEmail: string;
@ -31,4 +31,13 @@ export class AuthService extends GandalfClient {
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({
selector: 'app-nav-profile',
@ -8,5 +11,17 @@ import { Component } from '@angular/core';
standalone: true,
})
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">
<img class="logo" src="/full_logo.svg" alt="logo">
<app-panel class="shell-nav-bar neutral-20">
<app-icon path="/full_logo.svg" class="logo primary-icon-50"></app-icon>
<app-nav-profile></app-nav-profile>
</app-panel>

View File

@ -1,9 +1,10 @@
.shell-nav-bar {
.logo {
height: 3rem;
$height: 3rem;
height: $height;
width: #{$height * 3.356876171875};
}
app-nav-profile {
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 {RouterOutlet} from '@angular/router';
import {ActivatedRoute, RouterOutlet} from '@angular/router';
import {PanelComponent} from '../panel/panel.component';
import {IconComponent} from '../icon/icon.component';
import {toSignal} from '@angular/core/rxjs-interop';
@Component({
selector: 'app-shell',
imports: [
NavProfileComponent,
RouterOutlet,
PanelComponent
PanelComponent,
IconComponent
],
templateUrl: './shell.component.html',
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() {
--primary-color: #{$primary-color};
--secondary-color: #{$secondary-color};
@ -29,21 +23,39 @@ $danger-color: hsl(10, 80%, 50%);
--danger-color: #{$danger-color};
}
@mixin set-color-classes($name, $color, $step: 10, $padded: false) {
$from: if($padded, 0, 1);
$to: if($padded, 100/$step, (100/$step)-1);
@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: 1;
$to: 100/$step;
@if $padded {
$from: 0;
}
@if not $padded {
$to: $to - 1;
}
@for $i from $from through $to {
.#{$name}-#{$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 {
@include set-neutral-colors();
@include set-color-shades("neutral", hsl(0, 0%, 0%), 10);
@include set-common-colors();
@include set-color-shades("primary", $primary-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);
@include set-color-classes("secondary", $secondary-color, 10);
a {
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);
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);
}
[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)
{
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 });
}
}