Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/AmtocBots.Web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ── Build stage ───────────────────────────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build:prod

# ── Serve stage ───────────────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist/amtocbots-web/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
71 changes: 71 additions & 0 deletions src/AmtocBots.Web/angular.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"amtocbots-web": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/amtocbots-web",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{ "glob": "ngsw-worker.js", "input": "./node_modules/@angular/service-worker", "output": "." }
],
"styles": ["src/styles.scss"],
"scripts": [],
"serviceWorker": "ngsw-config.json"
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
],
"outputHashing": "all",
"optimization": true
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": { "buildTarget": "amtocbots-web:build:production" },
"development": { "buildTarget": "amtocbots-web:build:development" }
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
}
}
}
}
}
}
27 changes: 27 additions & 0 deletions src/AmtocBots.Web/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;

# PWA — all routes to index.html
location / {
try_files $uri $uri/ /index.html;
}

# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

# Never cache index.html or service worker
location = /index.html {
add_header Cache-Control "no-cache";
}
location = /ngsw-worker.js {
add_header Cache-Control "no-cache";
}

gzip on;
gzip_types text/plain text/css application/javascript application/json;
}
33 changes: 33 additions & 0 deletions src/AmtocBots.Web/ngsw-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app-shell",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/manifest.webmanifest", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"]
}
}
],
"dataGroups": [
{
"name": "api",
"urls": ["/api/**"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "1m",
"timeout": "10s"
}
}
]
}
45 changes: 45 additions & 0 deletions src/AmtocBots.Web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "amtocbots-web",
"version": "0.0.1",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --configuration production",
"test": "ng test",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^21.0.0",
"@angular/cdk": "^21.0.0",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/material": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/platform-browser-dynamic": "^21.0.0",
"@angular/router": "^21.0.0",
"@angular/service-worker": "^21.0.0",
"@microsoft/signalr": "^8.0.7",
"angular-auth-oidc-client": "^19.0.0",
"ngx-monaco-editor-v2": "^21.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^21.0.0",
"@angular/cli": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.3.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.6.0"
}
}
13 changes: 13 additions & 0 deletions src/AmtocBots.Web/proxy.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true
},
"/hubs": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"ws": true
}
}
10 changes: 10 additions & 0 deletions src/AmtocBots.Web/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class AppComponent {}
36 changes: 36 additions & 0 deletions src/AmtocBots.Web/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideServiceWorker } from '@angular/service-worker';
import { provideAuth, LogLevel } from 'angular-auth-oidc-client';
import { appRoutes } from './app.routes';
import { authTokenInterceptor } from './core/auth/auth-token.interceptor';
import { environment } from '../environments/environment';

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(appRoutes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(withInterceptors([authTokenInterceptor])),
provideAnimationsAsync(),
provideServiceWorker('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
provideAuth({
config: {
authority: environment.keycloak.authority,
redirectUrl: environment.keycloak.redirectUri,
postLogoutRedirectUri: environment.keycloak.postLogoutRedirectUri,
clientId: environment.keycloak.clientId,
scope: environment.keycloak.scope,
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
logLevel: environment.production ? LogLevel.Warn : LogLevel.Debug,
secureRoutes: [environment.apiBase, environment.hubBase],
},
}),
],
};
39 changes: 39 additions & 0 deletions src/AmtocBots.Web/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
import { ShellComponent } from './core/layout/shell.component';

export const appRoutes: Routes = [
{
path: 'callback',
loadComponent: () => import('./core/auth/auth-callback.component').then(m => m.AuthCallbackComponent),
},
{
path: '',
component: ShellComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadChildren: () => import('./features/dashboard/dashboard.routes').then(m => m.dashboardRoutes),
},
{
path: 'instances',
loadChildren: () => import('./features/instances/instances.routes').then(m => m.instanceRoutes),
},
{
path: 'models',
loadChildren: () => import('./features/models/models.routes').then(m => m.modelRoutes),
},
{
path: 'kanban',
loadChildren: () => import('./features/kanban/kanban.routes').then(m => m.kanbanRoutes),
},
{
path: 'chat',
loadChildren: () => import('./features/chat/chat.routes').then(m => m.chatRoutes),
},
],
},
{ path: '**', redirectTo: '' },
];
19 changes: 19 additions & 0 deletions src/AmtocBots.Web/src/app/core/auth/auth-callback.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Component({
selector: 'app-auth-callback',
standalone: true,
template: `<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#94a3b8">Authenticating…</div>`,
})
export class AuthCallbackComponent implements OnInit {
private readonly oidc = inject(OidcSecurityService);
private readonly router = inject(Router);

ngOnInit(): void {
this.oidc.checkAuth().subscribe(({ isAuthenticated }) => {
this.router.navigateByUrl(isAuthenticated ? '/' : '/');
});
}
}
18 changes: 18 additions & 0 deletions src/AmtocBots.Web/src/app/core/auth/auth-token.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { switchMap, take } from 'rxjs';

export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const oidc = inject(OidcSecurityService);

return oidc.getAccessToken().pipe(
take(1),
switchMap(token => {
if (token && (req.url.startsWith('/api') || req.url.startsWith('/hubs'))) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req);
})
);
};
17 changes: 17 additions & 0 deletions src/AmtocBots.Web/src/app/core/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map, take } from 'rxjs';

export const authGuard: CanActivateFn = () => {
const oidc = inject(OidcSecurityService);

return oidc.isAuthenticated$.pipe(
take(1),
map(({ isAuthenticated }) => {
if (isAuthenticated) return true;
oidc.authorize();
return false;
})
);
};
38 changes: 38 additions & 0 deletions src/AmtocBots.Web/src/app/core/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly oidc = inject(OidcSecurityService);

readonly isAuthenticated = toSignal(
this.oidc.isAuthenticated$.pipe(map(({ isAuthenticated }) => isAuthenticated)),
{ initialValue: false }
);

readonly userData = toSignal(
this.oidc.userData$.pipe(map(({ userData }) => userData)),
{ initialValue: null }
);

readonly accessToken = toSignal(
this.oidc.getAccessToken(),
{ initialValue: '' }
);

readonly roles = computed<string[]>(() => {
const ud = this.userData();
return ud?.realm_access?.roles ?? [];
});

readonly isAdmin = computed(() => this.roles().includes('admin'));
readonly isOperator = computed(() => this.roles().includes('admin') || this.roles().includes('operator'));

login() { this.oidc.authorize(); }
logout() { this.oidc.logoff().subscribe(); }

getToken(): string { return this.oidc.getAccessToken()() ?? ''; }
}

import { map } from 'rxjs';
6 changes: 6 additions & 0 deletions src/AmtocBots.Web/src/app/core/auth/role.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';

export const adminGuard: CanActivateFn = () => inject(AuthService).isAdmin();
export const operatorGuard: CanActivateFn = () => inject(AuthService).isOperator();
Loading