Compare commits

..

1 Commits

Author SHA1 Message Date
523c9d941e Add Angular frontend with Authentik OIDC authentication
- Angular 17 with standalone components
- Angular Material + Tailwind CSS
- OIDC authorization code flow with Authentik
- Role-based access control (USER, DEVELOPER, APPROVER, ADMIN)
- Dashboard with pending requests, tunnel list, and create mapping
- Nginx reverse proxy to backend API
- Multi-container Docker Compose setup (frontend, backend, postgres)
- Environment-based configuration (local, test, prod)
2026-02-16 01:47:04 +05:30
35 changed files with 1672 additions and 3 deletions

28
.dockerignore Normal file
View File

@ -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

View File

@ -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
- ${DB_PATH}:/var/lib/postgresql/data
networks:
- cftunnels-network
networks:
cftunnels-network:
driver: bridge

24
frontend/.dockerignore Normal file
View File

@ -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/

32
frontend/.gitignore vendored Normal file
View File

@ -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/

38
frontend/Dockerfile Normal file
View File

@ -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;"]

121
frontend/angular.json Normal file
View File

@ -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": []
}
}
}
}
}
}

40
frontend/nginx.conf Normal file
View File

@ -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;
}

38
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -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></router-outlet>`,
})
export class AppComponent {}

View File

@ -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,
},
})
),
],
};

View File

@ -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: '',
},
];

View File

@ -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;
};

View File

@ -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);
})
);
};

View File

@ -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<User | null>(null);
public user$ = this.userSubject.asObservable();
private isAuthenticatedSubject = new BehaviorSubject<boolean>(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<void> {
await this.oauthService.loadDiscoveryDocument();
await this.oauthService.initCodeFlow();
}
async handleLoginCallback(): Promise<void> {
await this.oauthService.loadDiscoveryDocumentAndTryLogin();
if (this.oauthService.isAuthenticated()) {
await this.fetchUserInfo();
this.isAuthenticatedSubject.next(true);
}
}
private async fetchUserInfo(): Promise<void> {
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();
}
}

View File

@ -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;
};

View File

@ -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<ApiResponse<User>> {
return this.http.get<ApiResponse<User>>(`${this.baseUrl}/whoami`);
}
getRequests(): Observable<ApiResponse<Request[]>> {
return this.http.get<ApiResponse<Request[]>>(`${this.baseUrl}/requests`);
}
approveRequest(requestId: string): Observable<Request> {
return this.http.put<Request>(
`${this.baseUrl}/requests/${requestId}/approve`,
{}
);
}
rejectRequest(requestId: string): Observable<Request> {
return this.http.put<Request>(
`${this.baseUrl}/requests/${requestId}/reject`,
{}
);
}
getConfiguredTunnels(): Observable<ApiResponse<Tunnel[]>> {
return this.http.get<ApiResponse<Tunnel[]>>(
`${this.baseUrl}/configured/tunnels`
);
}
getTunnelMappings(tunnelId: string): Observable<ApiResponse<Mapping[]>> {
return this.http.get<ApiResponse<Mapping[]>>(
`${this.baseUrl}/tunnels/${tunnelId}/mappings`
);
}
createMappingRequest(
tunnelId: string,
mapping: { subdomain: string; protocol: string; port: number }
): Observable<Request> {
return this.http.post<Request>(
`${this.baseUrl}/tunnels/${tunnelId}/requests`,
mapping
);
}
}

View File

@ -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: `
<mat-card class="create-card">
<mat-card-header>
<mat-icon mat-card-avatar>add_circle</mat-icon>
<mat-card-title>Create New Mapping Request</mat-card-title>
<mat-card-subtitle>Request a new subdomain mapping</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="mappingForm" (ngSubmit)="onSubmit()">
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Subdomain</mat-label>
<input
matInput
formControlName="subdomain"
placeholder="myapp"
/>
<span matSuffix>.hithomelabs.com</span>
@if (mappingForm.get('subdomain')?.hasError('required')) {
<mat-error>Subdomain is required</mat-error>
}
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Tunnel</mat-label>
<mat-select formControlName="tunnelId">
@for (tunnel of tunnels; track tunnel.id) {
<mat-option [value]="tunnel.id">
{{ tunnel.name }} ({{ tunnel.environment }})
</mat-option>
}
</mat-select>
@if (mappingForm.get('tunnelId')?.hasError('required')) {
<mat-error>Tunnel is required</mat-error>
}
</mat-form-field>
</div>
<div class="form-row two-columns">
<mat-form-field appearance="outline">
<mat-label>Protocol</mat-label>
<mat-select formControlName="protocol">
<mat-option value="http">HTTP</mat-option>
<mat-option value="https">HTTPS</mat-option>
<mat-option value="ssh">SSH</mat-option>
<mat-option value="tcp">TCP</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Port</mat-label>
<input
matInput
type="number"
formControlName="port"
placeholder="8080"
/>
@if (mappingForm.get('port')?.hasError('required')) {
<mat-error>Port is required</mat-error>
}
@if (mappingForm.get('port')?.hasError('min')) {
<mat-error>Port must be greater than 0</mat-error>
}
@if (mappingForm.get('port')?.hasError('max')) {
<mat-error>Port must be less than 65535</mat-error>
}
</mat-form-field>
</div>
<div class="form-actions">
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="mappingForm.invalid || isSubmitting"
>
@if (isSubmitting) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<mat-icon>send</mat-icon>
}
Submit Request
</button>
</div>
</form>
</mat-card-content>
</mat-card>
`,
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;
},
});
}
}

View File

@ -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: `
<mat-toolbar color="primary" class="toolbar">
<span class="title">
<mat-icon>hub</mat-icon>
CFTunnels
</span>
<span class="spacer"></span>
<span class="user-info" *ngIf="user">
{{ user.username }}
<span class="roles">({{ user.roles.join(', ') }})</span>
</span>
<button mat-icon-button (click)="logout()" title="Logout">
<mat-icon>logout</mat-icon>
</button>
</mat-toolbar>
<div class="dashboard-container">
<div class="dashboard-content">
<!-- Pending Requests Section - APPROVER/ADMIN only -->
@if (canApprove()) {
<app-pending-requests
[requests]="pendingRequests"
(requestUpdated)="loadRequests()"
></app-pending-requests>
}
<!-- Tunnel List Section - DEVELOPER+ -->
@if (canViewTunnels()) {
<app-tunnel-list [tunnels]="tunnels"></app-tunnel-list>
}
<!-- Create Mapping Section - USER+ -->
<app-create-mapping></app-create-mapping>
</div>
</div>
`,
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();
}
}

View File

@ -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: `
<mat-card class="requests-card">
<mat-card-header>
<mat-icon mat-card-avatar>pending_actions</mat-icon>
<mat-card-title>Pending Requests</mat-card-title>
<mat-card-subtitle
>{{ requests.length }} request(s) awaiting approval</mat-card-subtitle
>
</mat-card-header>
<mat-card-content>
@if (isLoading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (!isLoading && requests.length === 0) {
<div class="empty-state">
<mat-icon>check_circle</mat-icon>
<p>No pending requests</p>
</div>
}
@for (request of requests; track request.id) {
<div class="request-item">
<div class="request-info">
<span class="subdomain">{{ request.mapping.subdomain }}.hithomelabs.com</span>
<span class="tunnel"> {{ request.mapping.tunnel.name }}</span>
<span class="port">Port: {{ request.mapping.port }}</span>
</div>
<div class="request-actions">
<button
mat-mini-fab
color="primary"
(click)="approve(request.id)"
[disabled]="actionInProgress"
title="Approve"
>
<mat-icon>check</mat-icon>
</button>
<button
mat-mini-fab
color="warn"
(click)="reject(request.id)"
[disabled]="actionInProgress"
title="Reject"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
</mat-card-content>
</mat-card>
`,
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<void>();
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;
},
});
}
}

View File

@ -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: `
<mat-card class="tunnels-card">
<mat-card-header>
<mat-icon mat-card-avatar>dns</mat-icon>
<mat-card-title>Tunnels</mat-card-title>
<mat-card-subtitle
>{{ tunnels.length }} tunnel(s) configured</mat-card-subtitle
>
</mat-card-header>
<mat-card-content>
@if (isLoading) {
<div class="loading-container">
<mat-spinner diameter="30"></mat-spinner>
</div>
}
@if (!isLoading && tunnels.length === 0) {
<div class="empty-state">
<mat-icon>info</mat-icon>
<p>No tunnels configured</p>
</div>
}
<mat-accordion>
@for (tunnel of tunnels; track tunnel.id) {
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon class="tunnel-icon">hub</mat-icon>
{{ tunnel.name }}
</mat-panel-title>
<mat-panel-description>
{{ tunnel.environment }}
</mat-panel-description>
</mat-expansion-panel-header>
@if (expandedTunnelId === tunnel.id) {
@if (mappingsLoading) {
<mat-spinner diameter="20"></mat-spinner>
} @else if (tunnelMappings[tunnel.id]?.length === 0) {
<p class="no-mappings">No mappings for this tunnel</p>
} @else {
@for (mapping of tunnelMappings[tunnel.id]; track mapping.id) {
<div class="mapping-item">
<span class="hostname"
>{{ mapping.subdomain }}.hithomelabs.com</span
>
<span class="service"
> {{ mapping.protocol }}://192.168.0.100:{{
mapping.port
}}</span
>
</div>
}
}
} @else {
<button
mat-button
color="primary"
(click)="loadMappings(tunnel.id)"
>
<mat-icon>visibility</mat-icon>
View Mappings
</button>
}
</mat-expansion-panel>
}
</mat-accordion>
</mat-card-content>
</mat-card>
`,
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;
},
});
}
}

View File

@ -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: `
<div class="login-container">
<mat-card class="login-card">
<mat-card-header>
<mat-card-title>
<mat-icon class="logo-icon">hub</mat-icon>
CFTunnels
</mat-card-title>
<mat-card-subtitle>Cloudflare Tunnel Management</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p>Sign in to manage your Cloudflare Tunnels and mappings.</p>
@if (isLoading) {
<div class="spinner-container">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else {
<button
mat-raised-button
color="primary"
class="login-button"
(click)="login()"
>
<mat-icon>login</mat-icon>
Login with Authentik
</button>
}
</mat-card-content>
</mat-card>
</div>
`,
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<void> {
this.isLoading = true;
await this.authService.login();
}
}

View File

@ -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<T> {
status: string;
data: T;
}
export interface TunnelWithMappings extends Tunnel {
mappings?: Mapping[];
}

View File

@ -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'
};

View File

@ -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'
};

View File

@ -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'
};

View File

@ -0,0 +1,7 @@
export const environment = {
production: false,
apiUrl: '/cloudflare',
issuer: 'https://auth.hithomelabs.com/application/o/cftunnels/',
oauthClientId: 'cftunnels',
redirectUri: '/login'
};

0
frontend/src/favicon.ico Normal file
View File

16
frontend/src/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CFTunnels</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

6
frontend/src/main.ts Normal file
View File

@ -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));

17
frontend/src/styles.scss Normal file
View File

@ -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;
}

View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,ts}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

28
frontend/tsconfig.json Normal file
View File

@ -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
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}