diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c1a8b7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Frontend +frontend/ +node_modules/ +dist/ +.angular/ + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Build +build/ +.gradle/ + +# Test +coverage/ + +# Environment +.env +*.env diff --git a/docker-compose.yaml b/docker-compose.yaml index 1983e88..8902242 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,27 @@ services: + frontend: + build: + context: ./frontend + args: + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - OAUTH_REDIRECT_URI=${OAUTH_REDIRECT_URI} + container_name: cftunnels-frontend_${ENV} + ports: + - "${FRONTEND_PORT:-80}:80" + environment: + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - OAUTH_REDIRECT_URI=${OAUTH_REDIRECT_URI} + depends_on: + - app + restart: unless-stopped + networks: + - cftunnels-network + app: image: gitea.hithomelabs.com/hithomelabs/cftunnels:${ENV} container_name: cftunnels_${ENV} ports: - - ${HOST_PORT}:8080 + - "${HOST_PORT:-8080}:8080" environment: - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID} - CLOUDFLARE_API_KEY=${CLOUDFLARE_API_KEY} @@ -17,7 +35,12 @@ services: - SWAGGER_OAUTH_CLIENT_ID=${SWAGGER_OAUTH_CLIENT_ID} env_file: - stack.env + depends_on: + - postgres restart: unless-stopped + networks: + - cftunnels-network + postgres: image: postgres:15-alpine container_name: cftunnel-db-${ENV} @@ -27,6 +50,12 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} restart: unless-stopped ports: - - "${DB_PORT}:5432" + - "${DB_PORT:-5432}:5432" volumes: - - ${DB_PATH}:/var/lib/postgresql/data \ No newline at end of file + - ${DB_PATH}:/var/lib/postgresql/data + networks: + - cftunnels-network + +networks: + cftunnels-network: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c08c0f9 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +.angular/ + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store + +# Logs +*.log +npm-debug.log* + +# Test +coverage/ + +# Misc +*.tgz +.cache/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3fe09c8 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +.angular/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment files (keep template) +# environment.ts is generated from environment.*.ts by Angular CLI + +# Test coverage +coverage/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +*.tgz +.cache/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..61679c1 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,38 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY . . + +# Build arguments for OIDC config +ARG OAUTH_CLIENT_ID=cftunnels +ARG OAUTH_REDIRECT_URI=http://localhost:80/login + +# Set build-time environment variables +ENV OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID +ENV OAUTH_REDIRECT_URI=$OAUTH_REDIRECT_URI + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist/cftunnels-frontend/browser /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..4c732eb --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,121 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "cftunnels-frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss", + "standalone": true + }, + "@schematics/angular:directive": { + "standalone": true + }, + "@schematics/angular:pipe": { + "standalone": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/cftunnels-frontend", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + }, + "local": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.local.ts" + } + ] + }, + "test": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.test.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "cftunnels-frontend:build:production" + }, + "development": { + "buildTarget": "cftunnels-frontend:build:development" + }, + "local": { + "buildTarget": "cftunnels-frontend:build:local" + }, + "test": { + "buildTarget": "cftunnels-frontend:build:test" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": ["zone.js", "zone.js/testing"], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + } + } + } + } + } +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..284ed85 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls to backend + location /cloudflare/ { + proxy_pass http://app:8080/cloudflare/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1183b91 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "cftunnels-frontend", + "version": "1.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^17.3.0", + "@angular/cdk": "^17.3.0", + "@angular/common": "^17.3.0", + "@angular/compiler": "^17.3.0", + "@angular/core": "^17.3.0", + "@angular/forms": "^17.3.0", + "@angular/material": "^17.3.0", + "@angular/platform-browser": "^17.3.0", + "@angular/platform-browser-dynamic": "^17.3.0", + "@angular/router": "^17.3.0", + "angular-oauth2-oidc": "^17.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.6.0", + "zone.js": "~0.14.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^17.3.0", + "@angular/cli": "^17.3.0", + "@angular/compiler-cli": "^17.3.0", + "@types/node": "^20.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "~5.4.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 0000000..a3de393 --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent {} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..2a18f3a --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,28 @@ +import { ApplicationConfig, importProvidersFrom } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { OAuthModule } from 'angular-oauth2-oidc'; + +import { routes } from './app/app.routes'; +import { authInterceptor } from './app/core/auth/auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + provideAnimations(), + importProvidersFrom( + OAuthModule.forRoot({ + config: { + issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/', + redirectUri: window.location.origin + '/login', + clientId: 'cftunnels', + scope: 'openid profile email offline_access', + responseType: 'code', + showDebugInformation: true, + }, + }) + ), + ], +}; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..0554f83 --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,24 @@ +import { Routes } from '@angular/router'; +import { authGuard } from './core/auth/auth.guard'; +import { loginGuard } from './core/auth/login.guard'; + +export const routes: Routes = [ + { + path: '', + canActivate: [authGuard], + loadComponent: () => + import('./features/dashboard/dashboard.component').then( + (m) => m.DashboardComponent + ), + }, + { + path: 'login', + canActivate: [loginGuard], + loadComponent: () => + import('./features/login/login.component').then((m) => m.LoginComponent), + }, + { + path: '**', + redirectTo: '', + }, +]; diff --git a/frontend/src/app/core/auth/auth.guard.ts b/frontend/src/app/core/auth/auth.guard.ts new file mode 100644 index 0000000..1b66992 --- /dev/null +++ b/frontend/src/app/core/auth/auth.guard.ts @@ -0,0 +1,17 @@ +import { inject } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService['oauthService'].isAuthenticated()) { + return true; + } + + router.navigate(['/login'], { + queryParams: { returnUrl: state.url }, + }); + return false; +}; diff --git a/frontend/src/app/core/auth/auth.interceptor.ts b/frontend/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 0000000..4b7a894 --- /dev/null +++ b/frontend/src/app/core/auth/auth.interceptor.ts @@ -0,0 +1,30 @@ +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, throwError } from 'rxjs'; +import { AuthService } from './auth.service'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const authService = inject(AuthService); + const router = inject(Router); + + const token = authService.getAccessToken(); + + if (token && req.url.includes('/cloudflare/')) { + req = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }); + } + + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + authService.logout(); + router.navigate(['/login']); + } + return throwError(() => error); + }) + ); +}; diff --git a/frontend/src/app/core/auth/auth.service.ts b/frontend/src/app/core/auth/auth.service.ts new file mode 100644 index 0000000..0c99a99 --- /dev/null +++ b/frontend/src/app/core/auth/auth.service.ts @@ -0,0 +1,99 @@ +import { Injectable, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { + OAuthService, + AuthConfig, +} from 'angular-oauth2-oidc'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { User } from '../shared/models'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + private userSubject = new BehaviorSubject(null); + public user$ = this.userSubject.asObservable(); + + private isAuthenticatedSubject = new BehaviorSubject(false); + public isAuthenticated$ = this.isAuthenticatedSubject.asObservable(); + + constructor( + private oauthService: OAuthService, + private router: Router + ) {} + + configure(): void { + const authCodeFlowConfig: AuthConfig = { + issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/', + redirectUri: window.location.origin + '/login', + clientId: 'cftunnels', + scope: 'openid profile email offline_access', + responseType: 'code', + showDebugInformation: true, + strictDiscoveryDocumentValidation: false, + useSilentRefresh: true, + }; + + this.oauthService.configure(authCodeFlowConfig); + this.oauthService.loadDiscoveryDocumentAndTryLogin(); + } + + async login(): Promise { + await this.oauthService.loadDiscoveryDocument(); + await this.oauthService.initCodeFlow(); + } + + async handleLoginCallback(): Promise { + await this.oauthService.loadDiscoveryDocumentAndTryLogin(); + if (this.oauthService.isAuthenticated()) { + await this.fetchUserInfo(); + this.isAuthenticatedSubject.next(true); + } + } + + private async fetchUserInfo(): Promise { + try { + const claims = await this.oauthService.loadUserProfile(); + const userInfo = claims as unknown as { + info: { email: string; name: string }; + groups: string[]; + }; + + const user: User = { + username: userInfo.info?.name || userInfo.info?.email || 'Unknown', + roles: userInfo.groups || [], + }; + + this.userSubject.next(user); + } catch (error) { + console.error('Error fetching user info:', error); + } + } + + logout(): void { + this.oauthService.logOut(); + this.userSubject.next(null); + this.isAuthenticatedSubject.next(false); + this.router.navigate(['/login']); + } + + getAccessToken(): string | null { + return this.oauthService.getAccessToken(); + } + + hasRole(role: string): boolean { + const user = this.userSubject.getValue(); + if (!user) return false; + return user.roles.some( + (r) => r === role || r === `ROLE_${role}` || r === `ROLE_${role.toUpperCase()}` + ); + } + + hasAnyRole(roles: string[]): boolean { + return roles.some((role) => this.hasRole(role)); + } + + getUser(): User | null { + return this.userSubject.getValue(); + } +} diff --git a/frontend/src/app/core/auth/login.guard.ts b/frontend/src/app/core/auth/login.guard.ts new file mode 100644 index 0000000..ec07e23 --- /dev/null +++ b/frontend/src/app/core/auth/login.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const loginGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService['oauthService'].isAuthenticated()) { + router.navigate(['/']); + return false; + } + + return true; +}; diff --git a/frontend/src/app/core/services/api.service.ts b/frontend/src/app/core/services/api.service.ts new file mode 100644 index 0000000..a393295 --- /dev/null +++ b/frontend/src/app/core/services/api.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + ApiResponse, + User, + Request, + Tunnel, + Mapping, +} from '../../shared/models'; + +@Injectable({ + providedIn: 'root', +}) +export class ApiService { + private baseUrl = '/cloudflare'; + + constructor(private http: HttpClient) {} + + whoami(): Observable> { + return this.http.get>(`${this.baseUrl}/whoami`); + } + + getRequests(): Observable> { + return this.http.get>(`${this.baseUrl}/requests`); + } + + approveRequest(requestId: string): Observable { + return this.http.put( + `${this.baseUrl}/requests/${requestId}/approve`, + {} + ); + } + + rejectRequest(requestId: string): Observable { + return this.http.put( + `${this.baseUrl}/requests/${requestId}/reject`, + {} + ); + } + + getConfiguredTunnels(): Observable> { + return this.http.get>( + `${this.baseUrl}/configured/tunnels` + ); + } + + getTunnelMappings(tunnelId: string): Observable> { + return this.http.get>( + `${this.baseUrl}/tunnels/${tunnelId}/mappings` + ); + } + + createMappingRequest( + tunnelId: string, + mapping: { subdomain: string; protocol: string; port: number } + ): Observable { + return this.http.post( + `${this.baseUrl}/tunnels/${tunnelId}/requests`, + mapping + ); + } +} diff --git a/frontend/src/app/features/dashboard/create-mapping/create-mapping.component.ts b/frontend/src/app/features/dashboard/create-mapping/create-mapping.component.ts new file mode 100644 index 0000000..65e2bc0 --- /dev/null +++ b/frontend/src/app/features/dashboard/create-mapping/create-mapping.component.ts @@ -0,0 +1,220 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Tunnel } from '../../../shared/models'; +import { ApiService } from '../../../core/services/api.service'; + +@Component({ + selector: 'app-create-mapping', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatSnackBarModule, + MatProgressSpinnerModule, + ], + template: ` + + + add_circle + Create New Mapping Request + Request a new subdomain mapping + + + + + + + Subdomain + + .hithomelabs.com + @if (mappingForm.get('subdomain')?.hasError('required')) { + Subdomain is required + } + + + + + + Tunnel + + @for (tunnel of tunnels; track tunnel.id) { + + {{ tunnel.name }} ({{ tunnel.environment }}) + + } + + @if (mappingForm.get('tunnelId')?.hasError('required')) { + Tunnel is required + } + + + + + + Protocol + + HTTP + HTTPS + SSH + TCP + + + + + Port + + @if (mappingForm.get('port')?.hasError('required')) { + Port is required + } + @if (mappingForm.get('port')?.hasError('min')) { + Port must be greater than 0 + } + @if (mappingForm.get('port')?.hasError('max')) { + Port must be less than 65535 + } + + + + + + @if (isSubmitting) { + + } @else { + send + } + Submit Request + + + + + + `, + styles: [ + ` + .create-card { + margin-bottom: 1.5rem; + } + + .form-row { + margin-bottom: 1rem; + } + + .two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } + + mat-form-field { + width: 100%; + } + + .form-actions { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; + } + + button[mat-raised-button] { + min-width: 150px; + } + + button mat-spinner { + display: inline-block; + } + `, + ], +}) +export class CreateMappingComponent implements OnInit { + mappingForm: FormGroup; + tunnels: Tunnel[] = []; + isSubmitting = false; + + constructor( + private fb: FormBuilder, + private apiService: ApiService, + private snackBar: MatSnackBar + ) { + this.mappingForm = this.fb.group({ + subdomain: ['', [Validators.required, Validators.pattern(/^[a-z0-9-]+$/)]], + tunnelId: ['', Validators.required], + protocol: ['http', Validators.required], + port: ['', [Validators.required, Validators.min(1), Validators.max(65535)]], + }); + } + + ngOnInit(): void { + this.loadTunnels(); + } + + loadTunnels(): void { + this.apiService.getConfiguredTunnels().subscribe({ + next: (response) => { + this.tunnels = response.data; + }, + error: (err) => { + console.error('Error loading tunnels:', err); + this.snackBar.open('Failed to load tunnels', 'Close', { + duration: 3000, + }); + }, + }); + } + + onSubmit(): void { + if (this.mappingForm.invalid) return; + + this.isSubmitting = true; + const { subdomain, tunnelId, protocol, port } = this.mappingForm.value; + + this.apiService.createMappingRequest(tunnelId, { + subdomain, + protocol, + port, + }).subscribe({ + next: () => { + this.snackBar.open('Mapping request submitted successfully!', 'Close', { + duration: 3000, + }); + this.mappingForm.reset({ protocol: 'http' }); + this.isSubmitting = false; + }, + error: (err) => { + console.error('Error creating mapping request:', err); + this.snackBar.open('Failed to submit mapping request', 'Close', { + duration: 3000, + }); + this.isSubmitting = false; + }, + }); + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.component.ts b/frontend/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..101dff6 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.ts @@ -0,0 +1,167 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatListModule } from '@angular/material/list'; +import { MatCardModule } from '@angular/material/card'; +import { AuthService } from '../../core/auth/auth.service'; +import { ApiService } from '../../core/services/api.service'; +import { User, Request, Tunnel } from '../../shared/models'; +import { PendingRequestsComponent } from './pending-requests/pending-requests.component'; +import { TunnelListComponent } from './tunnel-list/tunnel-list.component'; +import { CreateMappingComponent } from './create-mapping/create-mapping.component'; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [ + CommonModule, + MatToolbarModule, + MatButtonModule, + MatIconModule, + MatSidenavModule, + MatListModule, + MatCardModule, + PendingRequestsComponent, + TunnelListComponent, + CreateMappingComponent, + ], + template: ` + + + hub + CFTunnels + + + + {{ user.username }} + ({{ user.roles.join(', ') }}) + + + logout + + + + + + + @if (canApprove()) { + + } + + + @if (canViewTunnels()) { + + } + + + + + + `, + styles: [ + ` + .toolbar { + position: sticky; + top: 0; + z-index: 1000; + } + + .title { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + } + + .spacer { + flex: 1 1 auto; + } + + .user-info { + display: flex; + align-items: center; + gap: 0.5rem; + margin-right: 1rem; + font-size: 0.9rem; + } + + .roles { + color: rgba(255, 255, 255, 0.7); + font-size: 0.8rem; + } + + .dashboard-container { + display: flex; + justify-content: center; + padding: 1.5rem; + } + + .dashboard-content { + width: 100%; + max-width: 900px; + } + `, + ], +}) +export class DashboardComponent implements OnInit { + user: User | null = null; + pendingRequests: Request[] = []; + tunnels: Tunnel[] = []; + + constructor( + private authService: AuthService, + private apiService: ApiService + ) {} + + ngOnInit(): void { + this.authService.user$.subscribe((user) => { + this.user = user; + if (user) { + this.loadData(); + } + }); + } + + loadData(): void { + this.loadRequests(); + this.loadTunnels(); + } + + loadRequests(): void { + this.apiService.getRequests().subscribe({ + next: (response) => { + this.pendingRequests = response.data.filter( + (r) => r.status === 'PENDING' + ); + }, + error: (err) => console.error('Error loading requests:', err), + }); + } + + loadTunnels(): void { + this.apiService.getConfiguredTunnels().subscribe({ + next: (response) => { + this.tunnels = response.data; + }, + error: (err) => console.error('Error loading tunnels:', err), + }); + } + + canApprove(): boolean { + return this.authService.hasAnyRole(['APPROVER', 'ADMIN']); + } + + canViewTunnels(): boolean { + return this.authService.hasAnyRole(['DEVELOPER', 'APPROVER', 'ADMIN']); + } + + logout(): void { + this.authService.logout(); + } +} diff --git a/frontend/src/app/features/dashboard/pending-requests/pending-requests.component.ts b/frontend/src/app/features/dashboard/pending-requests/pending-requests.component.ts new file mode 100644 index 0000000..e821fc1 --- /dev/null +++ b/frontend/src/app/features/dashboard/pending-requests/pending-requests.component.ts @@ -0,0 +1,174 @@ +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { Request } from '../../../shared/models'; +import { ApiService } from '../../../core/services/api.service'; + +@Component({ + selector: 'app-pending-requests', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + MatProgressBarModule, + ], + template: ` + + + pending_actions + Pending Requests + {{ requests.length }} request(s) awaiting approval + + + + @if (isLoading) { + + } + + @if (!isLoading && requests.length === 0) { + + check_circle + No pending requests + + } + + @for (request of requests; track request.id) { + + + {{ request.mapping.subdomain }}.hithomelabs.com + → {{ request.mapping.tunnel.name }} + Port: {{ request.mapping.port }} + + + + check + + + close + + + + } + + + `, + styles: [ + ` + .requests-card { + margin-bottom: 1.5rem; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + color: #4caf50; + } + + .empty-state mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + } + + .request-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #eee; + } + + .request-item:last-child { + border-bottom: none; + } + + .request-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .subdomain { + font-weight: 500; + color: #1976d2; + } + + .tunnel { + color: #666; + font-size: 0.9rem; + } + + .port { + color: #999; + font-size: 0.85rem; + } + + .request-actions { + display: flex; + gap: 0.5rem; + } + `, + ], +}) +export class PendingRequestsComponent implements OnInit { + @Input() requests: Request[] = []; + @Output() requestUpdated = new EventEmitter(); + + isLoading = false; + actionInProgress = false; + + constructor(private apiService: ApiService) {} + + ngOnInit(): void {} + + approve(requestId: string): void { + this.actionInProgress = true; + this.apiService.approveRequest(requestId).subscribe({ + next: () => { + this.requestUpdated.emit(); + this.actionInProgress = false; + }, + error: (err) => { + console.error('Error approving request:', err); + this.actionInProgress = false; + }, + }); + } + + reject(requestId: string): void { + this.actionInProgress = true; + this.apiService.rejectRequest(requestId).subscribe({ + next: () => { + this.requestUpdated.emit(); + this.actionInProgress = false; + }, + error: (err) => { + console.error('Error rejecting request:', err); + this.actionInProgress = false; + }, + }); + } +} diff --git a/frontend/src/app/features/dashboard/tunnel-list/tunnel-list.component.ts b/frontend/src/app/features/dashboard/tunnel-list/tunnel-list.component.ts new file mode 100644 index 0000000..38d739e --- /dev/null +++ b/frontend/src/app/features/dashboard/tunnel-list/tunnel-list.component.ts @@ -0,0 +1,182 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Tunnel, Mapping } from '../../../shared/models'; +import { ApiService } from '../../../core/services/api.service'; + +@Component({ + selector: 'app-tunnel-list', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatExpansionModule, + MatProgressSpinnerModule, + ], + template: ` + + + dns + Tunnels + {{ tunnels.length }} tunnel(s) configured + + + + @if (isLoading) { + + + + } + + @if (!isLoading && tunnels.length === 0) { + + info + No tunnels configured + + } + + + @for (tunnel of tunnels; track tunnel.id) { + + + + hub + {{ tunnel.name }} + + + {{ tunnel.environment }} + + + + @if (expandedTunnelId === tunnel.id) { + @if (mappingsLoading) { + + } @else if (tunnelMappings[tunnel.id]?.length === 0) { + No mappings for this tunnel + } @else { + @for (mapping of tunnelMappings[tunnel.id]; track mapping.id) { + + {{ mapping.subdomain }}.hithomelabs.com + → {{ mapping.protocol }}://192.168.0.100:{{ + mapping.port + }} + + } + } + } @else { + + visibility + View Mappings + + } + + } + + + + `, + styles: [ + ` + .tunnels-card { + margin-bottom: 1.5rem; + } + + .loading-container { + display: flex; + justify-content: center; + padding: 2rem; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + color: #666; + } + + .empty-state mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + } + + .tunnel-icon { + margin-right: 0.5rem; + } + + .mapping-item { + display: flex; + flex-direction: column; + padding: 0.75rem; + margin: 0.5rem 0; + background: #f5f5f5; + border-radius: 4px; + } + + .hostname { + font-weight: 500; + color: #1976d2; + } + + .service { + color: #666; + font-size: 0.9rem; + } + + .no-mappings { + color: #999; + font-style: italic; + } + + mat-panel-title { + display: flex; + align-items: center; + } + `, + ], +}) +export class TunnelListComponent implements OnInit { + @Input() tunnels: Tunnel[] = []; + + isLoading = false; + mappingsLoading = false; + expandedTunnelId: string | null = null; + tunnelMappings: { [key: string]: Mapping[] } = {}; + + constructor(private apiService: ApiService) {} + + ngOnInit(): void {} + + loadMappings(tunnelId: string): void { + this.expandedTunnelId = tunnelId; + this.mappingsLoading = true; + + this.apiService.getTunnelMappings(tunnelId).subscribe({ + next: (response) => { + this.tunnelMappings[tunnelId] = response.data; + this.mappingsLoading = false; + }, + error: (err) => { + console.error('Error loading mappings:', err); + this.mappingsLoading = false; + }, + }); + } +} diff --git a/frontend/src/app/features/login/login.component.ts b/frontend/src/app/features/login/login.component.ts new file mode 100644 index 0000000..950d510 --- /dev/null +++ b/frontend/src/app/features/login/login.component.ts @@ -0,0 +1,127 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { AuthService } from '../../core/auth/auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + ], + template: ` + + + + + hub + CFTunnels + + Cloudflare Tunnel Management + + + + Sign in to manage your Cloudflare Tunnels and mappings. + + @if (isLoading) { + + + + } @else { + + login + Login with Authentik + + } + + + + `, + styles: [ + ` + .login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + + .login-card { + width: 400px; + padding: 2rem; + text-align: center; + } + + .logo-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 1rem; + color: #667eea; + } + + mat-card-title { + font-size: 2rem; + margin-bottom: 0.5rem; + } + + mat-card-subtitle { + margin-bottom: 2rem; + } + + p { + color: #666; + margin-bottom: 2rem; + } + + .login-button { + width: 100%; + padding: 1rem; + font-size: 1.1rem; + } + + .spinner-container { + display: flex; + justify-content: center; + margin-top: 2rem; + } + `, + ], +}) +export class LoginComponent implements OnInit { + isLoading = false; + + constructor( + private authService: AuthService, + private router: Router + ) {} + + ngOnInit(): void { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('code') || urlParams.has('state')) { + this.isLoading = true; + this.authService.handleLoginCallback().then(() => { + this.router.navigate(['/']); + }); + } + } + + async login(): Promise { + this.isLoading = true; + await this.authService.login(); + } +} diff --git a/frontend/src/app/shared/models/index.ts b/frontend/src/app/shared/models/index.ts new file mode 100644 index 0000000..ca86047 --- /dev/null +++ b/frontend/src/app/shared/models/index.ts @@ -0,0 +1,35 @@ +export interface User { + username: string; + roles: string[]; +} + +export interface Tunnel { + id: string; + name: string; + environment: string; +} + +export interface Mapping { + id: string; + subdomain: string; + protocol: string; + port: number; + tunnel: Tunnel; +} + +export interface Request { + id: string; + mapping: Mapping; + createdBy: User; + acceptedBy?: User; + status: 'PENDING' | 'APPROVED' | 'REJECTED'; +} + +export interface ApiResponse { + status: string; + data: T; +} + +export interface TunnelWithMappings extends Tunnel { + mappings?: Mapping[]; +} diff --git a/frontend/src/environments/environment.local.ts b/frontend/src/environments/environment.local.ts new file mode 100644 index 0000000..b24cb83 --- /dev/null +++ b/frontend/src/environments/environment.local.ts @@ -0,0 +1,7 @@ +export const environment = { + production: false, + apiUrl: '/cloudflare', + issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/', + oauthClientId: 'cftunnels', + redirectUri: 'http://localhost:80/login' +}; diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 0000000..aac802b --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,7 @@ +export const environment = { + production: true, + apiUrl: '/cloudflare', + issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/', + oauthClientId: 'cftunnels', + redirectUri: 'https://cftunnels.hithomelabs.com/login' +}; diff --git a/frontend/src/environments/environment.test.ts b/frontend/src/environments/environment.test.ts new file mode 100644 index 0000000..cfdf314 --- /dev/null +++ b/frontend/src/environments/environment.test.ts @@ -0,0 +1,7 @@ +export const environment = { + production: false, + apiUrl: '/cloudflare', + issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/', + oauthClientId: 'cftunnels', + redirectUri: 'https://testcf.hithomelabs.com/login' +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..7ffd987 --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,7 @@ +export const environment = { + production: false, + apiUrl: '/cloudflare', + issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/', + oauthClientId: 'cftunnels', + redirectUri: '/login' +}; diff --git a/frontend/src/favicon.ico b/frontend/src/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..f189185 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,16 @@ + + + + + CFTunnels + + + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..35b00f3 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..8fd4106 --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,17 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: Roboto, "Helvetica Neue", sans-serif; + background-color: #f5f5f5; +} + +.spacer { + flex: 1 1 auto; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..8974f83 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..5b9d3c5 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..fb9e5aa --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022", "dom"], + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..5d13f8a --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +}
No pending requests
No tunnels configured
No mappings for this tunnel
Sign in to manage your Cloudflare Tunnels and mappings.