mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
FE patchdb, mocks, and most endpoints
This commit is contained in:
@@ -334,6 +334,104 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"start-tunnel": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "projects/start-tunnel",
|
||||||
|
"sourceRoot": "projects/start-tunnel/src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": {
|
||||||
|
"base": "dist/raw/start-tunnel",
|
||||||
|
"browser": ""
|
||||||
|
},
|
||||||
|
"index": "projects/start-tunnel/src/index.html",
|
||||||
|
"browser": "projects/start-tunnel/src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "projects/start-tunnel/tsconfig.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "projects/shared/assets",
|
||||||
|
"output": "assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@taiga-ui/icons/src",
|
||||||
|
"output": "assets/taiga-ui/icons"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
|
||||||
|
"node_modules/@taiga-ui/core/styles/taiga-ui-fonts.less",
|
||||||
|
"projects/start-tunnel/src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"namedChunks": true
|
||||||
|
},
|
||||||
|
"ci": {
|
||||||
|
"progress": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "start-tunnel:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "start-tunnel:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"options": {
|
||||||
|
"port": 8100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular/build:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "start-tunnel:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": [
|
||||||
|
"projects/start-tunnel/src/**/*.ts",
|
||||||
|
"projects/start-tunnel/src/**/*.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"root": "projects/marketplace",
|
"root": "projects/marketplace",
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ module.exports = {
|
|||||||
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
|
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
|
||||||
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
|
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
|
||||||
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
|
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
|
||||||
|
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
|
||||||
}
|
}
|
||||||
|
|||||||
9331
web/package-lock.json
generated
9331
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,13 @@
|
|||||||
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
|
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||||
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
|
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||||
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
|
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
|
||||||
|
"check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck",
|
||||||
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
|
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
|
||||||
"build:install": "ng run install-wizard:build",
|
"build:install": "ng run install-wizard:build",
|
||||||
"build:setup": "ng run setup-wizard:build",
|
"build:setup": "ng run setup-wizard:build",
|
||||||
"build:ui": "ng run ui:build",
|
"build:ui": "ng run ui:build",
|
||||||
"build:ui:dev": "ng run ui:build:development",
|
"build:ui:dev": "ng run ui:build:development",
|
||||||
|
"build:tunnel": "ng run start-tunnel:build",
|
||||||
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install",
|
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install",
|
||||||
"build:shared": "ng build shared",
|
"build:shared": "ng build shared",
|
||||||
"build:marketplace": "npm run build:shared && ng build marketplace",
|
"build:marketplace": "npm run build:shared && ng build marketplace",
|
||||||
@@ -26,38 +28,39 @@
|
|||||||
"start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0",
|
"start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0",
|
||||||
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
|
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
|
||||||
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
|
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
|
||||||
|
"start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0",
|
||||||
"start:ui:proxy": "npm run-script build-config && ng serve --project ui --host 0.0.0.0 --proxy-config proxy.conf.json",
|
"start:ui:proxy": "npm run-script build-config && ng serve --project ui --host 0.0.0.0 --proxy-config proxy.conf.json",
|
||||||
"build-config": "node build-config.js"
|
"build-config": "node build-config.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^20.1.0",
|
"@angular/animations": "^20.3.0",
|
||||||
"@angular/cdk": "^20.1.0",
|
"@angular/cdk": "^20.1.0",
|
||||||
"@angular/common": "^20.1.0",
|
"@angular/common": "^20.3.0",
|
||||||
"@angular/compiler": "^20.1.0",
|
"@angular/compiler": "^20.3.0",
|
||||||
"@angular/core": "^20.1.0",
|
"@angular/core": "^20.3.0",
|
||||||
"@angular/forms": "^20.1.0",
|
"@angular/forms": "^20.3.0",
|
||||||
"@angular/platform-browser": "^20.1.0",
|
"@angular/platform-browser": "^20.3.0",
|
||||||
"@angular/platform-browser-dynamic": "^20.1.0",
|
"@angular/platform-browser-dynamic": "^20.1.0",
|
||||||
"@angular/pwa": "^20.1.0",
|
"@angular/pwa": "^20.3.0",
|
||||||
"@angular/router": "^20.1.0",
|
"@angular/router": "^20.3.0",
|
||||||
"@angular/service-worker": "^20.1.0",
|
"@angular/service-worker": "^20.3.0",
|
||||||
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
||||||
"@noble/curves": "^1.4.0",
|
"@noble/curves": "^1.4.0",
|
||||||
"@noble/hashes": "^1.4.0",
|
"@noble/hashes": "^1.4.0",
|
||||||
"@start9labs/argon2": "^0.3.0",
|
"@start9labs/argon2": "^0.3.0",
|
||||||
"@start9labs/start-sdk": "file:../sdk/baseDist",
|
"@start9labs/start-sdk": "file:../sdk/baseDist",
|
||||||
"@taiga-ui/addon-charts": "4.52.0",
|
"@taiga-ui/addon-charts": "4.55.0",
|
||||||
"@taiga-ui/addon-commerce": "4.52.0",
|
"@taiga-ui/addon-commerce": "4.55.0",
|
||||||
"@taiga-ui/addon-mobile": "4.52.0",
|
"@taiga-ui/addon-mobile": "4.55.0",
|
||||||
"@taiga-ui/addon-table": "4.52.0",
|
"@taiga-ui/addon-table": "4.55.0",
|
||||||
"@taiga-ui/cdk": "4.52.0",
|
"@taiga-ui/cdk": "4.55.0",
|
||||||
"@taiga-ui/core": "4.52.0",
|
"@taiga-ui/core": "4.55.0",
|
||||||
"@taiga-ui/dompurify": "4.1.11",
|
"@taiga-ui/dompurify": "4.1.11",
|
||||||
"@taiga-ui/event-plugins": "4.7.0",
|
"@taiga-ui/event-plugins": "4.7.0",
|
||||||
"@taiga-ui/experimental": "4.52.0",
|
"@taiga-ui/experimental": "4.55.0",
|
||||||
"@taiga-ui/icons": "4.52.0",
|
"@taiga-ui/icons": "4.55.0",
|
||||||
"@taiga-ui/kit": "4.52.0",
|
"@taiga-ui/kit": "4.55.0",
|
||||||
"@taiga-ui/layout": "4.52.0",
|
"@taiga-ui/layout": "4.55.0",
|
||||||
"@taiga-ui/polymorpheus": "4.9.0",
|
"@taiga-ui/polymorpheus": "4.9.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
|
|||||||
35
web/projects/start-tunnel/README.md
Normal file
35
web/projects/start-tunnel/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# StartTunnel
|
||||||
|
|
||||||
|
StartTunnel is a self-hosted Wiregaurd VPN optimized for reverse tunneling to personal servers.
|
||||||
|
|
||||||
|
You can think of StartTunnel as a "virtual router in the cloud".
|
||||||
|
|
||||||
|
Use it for private, remote access, to self-hosted services running on a personal server, or to expose self-hosted services to the public Internet without revealing the host server's IP address.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Subnets
|
||||||
|
|
||||||
|
Create subnets (private networks/VLANs).
|
||||||
|
|
||||||
|
### Devices
|
||||||
|
|
||||||
|
Invite devices to join specific subnets. Each device receives a unique Wireguard config that can be copied, downloaded, or scanned to join the network.
|
||||||
|
|
||||||
|
### Port Forwards
|
||||||
|
|
||||||
|
Expose specific ports on specific devices to the public Internet.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
StartTunnel comes with a command line interface to manage Subnets, Devices, and Port Forwards.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
The StartTunnel UI is available at `https://<IP Address>` and ships with a self-signed SSL certificate. Users will need to bypass the browser's security warning to access the interface.
|
||||||
|
|
||||||
|
Users can provide their own SSL certificate using the CLI:
|
||||||
|
|
||||||
|
```
|
||||||
|
st certificate add </path/to/cert.pem>
|
||||||
|
```
|
||||||
43
web/projects/start-tunnel/src/app/app.config.ts
Normal file
43
web/projects/start-tunnel/src/app/app.config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { tuiDropdownOptionsProvider } from '@taiga-ui/core'
|
||||||
|
import { provideEventPlugins } from '@taiga-ui/event-plugins'
|
||||||
|
import { provideAnimations } from '@angular/platform-browser/animations'
|
||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
provideZonelessChangeDetection,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { provideRouter, withRouterConfig } from '@angular/router'
|
||||||
|
import { tuiDialogOptionsProvider } from '@taiga-ui/experimental'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import {
|
||||||
|
PATCH_CACHE,
|
||||||
|
PatchDbSource,
|
||||||
|
} from 'src/app/services/patch-db/patch-db-source'
|
||||||
|
import { routes } from './app.routes'
|
||||||
|
import { ApiService } from './services/api/api.service'
|
||||||
|
import { LiveApiService } from './services/api/live-api.service'
|
||||||
|
import { MockApiService } from './services/api/mock-api.service'
|
||||||
|
import { WorkspaceConfig } from '@start9labs/shared'
|
||||||
|
|
||||||
|
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideAnimations(),
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' })),
|
||||||
|
provideEventPlugins(),
|
||||||
|
tuiDropdownOptionsProvider({ appearance: 'start-9' }),
|
||||||
|
tuiDialogOptionsProvider({ appearance: 'start-9 taiga' }),
|
||||||
|
{
|
||||||
|
provide: PatchDB,
|
||||||
|
deps: [PatchDbSource, PATCH_CACHE],
|
||||||
|
useClass: PatchDB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ApiService,
|
||||||
|
useClass: useMocks ? MockApiService : LiveApiService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
3
web/projects/start-tunnel/src/app/app.html
Normal file
3
web/projects/start-tunnel/src/app/app.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<tui-root>
|
||||||
|
<router-outlet />
|
||||||
|
</tui-root>
|
||||||
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal file
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { inject } from '@angular/core'
|
||||||
|
import { Routes } from '@angular/router'
|
||||||
|
import { AuthService } from 'src/app/services/auth.service'
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadChildren: () => import('./routes/home'),
|
||||||
|
canMatch: [() => inject(AuthService).authenticated()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./routes/login'),
|
||||||
|
canMatch: [() => !inject(AuthService).authenticated()],
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: '' },
|
||||||
|
]
|
||||||
9
web/projects/start-tunnel/src/app/app.scss
Normal file
9
web/projects/start-tunnel/src/app/app.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-root {
|
||||||
|
height: 100%;
|
||||||
|
border-image: none;
|
||||||
|
}
|
||||||
17
web/projects/start-tunnel/src/app/app.ts
Normal file
17
web/projects/start-tunnel/src/app/app.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { TuiRoot } from '@taiga-ui/core'
|
||||||
|
import { Component, inject } from '@angular/core'
|
||||||
|
import { RouterOutlet } from '@angular/router'
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||||
|
import { PatchService } from './services/patch.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet, TuiRoot],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.scss',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
readonly subscription = inject(PatchService)
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { TuiButton } from '@taiga-ui/core'
|
||||||
|
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'header',
|
||||||
|
template: `
|
||||||
|
<img alt="Start9" src="assets/icons/favicon.svg" />
|
||||||
|
<h1>StartTunnel</h1>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
iconStart="@tui.menu"
|
||||||
|
appearance="action-grayscale"
|
||||||
|
(click.stop)="sidebars.start.set(!sidebars.start())"
|
||||||
|
>
|
||||||
|
Menu
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
grid-column: span 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-inline-start: 0.75rem;
|
||||||
|
background: var(--tui-background-neutral-2);
|
||||||
|
box-shadow: var(--tui-shadow-medium);
|
||||||
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font: var(--tui-font-heading-6);
|
||||||
|
margin-inline-end: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
grid-column: span 1;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [TuiButton],
|
||||||
|
})
|
||||||
|
export class Header {
|
||||||
|
protected readonly sidebars = inject(SidebarService)
|
||||||
|
}
|
||||||
130
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal file
130
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { Router, RouterLink, RouterLinkActive } from '@angular/router'
|
||||||
|
import { LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiButton } from '@taiga-ui/core'
|
||||||
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
|
import { AuthService } from 'src/app/services/auth.service'
|
||||||
|
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'nav',
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
@for (route of routes; track $index) {
|
||||||
|
<a
|
||||||
|
tuiButton
|
||||||
|
size="s"
|
||||||
|
appearance="flat-grayscale"
|
||||||
|
routerLinkActive="active"
|
||||||
|
[iconStart]="route.icon"
|
||||||
|
[routerLink]="route.link"
|
||||||
|
>
|
||||||
|
{{ route.name }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
iconStart="@tui.log-out"
|
||||||
|
appearance="neutral"
|
||||||
|
size="s"
|
||||||
|
(click)="logout()"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
backdrop-filter: blur(1rem);
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform var(--tui-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: 1;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: start;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
position: absolute;
|
||||||
|
top: 3.5rem;
|
||||||
|
width: 14rem;
|
||||||
|
bottom: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
|
||||||
|
&:not(:focus-within, ._expanded) {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [TuiButton, RouterLink, RouterLinkActive],
|
||||||
|
host: {
|
||||||
|
'[class._expanded]': 'sidebars.start()',
|
||||||
|
'(document:click)': 'sidebars.start.set(false)',
|
||||||
|
'(mousedown.prevent)': '0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class Nav {
|
||||||
|
private readonly service = inject(AuthService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
protected readonly sidebars = inject(SidebarService)
|
||||||
|
protected readonly api = inject(ApiService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
|
||||||
|
protected readonly routes = [
|
||||||
|
{
|
||||||
|
name: 'Subnets',
|
||||||
|
icon: '@tui.network',
|
||||||
|
link: 'subnets',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Devices',
|
||||||
|
icon: '@tui.laptop',
|
||||||
|
link: 'devices',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Port Forwards',
|
||||||
|
icon: '@tui.globe',
|
||||||
|
link: 'port-forwards',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
icon: '@tui.settings',
|
||||||
|
link: 'settings',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
protected async logout() {
|
||||||
|
const loader = this.loader.open().subscribe()
|
||||||
|
try {
|
||||||
|
await this.api.logout()
|
||||||
|
this.service.authenticated.set(false)
|
||||||
|
this.router.navigate(['.'])
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||||
|
import { RouterOutlet } from '@angular/router'
|
||||||
|
import { TuiScrollbar } from '@taiga-ui/core'
|
||||||
|
import { Header } from 'src/app/routes/home/components/header'
|
||||||
|
import { Nav } from 'src/app/routes/home/components/nav'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-outlet',
|
||||||
|
template: `
|
||||||
|
<header></header>
|
||||||
|
<nav></nav>
|
||||||
|
<main>
|
||||||
|
<tui-scrollbar>
|
||||||
|
<router-outlet />
|
||||||
|
</tui-scrollbar>
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template: 3.5rem 1fr / 14rem 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: grid-template var(--tui-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-scrollbar {
|
||||||
|
max-width: 50rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--tui-radius-s);
|
||||||
|
|
||||||
|
::ng-deep > tui-scroll-controls {
|
||||||
|
top: var(--tui-height-m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
grid-template: 3.5rem 1fr / 1fr;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [Header, Nav, RouterOutlet, TuiScrollbar],
|
||||||
|
})
|
||||||
|
export class Outlet {}
|
||||||
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal file
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Routes } from '@angular/router'
|
||||||
|
import { Outlet } from 'src/app/routes/home/components/outlet'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: Outlet,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'subnets',
|
||||||
|
loadComponent: () => import('./routes/subnets'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'devices',
|
||||||
|
loadComponent: () => import('./routes/devices'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'port-forwards',
|
||||||
|
loadComponent: () => import('./routes/port-forwards'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () => import('./routes/settings'),
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: 'subnets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies Routes
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
Signal,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import {
|
||||||
|
NonNullableFormBuilder,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { LoadingService } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
TUI_IS_MOBILE,
|
||||||
|
TuiAutoFocus,
|
||||||
|
tuiMarkControlAsTouchedAndValidate,
|
||||||
|
TuiStringHandler,
|
||||||
|
} from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButton,
|
||||||
|
TuiDataList,
|
||||||
|
TuiDropdown,
|
||||||
|
TuiError,
|
||||||
|
TuiIcon,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiTitle,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
|
||||||
|
import {
|
||||||
|
TUI_CONFIRM,
|
||||||
|
TuiCopy,
|
||||||
|
TuiDataListWrapper,
|
||||||
|
TuiFieldErrorPipe,
|
||||||
|
TuiSegmented,
|
||||||
|
TuiSelect,
|
||||||
|
TuiTextarea,
|
||||||
|
} from '@taiga-ui/kit'
|
||||||
|
import { TuiForm, TuiHeader } from '@taiga-ui/layout'
|
||||||
|
import { QrCodeComponent } from 'ng-qrcode'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { filter, map } from 'rxjs'
|
||||||
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
|
import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<table class="g-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Subnet</th>
|
||||||
|
<th>LAN IP</th>
|
||||||
|
<th [style.padding-inline-end.rem]="0.625">
|
||||||
|
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (device of devices(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ device.name }}</td>
|
||||||
|
<td>{{ device.subnetName }}</td>
|
||||||
|
<td>{{ device.ip }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
tuiDropdown
|
||||||
|
tuiDropdownOpen
|
||||||
|
appearance="flat-grayscale"
|
||||||
|
iconStart="@tui.ellipsis-vertical"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
<tui-data-list *tuiTextfieldDropdown size="s">
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.pencil"
|
||||||
|
new
|
||||||
|
(click)="onEdit(device.name)"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.settings"
|
||||||
|
new
|
||||||
|
(click)="config.set(true)"
|
||||||
|
>
|
||||||
|
View Config
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.trash"
|
||||||
|
new
|
||||||
|
(click)="onDelete(device)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</tui-data-list>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ng-template [tuiDialogOptions]="{ label: label() }" [(tuiDialog)]="dialog">
|
||||||
|
<form tuiForm [formGroup]="form">
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>Name</label>
|
||||||
|
<input tuiTextfield tuiAutoFocus formControlName="name" />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="name"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (!editing()) {
|
||||||
|
<tui-textfield tuiChevron [stringify]="subnetDisplay">
|
||||||
|
<label tuiLabel>Subnet</label>
|
||||||
|
@if (mobile) {
|
||||||
|
<select
|
||||||
|
tuiSelect
|
||||||
|
formControlName="subnet"
|
||||||
|
[items]="subnets()"
|
||||||
|
></select>
|
||||||
|
} @else {
|
||||||
|
<input tuiSelect formControlName="subnet" />
|
||||||
|
}
|
||||||
|
@if (!mobile) {
|
||||||
|
<tui-data-list-wrapper
|
||||||
|
*tuiTextfieldDropdown
|
||||||
|
new
|
||||||
|
[items]="subnets()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="subnet"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (form.controls.subnet.value.range) {
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>LAN IP</label>
|
||||||
|
<input tuiTextfield tuiAutoFocus formControlName="ip" />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="ip"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<footer><button tuiButton (click)="onSave()">Save</button></footer>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [(tuiDialog)]="config">
|
||||||
|
<header tuiHeader>
|
||||||
|
<h2 tuiTitle>Device Config</h2>
|
||||||
|
<aside tuiAccessories>
|
||||||
|
<tui-segmented #segmented>
|
||||||
|
<button>
|
||||||
|
<tui-icon icon="@tui.file" />
|
||||||
|
File
|
||||||
|
</button>
|
||||||
|
<button>
|
||||||
|
<tui-icon icon="@tui.qr-code" />
|
||||||
|
QR
|
||||||
|
</button>
|
||||||
|
</tui-segmented>
|
||||||
|
</aside>
|
||||||
|
</header>
|
||||||
|
@if (segmented?.activeItemIndex) {
|
||||||
|
<qr-code [value]="mock" size="352" />
|
||||||
|
} @else {
|
||||||
|
<tui-textfield>
|
||||||
|
<textarea
|
||||||
|
tuiTextarea
|
||||||
|
[min]="16"
|
||||||
|
[max]="16"
|
||||||
|
[readOnly]="true"
|
||||||
|
[value]="mock"
|
||||||
|
></textarea>
|
||||||
|
<tui-icon tuiCopy />
|
||||||
|
<a
|
||||||
|
tuiIconButton
|
||||||
|
iconStart="@tui.download"
|
||||||
|
download="start-tunnel.conf"
|
||||||
|
size="s"
|
||||||
|
[href]="href"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</tui-textfield>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TuiButton,
|
||||||
|
TuiDropdown,
|
||||||
|
TuiDataList,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiDialog,
|
||||||
|
TuiForm,
|
||||||
|
TuiError,
|
||||||
|
TuiFieldErrorPipe,
|
||||||
|
TuiAutoFocus,
|
||||||
|
TuiSelect,
|
||||||
|
TuiDataListWrapper,
|
||||||
|
TuiHeader,
|
||||||
|
TuiTitle,
|
||||||
|
TuiSegmented,
|
||||||
|
TuiIcon,
|
||||||
|
QrCodeComponent,
|
||||||
|
TuiTextarea,
|
||||||
|
TuiCopy,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class Devices {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loading = inject(LoadingService)
|
||||||
|
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
|
||||||
|
|
||||||
|
protected readonly mock = MOCK
|
||||||
|
protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(MOCK)}`
|
||||||
|
protected readonly dialog = signal(false)
|
||||||
|
protected readonly config = signal(false)
|
||||||
|
protected readonly editing = signal(false)
|
||||||
|
|
||||||
|
protected readonly subnets = toSignal<MappedSubnet[], []>(
|
||||||
|
this.patch.watch$('wg', 'subnets').pipe(
|
||||||
|
map(s =>
|
||||||
|
Object.entries(s).map(([range, { name, clients }]) => ({
|
||||||
|
range,
|
||||||
|
name,
|
||||||
|
clients,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
|
||||||
|
protected readonly devices = computed(() =>
|
||||||
|
this.subnets().flatMap(subnet =>
|
||||||
|
Object.entries(subnet.clients).map(([ip, { name }]) => ({
|
||||||
|
subnet: subnet.range,
|
||||||
|
subnetName: subnet.name,
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
protected subnetDisplay: TuiStringHandler<MappedSubnet> = subnet =>
|
||||||
|
subnet.range ? `${subnet.name} (${subnet.range})` : ''
|
||||||
|
|
||||||
|
protected readonly label = computed(() =>
|
||||||
|
this.editing() ? 'Rename device' : 'Add device',
|
||||||
|
)
|
||||||
|
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||||
|
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
subnet: [{} as MappedSubnet, Validators.required],
|
||||||
|
ip: ['', Validators.required],
|
||||||
|
})
|
||||||
|
|
||||||
|
protected onAdd(): void {
|
||||||
|
this.editing.set(false)
|
||||||
|
this.form.reset()
|
||||||
|
this.dialog.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEdit(name: string): void {
|
||||||
|
this.editing.set(true)
|
||||||
|
this.form.reset({ name })
|
||||||
|
this.dialog.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onSave() {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = this.loading.open().subscribe()
|
||||||
|
|
||||||
|
const { name, subnet, ip } = this.form.getRawValue()
|
||||||
|
const toSave = {
|
||||||
|
name,
|
||||||
|
subnet: subnet.range,
|
||||||
|
ip,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.editing()
|
||||||
|
? await this.api.editDevice(toSave)
|
||||||
|
: await this.api.addDevice(toSave)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
this.dialog.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDelete(device: MappedDevice): void {
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_CONFIRM, { label: 'Are you sure?' })
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const loader = this.loading.open().subscribe()
|
||||||
|
try {
|
||||||
|
await this.api.deleteDevice({ subnet: device.subnet, ip: device.ip })
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
this.dialog.set(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappedSubnet = {
|
||||||
|
range: string
|
||||||
|
name: string
|
||||||
|
clients: WgServer['subnets']['']['clients']
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappedDevice = {
|
||||||
|
subnet: string
|
||||||
|
subnetName: string
|
||||||
|
ip: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK = `[Interface]
|
||||||
|
# Server's private IP address for the WireGuard VPN subnet
|
||||||
|
Address = 10.20.10.1/24
|
||||||
|
# UDP port WireGuard listens on
|
||||||
|
ListenPort = 33333
|
||||||
|
# Server private key (generated)
|
||||||
|
PrivateKey = 4K68mdpQWdEz/FpdVuRoZYgWpQgpW63J9GFzn+iOulQ=
|
||||||
|
|
||||||
|
# Commands to run after starting/stopping WireGuard tunnel to enable forwarding and NAT (example)
|
||||||
|
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||||
|
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
|
||||||
|
|
||||||
|
# Add client peers below with their public keys and allowed IPs
|
||||||
|
[Peer]
|
||||||
|
# Client public key
|
||||||
|
PublicKey = MQBiYHxAj7u8paj3L4w4uav3P/9YBPbaN4gkWn90SSs=
|
||||||
|
# Allowed client IP address within VPN subnet`
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import {
|
||||||
|
NonNullableFormBuilder,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { LoadingService } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
TUI_IS_MOBILE,
|
||||||
|
tuiMarkControlAsTouchedAndValidate,
|
||||||
|
TuiStringHandler,
|
||||||
|
} from '@taiga-ui/cdk'
|
||||||
|
import { TuiButton, TuiError, TuiTextfield } from '@taiga-ui/core'
|
||||||
|
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
|
||||||
|
import {
|
||||||
|
TUI_CONFIRM,
|
||||||
|
TuiChevron,
|
||||||
|
TuiDataListWrapper,
|
||||||
|
TuiFieldErrorPipe,
|
||||||
|
TuiSelect,
|
||||||
|
} from '@taiga-ui/kit'
|
||||||
|
import { TuiForm } from '@taiga-ui/layout'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { combineLatest, filter, map, Observable } from 'rxjs'
|
||||||
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
|
import { TunnelData } from 'src/app/services/patch-db/data-model'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<table class="g-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>External IP</th>
|
||||||
|
<th>External Port</th>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Internal Port</th>
|
||||||
|
<th [style.padding-inline-end.rem]="0.625">
|
||||||
|
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (forward of forwards(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ forward.externalip }}</td>
|
||||||
|
<td>{{ forward.externalport }}</td>
|
||||||
|
<td>{{ forward.device.name }}</td>
|
||||||
|
<td>{{ forward.internalport }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
appearance="flat-grayscale"
|
||||||
|
iconStart="@tui.trash"
|
||||||
|
(click)="onDelete(forward)"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ng-template
|
||||||
|
[tuiDialogOptions]="{ label: 'Add port forward' }"
|
||||||
|
[(tuiDialog)]="dialog"
|
||||||
|
>
|
||||||
|
<form tuiForm [formGroup]="form">
|
||||||
|
<tui-textfield tuiChevron>
|
||||||
|
<label tuiLabel>External IP</label>
|
||||||
|
@if (mobile) {
|
||||||
|
<select
|
||||||
|
tuiSelect
|
||||||
|
formControlName="externalip"
|
||||||
|
[items]="ips"
|
||||||
|
></select>
|
||||||
|
} @else {
|
||||||
|
<input tuiSelect formControlName="externalip" />
|
||||||
|
}
|
||||||
|
@if (!mobile) {
|
||||||
|
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips" />
|
||||||
|
}
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="externalip"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>External Port</label>
|
||||||
|
<input tuiTextfield formControlName="externalport" />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="externalport"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
<tui-textfield tuiChevron [stringify]="deviceDisplay">
|
||||||
|
<label tuiLabel>Device</label>
|
||||||
|
@if (mobile) {
|
||||||
|
<select
|
||||||
|
tuiSelect
|
||||||
|
formControlName="device"
|
||||||
|
[items]="devices()"
|
||||||
|
></select>
|
||||||
|
} @else {
|
||||||
|
<input tuiSelect formControlName="device" />
|
||||||
|
}
|
||||||
|
@if (!mobile) {
|
||||||
|
<tui-data-list-wrapper
|
||||||
|
*tuiTextfieldDropdown
|
||||||
|
new
|
||||||
|
[items]="devices()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="device"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>Internal Port</label>
|
||||||
|
<input tuiTextfield formControlName="internalport" />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="internalport"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
<footer><button tuiButton (click)="onSave()">Save</button></footer>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TuiButton,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiDialog,
|
||||||
|
TuiForm,
|
||||||
|
TuiError,
|
||||||
|
TuiFieldErrorPipe,
|
||||||
|
TuiChevron,
|
||||||
|
TuiSelect,
|
||||||
|
TuiDataListWrapper,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class PortForwards {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loading = inject(LoadingService)
|
||||||
|
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
|
||||||
|
|
||||||
|
protected readonly dialog = signal(false)
|
||||||
|
|
||||||
|
protected readonly ips = ['69.1.1.42']
|
||||||
|
|
||||||
|
protected readonly devices$: Observable<MappedDevice[]> = this.patch
|
||||||
|
.watch$('wg', 'subnets')
|
||||||
|
.pipe(
|
||||||
|
map(s =>
|
||||||
|
Object.values(s).flatMap(({ clients }) =>
|
||||||
|
Object.entries(clients).map(([ip, { name }]) => ({
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
protected readonly devices = toSignal(this.devices$, {
|
||||||
|
initialValue: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
protected readonly forwards = toSignal<MappedForward[], []>(
|
||||||
|
combineLatest([this.devices$, this.patch.watch$('port_forwards')]).pipe(
|
||||||
|
map(([devices, forwards]) =>
|
||||||
|
Object.entries(forwards).map(([source, target]) => {
|
||||||
|
const sourceSplit = source.split(':')
|
||||||
|
const targetSplit = target.split(':')
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalip: sourceSplit[0]!,
|
||||||
|
externalport: sourceSplit[1]!,
|
||||||
|
device: devices.find(d => d.ip === targetSplit[0])!,
|
||||||
|
internalport: targetSplit[1]!,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
|
||||||
|
protected readonly deviceDisplay: TuiStringHandler<MappedDevice> = device =>
|
||||||
|
device.ip ? `${device.name} (${device.ip})` : ''
|
||||||
|
|
||||||
|
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||||
|
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||||
|
externalip: ['', Validators.required],
|
||||||
|
externalport: ['', Validators.required],
|
||||||
|
device: [{} as MappedDevice, Validators.required],
|
||||||
|
internalport: ['', Validators.required],
|
||||||
|
})
|
||||||
|
|
||||||
|
protected onAdd(): void {
|
||||||
|
this.form.reset()
|
||||||
|
this.dialog.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onSave() {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = this.loading.open().subscribe()
|
||||||
|
|
||||||
|
const { externalip, externalport, device, internalport } =
|
||||||
|
this.form.getRawValue()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.addForward({
|
||||||
|
source: `${externalip}:${externalport}`,
|
||||||
|
target: `${device.ip}:${internalport}`,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
this.dialog.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDelete(forward: MappedForward): void {
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_CONFIRM, { label: 'Are you sure?' })
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const loader = this.loading.open().subscribe()
|
||||||
|
try {
|
||||||
|
await this.api.deleteForward({
|
||||||
|
source: `${forward.externalip}:${forward.externalport}`,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
this.dialog.set(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappedDevice = {
|
||||||
|
ip: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappedForward = {
|
||||||
|
externalip: string
|
||||||
|
externalport: string
|
||||||
|
device: MappedDevice
|
||||||
|
internalport: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import {
|
||||||
|
NonNullableFormBuilder,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { DialogService, ErrorService } from '@start9labs/shared'
|
||||||
|
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||||
|
import {
|
||||||
|
TuiAlertService,
|
||||||
|
TuiButton,
|
||||||
|
TuiError,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiTitle,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiButtonLoading, TuiFieldErrorPipe } from '@taiga-ui/kit'
|
||||||
|
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
|
||||||
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<form tuiCardLarge tuiForm [formGroup]="form">
|
||||||
|
<header tuiHeader>
|
||||||
|
<h2 tuiTitle>
|
||||||
|
Settings
|
||||||
|
<span tuiSubtitle>Change password</span>
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>New password</label>
|
||||||
|
<input formControlName="password" tuiTextfield />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="password"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>Confirm new password</label>
|
||||||
|
<input formControlName="confirm" tuiTextfield />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="confirm"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
<footer>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
(click)="onSave()"
|
||||||
|
[disabled]="form.invalid"
|
||||||
|
[loading]="loading()"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
form {
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
AsyncPipe,
|
||||||
|
TuiCard,
|
||||||
|
TuiForm,
|
||||||
|
TuiHeader,
|
||||||
|
TuiTitle,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiError,
|
||||||
|
TuiFieldErrorPipe,
|
||||||
|
TuiButton,
|
||||||
|
TuiButtonLoading,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class Settings {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly alerts = inject(TuiAlertService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
|
||||||
|
protected readonly loading = signal(false)
|
||||||
|
|
||||||
|
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||||
|
password: [
|
||||||
|
'',
|
||||||
|
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||||
|
],
|
||||||
|
confirm: [
|
||||||
|
'',
|
||||||
|
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
protected async onSave() {
|
||||||
|
const { password, confirm } = this.form.getRawValue()
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
this.form.controls.confirm.setErrors({
|
||||||
|
notEqual: 'New passwords do not match',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.setPassword({ password })
|
||||||
|
this.alerts
|
||||||
|
.open('Password changed', {
|
||||||
|
label: 'Success',
|
||||||
|
appearance: 'positive',
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
this.form.reset()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import {
|
||||||
|
NonNullableFormBuilder,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButton,
|
||||||
|
TuiDataList,
|
||||||
|
TuiDropdown,
|
||||||
|
TuiError,
|
||||||
|
TuiTextfield,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
|
||||||
|
import { TUI_CONFIRM, TuiFieldErrorPipe } from '@taiga-ui/kit'
|
||||||
|
import { TuiForm } from '@taiga-ui/layout'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { filter, map, tap } from 'rxjs'
|
||||||
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
|
import { TunnelData } from 'src/app/services/patch-db/data-model'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<table class="g-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>IP Range</th>
|
||||||
|
<th [style.padding-inline-end.rem]="0.625">
|
||||||
|
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (subnet of subnets(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ subnet.name }}</td>
|
||||||
|
<td>{{ subnet.range }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
tuiDropdown
|
||||||
|
tuiDropdownOpen
|
||||||
|
appearance="flat-grayscale"
|
||||||
|
iconStart="@tui.ellipsis-vertical"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
<tui-data-list *tuiTextfieldDropdown size="s">
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.pencil"
|
||||||
|
new
|
||||||
|
(click)="onEdit(subnet.name)"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.trash"
|
||||||
|
new
|
||||||
|
(click)="onDelete($index)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</tui-data-list>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ng-template [tuiDialogOptions]="{ label: label() }" [(tuiDialog)]="dialog">
|
||||||
|
<form tuiForm [formGroup]="form">
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>Name</label>
|
||||||
|
<input tuiTextfield tuiAutoFocus formControlName="name" />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="name"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
@if (!editing()) {
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>IP Range</label>
|
||||||
|
<input tuiTextfield formControlName="subnet" />
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
formControlName="subnet"
|
||||||
|
[error]="[] | tuiFieldError | async"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<footer><button tuiButton (click)="onSave()">Save</button></footer>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TuiButton,
|
||||||
|
TuiDropdown,
|
||||||
|
TuiDataList,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiDialog,
|
||||||
|
TuiForm,
|
||||||
|
TuiError,
|
||||||
|
TuiFieldErrorPipe,
|
||||||
|
TuiAutoFocus,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class Subnets {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loading = inject(LoadingService)
|
||||||
|
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
|
||||||
|
|
||||||
|
protected readonly dialog = signal(false)
|
||||||
|
protected readonly editing = signal(false)
|
||||||
|
|
||||||
|
protected readonly subnets = toSignal(
|
||||||
|
this.patch.watch$('wg', 'subnets').pipe(
|
||||||
|
map(s =>
|
||||||
|
Object.entries(s).map(([range, info]) => ({
|
||||||
|
range,
|
||||||
|
name: info.name,
|
||||||
|
hasClients: !!Object.keys(info.clients).length,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
|
||||||
|
protected readonly next = computed(() => {
|
||||||
|
const last = Number(
|
||||||
|
this.subnets().at(-1)?.range.split('/')[0]?.split('.')[2] || '-1',
|
||||||
|
)
|
||||||
|
return `10.59.${last + 1}.1/24`
|
||||||
|
})
|
||||||
|
|
||||||
|
protected readonly label = computed(() =>
|
||||||
|
this.editing() ? 'Rename Subnet' : 'Add Subnet',
|
||||||
|
)
|
||||||
|
|
||||||
|
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
subnet: ['', Validators.required],
|
||||||
|
})
|
||||||
|
|
||||||
|
protected onAdd(): void {
|
||||||
|
this.editing.set(false)
|
||||||
|
this.form.reset({ subnet: this.next() })
|
||||||
|
this.dialog.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEdit(name: string): void {
|
||||||
|
this.editing.set(true)
|
||||||
|
this.form.reset({ name })
|
||||||
|
this.dialog.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onSave() {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = this.loading.open().subscribe()
|
||||||
|
const value = this.form.getRawValue()
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.editing()
|
||||||
|
? await this.api.editSubnet(value)
|
||||||
|
: await this.api.addSubnet(value)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
this.dialog.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDelete(index: number): void {
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_CONFIRM, { label: 'Are you sure?' })
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const subnet = this.subnets().at(index)?.range
|
||||||
|
if (!subnet) return
|
||||||
|
|
||||||
|
const loader = this.loading.open().subscribe()
|
||||||
|
try {
|
||||||
|
await this.api.deleteSubnet({ subnet })
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
this.dialog.set(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal file
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { TuiButton, TuiError, TuiTextfield } from '@taiga-ui/core'
|
||||||
|
import { TuiButtonLoading } from '@taiga-ui/kit'
|
||||||
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
|
import { AuthService } from 'src/app/services/auth.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<img alt="Start9" src="assets/icons/favicon.svg" />
|
||||||
|
<form (ngSubmit)="login()">
|
||||||
|
<tui-textfield [tuiTextfieldCleaner]="false">
|
||||||
|
<input
|
||||||
|
tuiTextfield
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
(ngModelChange)="error.set(false)"
|
||||||
|
[disabled]="loading()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
appearance="action"
|
||||||
|
iconStart="@tui.log-in"
|
||||||
|
[loading]="loading()"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</tui-textfield>
|
||||||
|
@if (error()) {
|
||||||
|
<tui-error error="Password is invalid" />
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-textfield {
|
||||||
|
width: 18rem;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [TuiButton, TuiTextfield, FormsModule, TuiError, TuiButtonLoading],
|
||||||
|
})
|
||||||
|
export default class Login {
|
||||||
|
private readonly auth = inject(AuthService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
|
protected readonly error = signal(false)
|
||||||
|
protected readonly loading = signal(false)
|
||||||
|
|
||||||
|
password = ''
|
||||||
|
|
||||||
|
protected async login() {
|
||||||
|
this.loading.set(true)
|
||||||
|
try {
|
||||||
|
await this.api.login({ password: this.password })
|
||||||
|
this.auth.authenticated.set(true)
|
||||||
|
this.router.navigate(['.'])
|
||||||
|
} catch (e) {
|
||||||
|
this.error.set(true)
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { Dump } from 'patch-db-client'
|
||||||
|
import { TunnelData } from '../patch-db/data-model'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export abstract class ApiService {
|
||||||
|
abstract openWebsocket$<T>(guid: string): Observable<T>
|
||||||
|
abstract subscribe(): Promise<SubscribeRes>
|
||||||
|
// auth
|
||||||
|
abstract login(params: LoginReq): Promise<null>
|
||||||
|
abstract logout(): Promise<null>
|
||||||
|
abstract setPassword(params: LoginReq): Promise<null>
|
||||||
|
// subnets
|
||||||
|
abstract addSubnet(params: UpsertSubnetReq): Promise<null>
|
||||||
|
abstract editSubnet(params: UpsertSubnetReq): Promise<null>
|
||||||
|
abstract deleteSubnet(params: DeleteSubnetReq): Promise<null>
|
||||||
|
// devices
|
||||||
|
abstract addDevice(params: UpsertDeviceReq): Promise<null>
|
||||||
|
abstract editDevice(params: UpsertDeviceReq): Promise<null>
|
||||||
|
abstract deleteDevice(params: DeleteDeviceReq): Promise<null>
|
||||||
|
// forwards
|
||||||
|
abstract addForward(params: AddForwardReq): Promise<null>
|
||||||
|
abstract deleteForward(params: DeleteForwardReq): Promise<null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscribeRes = {
|
||||||
|
dump: Dump<TunnelData>
|
||||||
|
guid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginReq = { password: string }
|
||||||
|
|
||||||
|
export type UpsertSubnetReq = {
|
||||||
|
name: string
|
||||||
|
subnet: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteSubnetReq = {
|
||||||
|
subnet: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpsertDeviceReq = {
|
||||||
|
name: string
|
||||||
|
subnet: string
|
||||||
|
ip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteDeviceReq = {
|
||||||
|
subnet: string
|
||||||
|
ip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddForwardReq = {
|
||||||
|
source: string // externalip:port
|
||||||
|
target: string // internalip:port
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteForwardReq = {
|
||||||
|
source: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Injectable, DOCUMENT, inject } from '@angular/core'
|
||||||
|
import {
|
||||||
|
HttpService,
|
||||||
|
RPCOptions,
|
||||||
|
isRpcError,
|
||||||
|
RpcError,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
import { filter, firstValueFrom, Observable } from 'rxjs'
|
||||||
|
import { webSocket } from 'rxjs/webSocket'
|
||||||
|
import {
|
||||||
|
AddForwardReq,
|
||||||
|
ApiService,
|
||||||
|
DeleteDeviceReq,
|
||||||
|
DeleteForwardReq,
|
||||||
|
DeleteSubnetReq,
|
||||||
|
LoginReq,
|
||||||
|
SubscribeRes,
|
||||||
|
UpsertDeviceReq,
|
||||||
|
UpsertSubnetReq,
|
||||||
|
} from './api.service'
|
||||||
|
import { AuthService } from '../auth.service'
|
||||||
|
import { PATCH_CACHE } from '../patch-db/patch-db-source'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class LiveApiService extends ApiService {
|
||||||
|
private readonly http = inject(HttpService)
|
||||||
|
private readonly document = inject(DOCUMENT)
|
||||||
|
private readonly auth = inject(AuthService)
|
||||||
|
private readonly cache$ = inject(PATCH_CACHE)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
openWebsocket$<T>(guid: string): Observable<T> {
|
||||||
|
const { location } = this.document.defaultView!
|
||||||
|
const host = location.host
|
||||||
|
|
||||||
|
return webSocket({
|
||||||
|
url: `wss://${host}/ws/rpc/${guid}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(): Promise<SubscribeRes> {
|
||||||
|
return this.rpcRequest({ method: 'db.subscribe', params: {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth
|
||||||
|
|
||||||
|
async login(params: LoginReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'auth.login', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'auth.logout', params: {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(params: LoginReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'auth.set-password', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||||
|
return this.upsertSubnet(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async editSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||||
|
return this.upsertSubnet(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'subnet.delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// devices
|
||||||
|
|
||||||
|
async addDevice(params: UpsertDeviceReq): Promise<null> {
|
||||||
|
return this.upsertDevice(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async editDevice(params: UpsertDeviceReq): Promise<null> {
|
||||||
|
return this.upsertDevice(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'device.delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwards
|
||||||
|
|
||||||
|
async addForward(params: AddForwardReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'forward.create', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForward(params: DeleteForwardReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'forward.delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// private
|
||||||
|
|
||||||
|
private async upsertSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'subnet.upsert', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertDevice(params: UpsertDeviceReq): Promise<null> {
|
||||||
|
return this.rpcRequest({ method: 'device.upsert', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rpcRequest<T>(
|
||||||
|
options: RPCOptions,
|
||||||
|
urlOverride?: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await this.http.rpcRequest<T>(options, urlOverride)
|
||||||
|
const body = res.body
|
||||||
|
|
||||||
|
if (isRpcError(body)) {
|
||||||
|
if (body.error.code === 34) {
|
||||||
|
console.error('Unauthenticated, logging out')
|
||||||
|
this.auth.authenticated.set(false)
|
||||||
|
}
|
||||||
|
throw new RpcError(body.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchSequence = res.headers.get('x-patch-sequence')
|
||||||
|
if (patchSequence)
|
||||||
|
await firstValueFrom(
|
||||||
|
this.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
|
||||||
|
)
|
||||||
|
|
||||||
|
return body.result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core'
|
||||||
|
import { shareReplay, Subject, tap } from 'rxjs'
|
||||||
|
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||||
|
import {
|
||||||
|
AddForwardReq,
|
||||||
|
ApiService,
|
||||||
|
DeleteDeviceReq,
|
||||||
|
DeleteForwardReq,
|
||||||
|
DeleteSubnetReq,
|
||||||
|
LoginReq,
|
||||||
|
SubscribeRes,
|
||||||
|
UpsertDeviceReq,
|
||||||
|
UpsertSubnetReq,
|
||||||
|
} from './api.service'
|
||||||
|
import { pauseFor } from '@start9labs/shared'
|
||||||
|
import { AuthService } from '../auth.service'
|
||||||
|
import {
|
||||||
|
AddOperation,
|
||||||
|
Operation,
|
||||||
|
PatchOp,
|
||||||
|
RemoveOperation,
|
||||||
|
ReplaceOperation,
|
||||||
|
Revision,
|
||||||
|
} from 'patch-db-client'
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop'
|
||||||
|
import { mockTunnelData, WgClient, WgSubnet } from '../patch-db/data-model'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class MockApiService extends ApiService {
|
||||||
|
private readonly auth = inject(AuthService)
|
||||||
|
readonly mockWsSource$ = new Subject<Revision>()
|
||||||
|
sequence = 1
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
toObservable(this.auth.authenticated)
|
||||||
|
.pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.sequence = 1
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
openWebsocket$<T>(guid: string): WebSocketSubject<T> {
|
||||||
|
return this.mockWsSource$.pipe(
|
||||||
|
tap(v => console.log('MOCK WS', v)),
|
||||||
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
|
) as WebSocketSubject<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(): Promise<SubscribeRes> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
return {
|
||||||
|
dump: { id: 1, value: mockTunnelData },
|
||||||
|
guid: 'patch-db-guid',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(params: LoginReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(params: LoginReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: AddOperation<WgSubnet>[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.ADD,
|
||||||
|
path: `/wg/subnets/${params.subnet}`,
|
||||||
|
value: { name: params.name, clients: {} },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async editSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: ReplaceOperation<string>[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.REPLACE,
|
||||||
|
path: `/wg/subnets/${params.subnet}/name`,
|
||||||
|
value: params.name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: RemoveOperation[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.REMOVE,
|
||||||
|
path: `/wg/subnets/${params.subnet}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDevice(params: UpsertDeviceReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: AddOperation<WgClient>[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.ADD,
|
||||||
|
path: `/wg/subnets/${params.subnet}/clients/${params.ip}`,
|
||||||
|
value: { name: params.name },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async editDevice(params: UpsertDeviceReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: ReplaceOperation<string>[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.REPLACE,
|
||||||
|
path: `/wg/subnets/${params.subnet}/clients/${params.ip}/name`,
|
||||||
|
value: params.name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: RemoveOperation[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.REMOVE,
|
||||||
|
path: `/wg/subnets/${params.subnet}/clients/${params.ip}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async addForward(params: AddForwardReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: AddOperation<string>[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.ADD,
|
||||||
|
path: `/port_forwards/${params.source}`,
|
||||||
|
value: params.target,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForward(params: DeleteForwardReq): Promise<null> {
|
||||||
|
await pauseFor(1000)
|
||||||
|
|
||||||
|
const patch: RemoveOperation[] = [
|
||||||
|
{
|
||||||
|
op: PatchOp.REMOVE,
|
||||||
|
path: `/port_forwards/${params.source}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
this.mockRevision(patch)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
|
||||||
|
const revision = {
|
||||||
|
id: ++this.sequence,
|
||||||
|
patch,
|
||||||
|
}
|
||||||
|
this.mockWsSource$.next(revision)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
readonly authenticated = signal(false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type TunnelData = {
|
||||||
|
wg: WgServer
|
||||||
|
port_forwards: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WgServer = {
|
||||||
|
subnets: Record<string, WgSubnet>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WgSubnet = {
|
||||||
|
name: string
|
||||||
|
clients: Record<string, WgClient>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WgClient = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockTunnelData: TunnelData = {
|
||||||
|
wg: {
|
||||||
|
subnets: {
|
||||||
|
'10.59.0.1/24': {
|
||||||
|
name: 'Family',
|
||||||
|
clients: {
|
||||||
|
'10.59.0.2': {
|
||||||
|
name: 'Start9 Server',
|
||||||
|
},
|
||||||
|
'10.59.0.3': {
|
||||||
|
name: 'Phone',
|
||||||
|
},
|
||||||
|
'10.59.0.4': {
|
||||||
|
name: 'Laptop',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
port_forwards: {
|
||||||
|
'69.1.1.42:443': '10.59.0.2:5443',
|
||||||
|
'69.1.1.42:3000': '10.59.0.2:3000',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { inject, Injectable, InjectionToken } from '@angular/core'
|
||||||
|
import { Dump, Revision, Update } from 'patch-db-client'
|
||||||
|
import { BehaviorSubject, EMPTY, Observable, timer } from 'rxjs'
|
||||||
|
import {
|
||||||
|
bufferTime,
|
||||||
|
catchError,
|
||||||
|
filter,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from 'rxjs/operators'
|
||||||
|
import { ApiService } from '../api/api.service'
|
||||||
|
import { AuthService } from '../auth.service'
|
||||||
|
import { TunnelData } from './data-model'
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop'
|
||||||
|
|
||||||
|
export const PATCH_CACHE = new InjectionToken('', {
|
||||||
|
factory: () =>
|
||||||
|
new BehaviorSubject<Dump<TunnelData>>({
|
||||||
|
id: 0,
|
||||||
|
value: {} as TunnelData,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class PatchDbSource extends Observable<Update<TunnelData>[]> {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly auth = inject(AuthService)
|
||||||
|
|
||||||
|
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
|
||||||
|
switchMap(verified => (verified ? this.api.subscribe() : EMPTY)),
|
||||||
|
switchMap(({ dump, guid }) =>
|
||||||
|
this.api.openWebsocket$<Revision>(guid).pipe(
|
||||||
|
bufferTime(250),
|
||||||
|
filter(revisions => !!revisions.length),
|
||||||
|
startWith([dump]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
catchError((_, watch$) => timer(500).pipe(switchMap(() => watch$))),
|
||||||
|
startWith([{ id: 0, value: {} as TunnelData }]),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(subscriber => this.stream$.subscribe(subscriber))
|
||||||
|
}
|
||||||
|
}
|
||||||
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal file
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core'
|
||||||
|
import { tap, Observable } from 'rxjs'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { TunnelData } from './patch-db/data-model'
|
||||||
|
import { AuthService } from './auth.service'
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class PatchService extends Observable<unknown> {
|
||||||
|
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
|
||||||
|
private readonly auth = inject(AuthService)
|
||||||
|
|
||||||
|
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
|
||||||
|
tap(authed => (authed ? this.patch.start() : this.patch.stop())),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(subscriber => this.stream$.subscribe(subscriber))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class SidebarService {
|
||||||
|
readonly start = signal(false)
|
||||||
|
}
|
||||||
20
web/projects/start-tunnel/src/index.html
Normal file
20
web/projects/start-tunnel/src/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>StartTunnel</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="/assets/icons/favicon-96x96.png"
|
||||||
|
sizes="96x96"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body tuiTheme="dark">
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
web/projects/start-tunnel/src/main.ts
Normal file
5
web/projects/start-tunnel/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser'
|
||||||
|
import { appConfig } from './app/app.config'
|
||||||
|
import { App } from './app/app'
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig).catch(err => console.error(err))
|
||||||
90
web/projects/start-tunnel/src/styles.scss
Normal file
90
web/projects/start-tunnel/src/styles.scss
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
:root {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
--tui-text-action: #428bf9;
|
||||||
|
--tui-text-action-hover: #165eca;
|
||||||
|
--tui-background-accent-1: #428bf9;
|
||||||
|
--tui-background-accent-1-hover: #126df7;
|
||||||
|
--tui-background-accent-1-pressed: #156ed4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background:
|
||||||
|
conic-gradient(var(--tui-background-base)),
|
||||||
|
radial-gradient(circle at top right, #5240a8, transparent 40%),
|
||||||
|
radial-gradient(circle at bottom right, #9236c9, transparent),
|
||||||
|
radial-gradient(circle at 25% 100%, #5b65d5, transparent 30%),
|
||||||
|
radial-gradient(circle at bottom left, #0090c0, transparent 50%),
|
||||||
|
radial-gradient(circle at top left, #2a5ba8, transparent 50%),
|
||||||
|
linear-gradient(to bottom, #5654b2, transparent);
|
||||||
|
background-blend-mode: hard-light;
|
||||||
|
|
||||||
|
&:not([tuiTheme]) {
|
||||||
|
background-blend-mode: soft-light;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: rgb(255 255 255 / 15%);
|
||||||
|
backdrop-filter: brightness(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-dropdown[data-appearance='start-9'] {
|
||||||
|
background: none;
|
||||||
|
backdrop-filter: blur(1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-dialog[new][data-appearance~='start-9'] {
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
backdrop-filter: blur(5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: var(--tui-radius-s);
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1);
|
||||||
|
|
||||||
|
thead tr {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
backdrop-filter: blur(5rem);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
backdrop-filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
height: var(--tui-height-m);
|
||||||
|
padding: 0 1rem;
|
||||||
|
text-align: start;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: inset 0 1px var(--tui-background-neutral-1);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qr-code {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-data-list {
|
||||||
|
--tui-text-action: var(--tui-text-primary);
|
||||||
|
}
|
||||||
9
web/projects/start-tunnel/tsconfig.json
Normal file
9
web/projects/start-tunnel/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./"
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user