This commit is contained in:
Eugene 2024-10-28 08:58:40 +01:00 committed by GitHub
parent a903fdb811
commit f1d565b6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1522 additions and 1369 deletions

View file

@ -1,165 +0,0 @@
parser: '@typescript-eslint/parser'
parserOptions:
sourceType: module
project:
- ./tsconfig.json
extraFileExtensions:
- .svelte
env:
es6: true
browser: true
extends:
- 'plugin:import/recommended'
- 'plugin:import/typescript'
- 'plugin:@typescript-eslint/all'
- 'plugin:svelte/recommended'
plugins:
- import
- '@typescript-eslint/eslint-plugin'
settings:
import/resolver:
typescript: {}
rules:
'@typescript-eslint/semi':
- error
- never
'@typescript-eslint/indent':
- error
- 4
'@typescript-eslint/explicit-member-accessibility':
- error
- accessibility: no-public
overrides:
parameterProperties: explicit
'@typescript-eslint/no-require-imports': 'off'
'@typescript-eslint/no-parameter-properties': 'off'
'@typescript-eslint/explicit-function-return-type': 'off'
'@typescript-eslint/no-explicit-any': 'off'
'@typescript-eslint/no-magic-numbers': 'off'
'@typescript-eslint/member-delimiter-style': 'off'
'@typescript-eslint/promise-function-async': 'off'
'@typescript-eslint/require-array-sort-compare': 'off'
'@typescript-eslint/no-floating-promises': 'off'
'@typescript-eslint/prefer-readonly': 'off'
'@typescript-eslint/require-await': 'off'
'@typescript-eslint/strict-boolean-expressions': 'off'
'@typescript-eslint/no-misused-promises':
- error
- checksVoidReturn: false
'@typescript-eslint/typedef': 'off'
'@typescript-eslint/consistent-type-imports': 'off'
'@typescript-eslint/sort-type-union-intersection-members': 'off'
'@typescript-eslint/no-use-before-define':
- error
- classes: false
functions: false
no-duplicate-imports: error
array-bracket-spacing:
- error
- never
block-scoped-var: error
brace-style: 'off'
'@typescript-eslint/brace-style':
- error
- 1tbs
- allowSingleLine: true
computed-property-spacing:
- error
- never
curly: error
eol-last: error
eqeqeq:
- error
- smart
max-depth:
- 1
- 5
max-statements:
- 1
- 80
no-multiple-empty-lines: error
no-mixed-spaces-and-tabs: error
no-trailing-spaces: error
'@typescript-eslint/no-unused-vars':
- error
- vars: all
args: after-used
argsIgnorePattern: ^_
no-undef: error
no-var: error
object-curly-spacing: 'off'
'@typescript-eslint/object-curly-spacing':
- error
- always
quote-props:
- warn
- as-needed
- keywords: true
numbers: true
quotes: 'off'
'@typescript-eslint/quotes':
- error
- single
- allowTemplateLiterals: true
'@typescript-eslint/no-confusing-void-expression':
- error
- ignoreArrowShorthand: true
'@typescript-eslint/no-non-null-assertion': 'off'
'@typescript-eslint/no-unnecessary-condition':
- error
- allowConstantLoopConditions: true
'@typescript-eslint/restrict-template-expressions': 'off'
'@typescript-eslint/prefer-readonly-parameter-types': 'off'
'@typescript-eslint/no-unsafe-member-access': 'off'
'@typescript-eslint/no-unsafe-call': 'off'
'@typescript-eslint/no-unsafe-return': 'off'
'@typescript-eslint/no-unsafe-assignment': 'off'
'@typescript-eslint/naming-convention': 'off'
'@typescript-eslint/lines-between-class-members':
- error
- always
- exceptAfterSingleLine: true
'@typescript-eslint/dot-notation': 'off'
'@typescript-eslint/no-implicit-any-catch': 'off'
'@typescript-eslint/member-ordering': 'off'
'@typescript-eslint/no-var-requires': 'off'
'@typescript-eslint/no-unsafe-argument': 'off'
'@typescript-eslint/restrict-plus-operands': 'off'
'@typescript-eslint/space-infix-ops': 'off'
'@typescript-eslint/no-type-alias':
- error
- allowAliases: in-unions-and-intersections
allowLiterals: always
allowCallbacks: always
'@typescript-eslint/comma-dangle':
- error
- arrays: always-multiline
objects: always-multiline
imports: always-multiline
exports: always-multiline
functions: only-multiline
'@typescript-eslint/use-unknown-in-catch-callback-variable': off
overrides:
- files: '*.svelte'
parser: 'svelte-eslint-parser'
parserOptions:
svelteFeatures:
experimentalGenerics: true
parser:
ts: '@typescript-eslint/parser'
js: 'espree'
typescript: '@typescript-eslint/parser'
rules:
# To allow prop definitions
'@typescript-eslint/init-declarations': off
# False positives for {#if}
'@typescript-eslint/no-unnecessary-condition': off
# False positives for FontAwesome
import/no-named-as-default: off
import/no-named-as-default-member: off
ignorePatterns:
- svelte.config.js
- vite.config.ts
- src/*/lib/api-client/**

View file

@ -0,0 +1,178 @@
// eslint.config.cjs
import globals from "globals";
import eslintPluginSvelte from 'eslint-plugin-svelte';
import js from '@eslint/js';
import svelteParser from 'svelte-eslint-parser';
import tsEslint from 'typescript-eslint';
import tsParser from '@typescript-eslint/parser';
import stylistic from '@stylistic/eslint-plugin'
export default [
js.configs.recommended,
...tsEslint.configs.strict,
...eslintPluginSvelte.configs['flat/recommended'],
{
ignores: ["**/svelte.config.js", "**/vite.config.ts", "src/*/lib/api-client/**/*"],
},
{
plugins: {
'@stylistic': stylistic,
},
languageOptions: {
parser: svelteParser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
parser: tsParser,
extraFileExtensions: [".svelte"],
},
globals: {
...globals.browser,
},
},
rules: {
"@stylistic/semi": ["error", "never"],
"@stylistic/indent": ["error", 4],
"@typescript-eslint/explicit-member-accessibility": ["error", {
accessibility: "no-public",
overrides: {
parameterProperties: "explicit",
},
}],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-magic-numbers": "off",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/promise-function-async": "off",
"@typescript-eslint/require-array-sort-compare": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/prefer-readonly": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/explicit-module-boundary-types": "error",
// "@typescript-eslint/no-misused-promises": ["error", {
// checksVoidReturn: false,
// }],
"@typescript-eslint/typedef": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/sort-type-union-intersection-members": "off",
"@typescript-eslint/no-use-before-define": ["error", {
classes: false,
functions: false,
}],
"no-duplicate-imports": "error",
"array-bracket-spacing": ["error", "never"],
"block-scoped-var": "error",
"brace-style": "off",
"@stylistic/brace-style": ["error", "1tbs", {
allowSingleLine: true,
}],
"computed-property-spacing": ["error", "never"],
curly: "error",
"eol-last": "error",
eqeqeq: ["error", "smart"],
"max-depth": [1, 5],
"max-statements": [1, 80],
"no-multiple-empty-lines": "error",
"no-mixed-spaces-and-tabs": "error",
"no-trailing-spaces": "error",
"@typescript-eslint/no-unused-vars": ["error", {
vars: "all",
args: "after-used",
argsIgnorePattern: "^_",
}],
"no-undef": "error",
"no-var": "error",
"object-curly-spacing": "off",
"@stylistic/object-curly-spacing": ["error", "always"],
"quote-props": ["warn", "as-needed", {
keywords: true,
numbers: true,
}],
quotes: "off",
"@stylistic/quotes": ["error", "single", {
allowTemplateLiterals: true,
}],
"@typescript-eslint/no-confusing-void-expression": ["error", {
ignoreArrowShorthand: true,
}],
"@typescript-eslint/no-non-null-assertion": "off",
// "@typescript-eslint/no-unnecessary-condition": ["error", {
// allowConstantLoopConditions: true,
// }],
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/naming-convention": "off",
"@stylistic/lines-between-class-members": ["error", "always", {
exceptAfterSingleLine: true,
}],
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/no-implicit-any-catch": "off",
"@typescript-eslint/member-ordering": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/space-infix-ops": "off",
"@typescript-eslint/no-type-alias": ["error", {
allowAliases: "in-unions-and-intersections",
allowLiterals: "always",
allowCallbacks: "always",
}],
"@stylistic/comma-dangle": ["error", {
arrays: "always-multiline",
objects: "always-multiline",
imports: "always-multiline",
exports: "always-multiline",
functions: "only-multiline",
}],
"@typescript-eslint/use-unknown-in-catch-callback-variable": "off",
},
},
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
parser: tsParser,
},
},
rules: {
'svelte/no-target-blank': 'error',
'svelte/no-at-debug-tags': 'error',
'svelte/no-reactive-functions': 'error',
'svelte/no-reactive-literals': 'error',
},
},
];

View file

@ -25,17 +25,17 @@
"@otplib/plugin-base32-enc-dec": "^12.0.1",
"@otplib/plugin-crypto-js": "^12.0.1",
"@otplib/preset-browser": "^12.0.1",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltestrap/sveltestrap": "^6.2.7",
"@tsconfig/svelte": "^5.0.0",
"@types/qrcode": "^1.5.0",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.18.0",
"@xterm/addon-serialize": "^0.13",
"@xterm/xterm": "^5.5",
"bootstrap": "^5.3.3",
"copy-text-to-clipboard": "^3.0.1",
"date-fns": "^4.1.0",
"eslint": "^8",
"eslint": "^9.13.0",
"eslint-config-standard": "^17.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0",
@ -43,9 +43,10 @@
"eslint-plugin-promise": "^7.1.0",
"eslint-plugin-svelte": "^2.46.0",
"format-duration": "^3.0.2",
"otpauth": "^9.3.4",
"qrcode": "^1.5.4",
"sass": "^1.80.4",
"svelte": "^4.2.19",
"sass": "~1.78",
"svelte": "^5.1.3",
"svelte-check": "^4.0.5",
"svelte-fa": "^4.0.3",
"svelte-intersection-observer": "^1.0.0",
@ -58,9 +59,10 @@
"ua-parser-js": "^1.0.39",
"vite": "^5.4.10",
"vite-plugin-checker": "^0.8.0",
"vite-tsconfig-paths": "^4.3.2",
"@xterm/xterm": "^5.5",
"@xterm/addon-serialize": "^0.13",
"otpauth": "^9.3.4"
"vite-tsconfig-paths": "^4.3.2"
},
"dependencies": {
"@stylistic/eslint-plugin": "^2.9.0",
"typescript-eslint": "^8.11.0"
}
}

View file

@ -17,46 +17,46 @@ init()
const routes = {
'/': wrap({
asyncComponent: () => import('./Home.svelte'),
asyncComponent: () => import('./Home.svelte') as any,
}),
'/sessions/:id': wrap({
asyncComponent: () => import('./Session.svelte'),
asyncComponent: () => import('./Session.svelte') as any,
}),
'/recordings/:id': wrap({
asyncComponent: () => import('./Recording.svelte'),
asyncComponent: () => import('./Recording.svelte') as any,
}),
'/tickets': wrap({
asyncComponent: () => import('./Tickets.svelte'),
asyncComponent: () => import('./Tickets.svelte') as any,
}),
'/tickets/create': wrap({
asyncComponent: () => import('./CreateTicket.svelte'),
asyncComponent: () => import('./CreateTicket.svelte') as any,
}),
'/config': wrap({
asyncComponent: () => import('./Config.svelte'),
asyncComponent: () => import('./Config.svelte') as any,
}),
'/targets/create': wrap({
asyncComponent: () => import('./CreateTarget.svelte'),
asyncComponent: () => import('./CreateTarget.svelte') as any,
}),
'/targets/:id': wrap({
asyncComponent: () => import('./Target.svelte'),
asyncComponent: () => import('./Target.svelte') as any,
}),
'/roles/create': wrap({
asyncComponent: () => import('./CreateRole.svelte'),
asyncComponent: () => import('./CreateRole.svelte') as any,
}),
'/roles/:id': wrap({
asyncComponent: () => import('./Role.svelte'),
asyncComponent: () => import('./Role.svelte') as any,
}),
'/users/create': wrap({
asyncComponent: () => import('./CreateUser.svelte'),
asyncComponent: () => import('./CreateUser.svelte') as any,
}),
'/users/:id': wrap({
asyncComponent: () => import('./User.svelte'),
asyncComponent: () => import('./User.svelte') as any,
}),
'/ssh': wrap({
asyncComponent: () => import('./SSH.svelte'),
asyncComponent: () => import('./SSH.svelte') as any,
}),
'/log': wrap({
asyncComponent: () => import('./Log.svelte'),
asyncComponent: () => import('./Log.svelte') as any,
}),
}
</script>

View file

@ -1,12 +1,20 @@
<script lang="ts">
import { Input } from '@sveltestrap/sveltestrap'
import { CredentialKind, type User, type UserRequireCredentialsPolicy } from './lib/api'
export let user: User
export let value: UserRequireCredentialsPolicy
export let possibleCredentials: Set<CredentialKind>
export let protocolId: 'http' | 'ssh' | 'mysql' | 'postgres'
interface Props {
user: User;
value: UserRequireCredentialsPolicy;
possibleCredentials: Set<CredentialKind>;
protocolId: 'http' | 'ssh' | 'mysql' | 'postgres';
}
let {
user,
value = $bindable(),
possibleCredentials,
protocolId,
}: Props = $props()
const labels = {
Password: 'Password',
@ -16,17 +24,17 @@ const labels = {
WebUserApproval: 'In-browser auth',
}
let isAny = false
let validCredentials = new Set<CredentialKind>()
let isAny = $state(false)
const validCredentials = $derived.by(() => {
let vc = new Set<CredentialKind>()
vc = new Set(user.credentials.map(x => x.kind as CredentialKind))
vc.add(CredentialKind.WebUserApproval)
return vc
})
$: {
validCredentials = new Set(user.credentials.map(x => x.kind as CredentialKind))
validCredentials.add(CredentialKind.WebUserApproval)
setTimeout(() => {
isAny = !value[protocolId]
})
}
$effect(() => {
isAny = !value[protocolId]
})
function updateAny () {
if (isAny) {

View file

@ -48,32 +48,33 @@
</div>
<ItemList load={getTargets} showSearch={true}>
<a
slot="item" let:item={target}
class="list-group-item list-group-item-action"
href="/targets/{target.id}"
use:link>
<strong class="me-auto">
{target.name}
</strong>
<small class="text-muted ms-auto">
{#if target.options.kind === 'Http'}
HTTP
{/if}
{#if target.options.kind === 'MySql'}
MySQL
{/if}
{#if target.options.kind === 'Postgres'}
PostgreSQL
{/if}
{#if target.options.kind === 'Ssh'}
SSH
{/if}
{#if target.options.kind === 'WebAdmin'}
This web admin interface
{/if}
</small>
</a>
{#snippet item({ item: target })}
<a
class="list-group-item list-group-item-action"
href="/targets/{target.id}"
use:link>
<strong class="me-auto">
{target.name}
</strong>
<small class="text-muted ms-auto">
{#if target.options.kind === 'Http'}
HTTP
{/if}
{#if target.options.kind === 'MySql'}
MySQL
{/if}
{#if target.options.kind === 'Postgres'}
PostgreSQL
{/if}
{#if target.options.kind === 'Ssh'}
SSH
{/if}
{#if target.options.kind === 'WebAdmin'}
This web admin interface
{/if}
</small>
</a>
{/snippet}
</ItemList>
</div>
@ -89,15 +90,16 @@
</div>
<ItemList load={getUsers} showSearch={true}>
<a
slot="item" let:item={user}
class="list-group-item list-group-item-action"
href="/users/{user.id}"
use:link>
<strong class="me-auto">
{user.username}
</strong>
</a>
{#snippet item({ item: user })}
<a
class="list-group-item list-group-item-action"
href="/users/{user.id}"
use:link>
<strong class="me-auto">
{user.username}
</strong>
</a>
{/snippet}
</ItemList>
<div class="page-summary-bar mt-4">
@ -111,15 +113,16 @@
</div>
<ItemList load={getRoles} showSearch={true}>
<a
slot="item" let:item={role}
class="list-group-item list-group-item-action"
href="/roles/{role.id}"
use:link>
<strong class="me-auto">
{role.name}
</strong>
</a>
{#snippet item({ item: role })}
<a
class="list-group-item list-group-item-action"
href="/roles/{role.id}"
use:link>
<strong class="me-auto">
{role.name}
</strong>
</a>
{/snippet}
</ItemList>
</div>
</div>

View file

@ -2,11 +2,12 @@
import { api } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import { replace } from 'svelte-spa-router'
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
let error: string|null = null
let name = ''
let error: string|null = $state(null)
let name = $state('')
async function create () {
if (!name) {
@ -36,7 +37,7 @@ async function create () {
</div>
<FormGroup floating label="Name">
<input class="form-control" bind:value={name} />
<input class="form-control" bind:value={name} required />
</FormGroup>
<AsyncButton

View file

@ -2,12 +2,13 @@
import { api, type TargetOptions, TlsMode } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import { replace } from 'svelte-spa-router'
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
let error: string|null = null
let name = ''
let type: 'Http' | 'MySql' | 'Ssh' | 'Postgres' = 'Ssh'
let error: string|null = $state(null)
let name = $state('')
let type: 'Http' | 'MySql' | 'Ssh' | 'Postgres' = $state('Ssh')
async function create () {
if (!name || !type) {

View file

@ -4,18 +4,19 @@ import AsyncButton from 'common/AsyncButton.svelte'
import ConnectionInstructions from 'common/ConnectionInstructions.svelte'
import { TargetKind } from 'gateway/lib/api'
import { link } from 'svelte-spa-router'
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import { firstBy } from 'thenby'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
let error: string|null = null
let targets: Target[]|undefined
let users: User[]|undefined
let selectedTarget: Target|undefined
let selectedUser: User|undefined
let selectedExpiry: string|undefined
let selectedNumberOfUses: number|undefined
let result: TicketAndSecret|undefined
let error: string|null = $state(null)
let targets: Target[]|undefined = $state()
let users: User[]|undefined = $state()
let selectedTarget: Target|undefined = $state()
let selectedUser: User|undefined = $state()
let selectedExpiry: string|undefined = $state()
let selectedNumberOfUses: number|undefined = $state()
let result: TicketAndSecret|undefined = $state()
async function load () {
[targets, users] = await Promise.all([

View file

@ -2,11 +2,12 @@
import { api } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import { replace } from 'svelte-spa-router'
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
let error: string|null = null
let username = ''
let error: string|null = $state(null)
let username = $state('')
async function create () {
if (!username) {

View file

@ -15,7 +15,7 @@
let [showActiveOnly, showActiveOnly$] = autosave('sessions-list:show-active-only', false)
let [showLoggedInOnly, showLoggedInOnly$] = autosave('sessions-list:show-logged-in-only', true)
let activeSessionCount: number|undefined
let activeSessionCount: number|undefined = $state()
let socket = new WebSocket(`wss://${location.host}/@warpgate/admin/api/sessions/changes`)
let sessionChanges$ = fromEvent(socket, 'message')
@ -80,39 +80,43 @@
{/if}
<ItemList load={loadSessions} pageSize={100}>
<div slot="header" class="d-flex align-items-center mb-1 w-100">
<div class="ms-auto"></div>
<Input class="ms-3" type="switch" label="Active only" bind:checked={$showActiveOnly} />
<Input class="ms-3" type="switch" label="Logged in only" bind:checked={$showLoggedInOnly} />
</div>
<a
slot="item" let:item={session}
class="list-group-item list-group-item-action"
href="/sessions/{session.id}"
use:link>
<div class="main">
<div class="icon" class:text-success={!session.ended}>
{#if !session.ended}
<Fa icon={iconActive} fw />
{/if}
</div>
<div class="protocol text-muted me-2">{session.protocol}</div>
<strong>
{describeSession(session)}
</strong>
<div class="meta">
{#if session.ended }
{formatDistance(new Date(session.started), new Date(session.ended))}
{/if}
</div>
<div class="meta ms-auto">
<RelativeDate date={session.started} />
</div>
{#snippet header()}
<div class="d-flex align-items-center mb-1 w-100">
<div class="ms-auto"></div>
<Input class="ms-3" type="switch" label="Active only" bind:checked={$showActiveOnly} />
<Input class="ms-3" type="switch" label="Logged in only" bind:checked={$showLoggedInOnly} />
</div>
</a>
{/snippet}
{#snippet item({ item: session })}
<a
class="list-group-item list-group-item-action"
href="/sessions/{session.id}"
use:link>
<div class="main">
<div class="icon" class:text-success={!session.ended}>
{#if !session.ended}
<Fa icon={iconActive} fw />
{/if}
</div>
<div class="protocol text-muted me-2">{session.protocol}</div>
<strong>
{describeSession(session)}
</strong>
<div class="meta">
{#if session.ended }
{formatDistance(new Date(session.started), new Date(session.ended))}
{/if}
</div>
<div class="meta ms-auto">
<RelativeDate date={session.started} />
</div>
</div>
</a>
{/snippet}
</ItemList>
<style lang="scss">

View file

@ -1,26 +1,30 @@
<script lang="ts">
import { api, type LogEntry } from 'admin/lib/api'
import { Alert } from '@sveltestrap/sveltestrap'
import { firstBy } from 'thenby'
import IntersectionObserver from 'svelte-intersection-observer'
import { link } from 'svelte-spa-router'
import { onDestroy, onMount } from 'svelte'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
export let filters: {
sessionId?: string,
} | undefined
interface Props {
filters: {
sessionId?: string,
} | undefined;
}
let error: string|null = null
let { filters }: Props = $props()
let error: string|null = $state(null)
let items: LogEntry[]|undefined
let visibleItems: LogEntry[]|undefined
let loading = true
let endReached = false
let loadOlderButton: HTMLButtonElement|undefined
let visibleItems: LogEntry[]|undefined = $state()
let loading = $state(true)
let endReached = $state(false)
let loadOlderButton: HTMLButtonElement|undefined = $state()
let reloadInterval: any
let lastUpdate = new Date()
let isLive = true
let searchQuery = ''
let lastUpdate = $state(new Date())
let isLive = $state(true)
let searchQuery = $state('')
const PAGE_SIZE = 1000
function addItems (newItems: LogEntry[]) {
@ -121,56 +125,57 @@ onDestroy(() => {
type="text"
class="form-control form-control-sm mb-2"
bind:value={searchQuery}
on:keyup={() => search()} />
onkeyup={() => search()} />
{#if visibleItems}
<div class="table-wrapper">
<table class="w-100">
<tr>
<th>Time</th>
{#if !filters?.sessionId}
<th>User</th>
<th>Session</th>
{/if}
<th class="d-flex">
<div class="me-auto">Message</div>
{#if isLive}
<span class="badge bg-danger">Live</span>
{:else}
<small><em>Last update: {stringifyDate(lastUpdate)}</em></small>
{/if}
</th>
</tr>
{#each visibleItems as item}
<tbody>
<tr>
<td class="timestamp pe-4">
{stringifyDate(item.timestamp)}
</td>
<th>Time</th>
{#if !filters?.sessionId}
<td class="username pe-4">
{#if item.username}
{item.username}
{/if}
</td>
<td class="session pe-4">
{#if item.sessionId}
<a href="/sessions/{item.sessionId}" use:link>
{item.sessionId}
</a>
{/if}
</td>
<th>User</th>
<th>Session</th>
{/if}
<td class="content">
<span class="text">
{item.text}
</span>
{#each Object.entries(item.values ?? {}) as pair}
<span class="key">{pair[0]}:</span>
<span class="value">{pair[1]}</span>
{/each}
</td>
<th class="d-flex">
<div class="me-auto">Message</div>
{#if isLive}
<span class="badge bg-danger">Live</span>
{:else}
<small><em>Last update: {stringifyDate(lastUpdate)}</em></small>
{/if}
</th>
</tr>
{#each visibleItems as item}
<tr>
<td class="timestamp pe-4">
{stringifyDate(item.timestamp)}
</td>
{#if !filters?.sessionId}
<td class="username pe-4">
{#if item.username}
{item.username}
{/if}
</td>
<td class="session pe-4">
{#if item.sessionId}
<a href="/sessions/{item.sessionId}" use:link>
{item.sessionId}
</a>
{/if}
</td>
{/if}
<td class="content">
<span class="text">
{item.text}
</span>
{#each Object.entries(item.values ?? {}) as pair}
<span class="key">{pair[0]}:</span>
<span class="value">{pair[1]}</span>
{/each}
</td>
</tr>
{/each}
{#if !endReached}
{#if !loading}
@ -184,7 +189,7 @@ onDestroy(() => {
<button
bind:this={loadOlderButton}
class="btn btn-light"
on:click={() => loadOlder()}
onclick={() => loadOlder()}
disabled={loading}
>
Load older
@ -203,6 +208,7 @@ onDestroy(() => {
<td class="text">End of the log</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}

View file

@ -1,14 +1,18 @@
<script lang="ts">
import { api, type Recording } from 'admin/lib/api'
import { Alert } from '@sveltestrap/sveltestrap'
import TerminalRecordingPlayer from 'admin/player/TerminalRecordingPlayer.svelte'
import Alert from 'common/Alert.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import { stringifyError } from 'common/errors'
export let params = { id: '' }
interface Props {
params: { id: string }
}
let error: string|null = null
let recording: Recording|null = null
let { params = { id: '' } }: Props = $props()
let error: string|null = $state(null)
let recording: Recording|null = $state(null)
async function load () {
recording = await api.getRecording(params)

View file

@ -1,6 +1,10 @@
<script lang="ts">
import { timeAgo } from 'admin/lib/time'
export let date: any
interface Props {
date: Date;
}
let { date }: Props = $props()
</script>
<span title={date}>{timeAgo(date)}</span>
<span title={date.toLocaleString()}>{timeAgo(date)}</span>

View file

@ -3,13 +3,18 @@ import { api, type Role } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import { replace } from 'svelte-spa-router'
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
export let params: { id: string }
interface Props {
params: { id: string };
}
let error: string|null = null
let role: Role
let { params }: Props = $props()
let error: string|null = $state(null)
let role: Role | undefined = $state()
async function load () {
try {
@ -23,7 +28,7 @@ async function update () {
try {
role = await api.updateRole({
id: params.id,
roleDataRequest: role,
roleDataRequest: role!,
})
} catch (err) {
error = await stringifyError(err)
@ -31,8 +36,8 @@ async function update () {
}
async function remove () {
if (confirm(`Delete role ${role.name}?`)) {
await api.deleteRole(role)
if (confirm(`Delete role ${role!.name}?`)) {
await api.deleteRole(role!)
replace('/config')
}
}
@ -43,13 +48,13 @@ async function remove () {
{:then}
<div class="page-summary-bar">
<div>
<h1>{role.name}</h1>
<h1>{role!.name}</h1>
<div class="text-muted">Role</div>
</div>
</div>
<FormGroup floating label="Name">
<input class="form-control" bind:value={role.name} />
<input class="form-control" bind:value={role!.name} />
</FormGroup>
{/await}

View file

@ -1,12 +1,12 @@
<script lang="ts">
import { api, type SSHKey, type SSHKnownHost } from 'admin/lib/api'
import Alert from 'common/Alert.svelte'
import CopyButton from 'common/CopyButton.svelte'
import { Alert } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
let error: string|undefined
let knownHosts: SSHKnownHost[]|undefined
let ownKeys: SSHKey[]|undefined
let error: string|undefined = $state()
let knownHosts: SSHKnownHost[]|undefined = $state()
let ownKeys: SSHKey[]|undefined = $state()
async function load () {
ownKeys = await api.getSshOwnKeys()
@ -62,7 +62,10 @@ async function deleteHost (host: SSHKnownHost) {
{host.host}:{host.port}
</strong>
<a class="ms-auto" href={''} on:click|preventDefault={() => deleteHost(host)}>Delete</a>
<a class="ms-auto" href={''} onclick={e => {
e.preventDefault()
deleteHost(host)
}}>Delete</a>
</div>
<pre>{host.keyType} {host.keyBase64}</pre>
</div>

View file

@ -6,16 +6,20 @@ import DelayedSpinner from 'common/DelayedSpinner.svelte'
import { formatDistance, formatDistanceToNow } from 'date-fns'
import { onDestroy } from 'svelte'
import { link } from 'svelte-spa-router'
import { Alert } from '@sveltestrap/sveltestrap'
import LogViewer from './LogViewer.svelte'
import RelativeDate from './RelativeDate.svelte'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
export let params = { id: '' }
interface Props {
params: { id: string }
}
let error: string|null = null
let session: SessionSnapshot|null = null
let recordings: Recording[]|null = null
let { params = { id: '' } }: Props = $props()
let error: string|null = $state(null)
let session: SessionSnapshot|null = $state(null)
let recordings: Recording[]|null = $state(null)
async function load () {
session = await api.getSession(params)

View file

@ -8,17 +8,22 @@ import { TargetKind } from 'gateway/lib/api'
import { serverInfo } from 'gateway/lib/store'
import Fa from 'svelte-fa'
import { replace } from 'svelte-spa-router'
import { Alert, FormGroup, Input } from '@sveltestrap/sveltestrap'
import { FormGroup, Input } from '@sveltestrap/sveltestrap'
import TlsConfiguration from './TlsConfiguration.svelte'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
export let params: { id: string }
interface Props {
params: { id: string };
}
let error: string|undefined
let selectedUser: User|undefined
let target: Target
let allRoles: Role[] = []
let roleIsAllowed: Record<string, any> = {}
let { params }: Props = $props()
let error: string|undefined = $state()
let selectedUser: User|undefined = $state()
let target: Target | undefined = $state()
let allRoles: Role[] = $state([])
let roleIsAllowed: Record<string, any> = $state({})
async function load () {
try {
@ -30,19 +35,18 @@ async function load () {
async function loadRoles () {
allRoles = await api.getRoles()
const allowedRoles = await api.getTargetRoles(target)
const allowedRoles = await api.getTargetRoles(target!)
roleIsAllowed = Object.fromEntries(allowedRoles.map(r => [r.id, true]))
}
async function update () {
try {
if (target.options.kind === 'Http') {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
target.options.externalHost = target.options.externalHost || undefined
if (target!.options.kind === 'Http') {
target!.options.externalHost = target!.options.externalHost || undefined
}
target = await api.updateTarget({
id: params.id,
targetDataRequest: target,
targetDataRequest: target!,
})
} catch (err) {
error = await stringifyError(err)
@ -50,8 +54,8 @@ async function update () {
}
async function remove () {
if (confirm(`Delete target ${target.name}?`)) {
await api.deleteTarget(target)
if (confirm(`Delete target ${target!.name}?`)) {
await api.deleteTarget(target!)
replace('/config')
}
}
@ -59,13 +63,13 @@ async function remove () {
async function toggleRole (role: Role) {
if (roleIsAllowed[role.id]) {
await api.deleteTargetRole({
id: target.id,
id: target!.id,
roleId: role.id,
})
roleIsAllowed = { ...roleIsAllowed, [role.id]: false }
} else {
await api.addTargetRole({
id: target.id,
id: target!.id,
roleId: role.id,
})
roleIsAllowed = { ...roleIsAllowed, [role.id]: true }
@ -76,6 +80,7 @@ async function toggleRole (role: Role) {
{#await load()}
<DelayedSpinner />
{:then}
{#if target}
<div class="page-summary-bar">
<div>
<h1>{target.name}</h1>
@ -254,6 +259,7 @@ async function toggleRole (role: Role) {
{/each}
</div>
{/await}
{/if}
{/await}
{#if error}

View file

@ -1,14 +1,14 @@
<script lang="ts">
import { api, type Ticket } from 'admin/lib/api'
import { link } from 'svelte-spa-router'
import { Alert } from '@sveltestrap/sveltestrap'
import RelativeDate from './RelativeDate.svelte'
import Fa from 'svelte-fa'
import { faCalendarXmark, faCalendarCheck, faSquareXmark, faSquareCheck } from '@fortawesome/free-solid-svg-icons'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
let error: string|undefined
let tickets: Ticket[]|undefined
let error: string|undefined = $state()
let tickets: Ticket[]|undefined = $state()
async function load () {
tickets = await api.getTickets()
@ -71,7 +71,10 @@ async function deleteTicket (ticket: Ticket) {
<small class="text-muted me-4 ms-auto">
<RelativeDate date={ticket.created} />
</small>
<a href={''} on:click|preventDefault={() => deleteTicket(ticket)}>Delete</a>
<a href={''} onclick={e => {
deleteTicket(ticket)
e.preventDefault()
}}>Delete</a>
</div>
{/each}
</div>

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { type Tls, TlsMode } from 'admin/lib/api'
import { FormGroup, Input } from '@sveltestrap/sveltestrap'
import { type Tls, TlsMode } from 'admin/lib/api'
import { FormGroup, Input } from '@sveltestrap/sveltestrap'
export let value: Tls
interface Props {
value: Tls;
}
let { value = $bindable() }: Props = $props()
</script>

View file

@ -1,247 +1,257 @@
<script lang="ts">
import { faIdBadge, faKey, faKeyboard, faMobileScreen } from '@fortawesome/free-solid-svg-icons'
import { api, CredentialKind, type Role, type User, type UserAuthCredential, type UserRequireCredentialsPolicy } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import Fa from 'svelte-fa'
import { replace } from 'svelte-spa-router'
import { Alert, Button, FormGroup, Input } from '@sveltestrap/sveltestrap'
import AuthPolicyEditor from './AuthPolicyEditor.svelte'
import UserCredentialModal from './UserCredentialModal.svelte'
import { stringifyError } from 'common/errors'
import { faIdBadge, faKey, faKeyboard, faMobileScreen } from '@fortawesome/free-solid-svg-icons'
import { api, CredentialKind, type Role, type User, type UserAuthCredential, type UserRequireCredentialsPolicy } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import Fa from 'svelte-fa'
import { replace } from 'svelte-spa-router'
import { Button, FormGroup, Input } from '@sveltestrap/sveltestrap'
import AuthPolicyEditor from './AuthPolicyEditor.svelte'
import UserCredentialModal from './UserCredentialModal.svelte'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
export let params: { id: string }
let error: string|null = null
let user: User
let editingCredential: UserAuthCredential|undefined
let policy: UserRequireCredentialsPolicy
let allRoles: Role[] = []
let roleIsAllowed: Record<string, any> = {}
const policyProtocols: { id: 'ssh' | 'http' | 'mysql' | 'postgres', name: string }[] = [
{ id: 'ssh', name: 'SSH' },
{ id: 'http', name: 'HTTP' },
{ id: 'mysql', name: 'MySQL' },
{ id: 'postgres', name: 'PostgreSQL' },
]
const possibleCredentials: Record<string, Set<CredentialKind>> = {
ssh: new Set([CredentialKind.Password, CredentialKind.PublicKey, CredentialKind.Totp, CredentialKind.WebUserApproval]),
http: new Set([CredentialKind.Password, CredentialKind.Totp, CredentialKind.Sso]),
mysql: new Set([CredentialKind.Password]),
postgres: new Set([CredentialKind.Password]),
}
async function load () {
try {
user = await api.getUser({ id: params.id })
policy = user.credentialPolicy ?? {}
user.credentialPolicy = policy
allRoles = await api.getRoles()
const allowedRoles = await api.getUserRoles(user)
roleIsAllowed = Object.fromEntries(allowedRoles.map(r => [r.id, true]))
} catch (err) {
error = await stringifyError(err)
interface Props {
params: { id: string };
}
}
function deleteCredential (credential: UserAuthCredential) {
user.credentials = user.credentials.filter(c => c !== credential)
}
let { params }: Props = $props()
function abbreviatePublicKey (key: string) {
return key.slice(0, 16) + '...' + key.slice(-8)
}
let error: string|null = $state(null)
let user: User | undefined = $state()
let editingCredential: UserAuthCredential|undefined = $state()
let policy: UserRequireCredentialsPolicy | undefined = $state()
let allRoles: Role[] = $state([])
let roleIsAllowed: Record<string, any> = $state({})
async function update () {
try {
user = await api.updateUser({
id: params.id,
userDataRequest: user,
})
} catch (err) {
error = await stringifyError(err)
const policyProtocols: { id: 'ssh' | 'http' | 'mysql' | 'postgres', name: string }[] = [
{ id: 'ssh', name: 'SSH' },
{ id: 'http', name: 'HTTP' },
{ id: 'mysql', name: 'MySQL' },
{ id: 'postgres', name: 'PostgreSQL' },
]
const possibleCredentials: Record<string, Set<CredentialKind>> = {
ssh: new Set([CredentialKind.Password, CredentialKind.PublicKey, CredentialKind.Totp, CredentialKind.WebUserApproval]),
http: new Set([CredentialKind.Password, CredentialKind.Totp, CredentialKind.Sso]),
mysql: new Set([CredentialKind.Password]),
postgres: new Set([CredentialKind.Password]),
}
}
async function remove () {
if (confirm(`Delete user ${user.username}?`)) {
await api.deleteUser(user)
replace('/config')
}
}
async function load () {
try {
user = await api.getUser({ id: params.id })
policy = user.credentialPolicy ?? {}
user.credentialPolicy = policy
async function toggleRole (role: Role) {
if (roleIsAllowed[role.id]) {
await api.deleteUserRole({
id: user.id,
roleId: role.id,
})
roleIsAllowed = { ...roleIsAllowed, [role.id]: false }
} else {
await api.addUserRole({
id: user.id,
roleId: role.id,
})
roleIsAllowed = { ...roleIsAllowed, [role.id]: true }
allRoles = await api.getRoles()
const allowedRoles = await api.getUserRoles(user)
roleIsAllowed = Object.fromEntries(allowedRoles.map(r => [r.id, true]))
} catch (err) {
error = await stringifyError(err)
}
}
}
function saveCredential () {
if (!editingCredential) {
return
function deleteCredential (credential: UserAuthCredential) {
user!.credentials = user!.credentials.filter(c => c !== credential)
}
if (user.credentials.includes(editingCredential)) {
user.credentials = [...user.credentials]
} else {
user.credentials.push(editingCredential)
for (const protocol of ['http', 'ssh'] as ('http'|'ssh')[]) {
for (const ck of [CredentialKind.Password, CredentialKind.PublicKey]) {
if (
editingCredential.kind === CredentialKind.Totp
function abbreviatePublicKey (key: string) {
return key.slice(0, 16) + '...' + key.slice(-8)
}
async function update () {
try {
user = await api.updateUser({
id: params.id,
userDataRequest: user!,
})
} catch (err) {
error = await stringifyError(err)
}
}
async function remove () {
if (confirm(`Delete user ${user!.username}?`)) {
await api.deleteUser(user!)
replace('/config')
}
}
async function toggleRole (role: Role) {
if (roleIsAllowed[role.id]) {
await api.deleteUserRole({
id: user!.id,
roleId: role.id,
})
roleIsAllowed = { ...roleIsAllowed, [role.id]: false }
} else {
await api.addUserRole({
id: user!.id,
roleId: role.id,
})
roleIsAllowed = { ...roleIsAllowed, [role.id]: true }
}
}
function saveCredential () {
if (!editingCredential || !user) {
return
}
if (user.credentials.includes(editingCredential)) {
user.credentials = [...user.credentials]
} else {
user.credentials.push(editingCredential)
for (const protocol of ['http', 'ssh'] as ('http'|'ssh')[]) {
for (const ck of [CredentialKind.Password, CredentialKind.PublicKey]) {
if (
editingCredential.kind === CredentialKind.Totp
&& !user.credentialPolicy?.[protocol]
&& user.credentials.some(x => x.kind === ck)
&& possibleCredentials[protocol]?.has(ck)
) {
user.credentialPolicy = {
...user.credentialPolicy ?? {},
[protocol]: [ck, CredentialKind.Totp],
) {
user.credentialPolicy = {
...user.credentialPolicy ?? {},
[protocol]: [ck, CredentialKind.Totp],
}
policy = user.credentialPolicy
}
policy = user.credentialPolicy
}
}
}
editingCredential = undefined
}
editingCredential = undefined
}
function assertDefined<T>(value: T|undefined): T {
if (value === undefined) {
throw new Error('Value is undefined')
function assertDefined<T>(value: T|undefined): T {
if (value === undefined) {
throw new Error('Value is undefined')
}
return value
}
return value
}
</script>
{#await load()}
<DelayedSpinner />
<DelayedSpinner />
{:then}
<div class="page-summary-bar">
<div>
<h1>{user.username}</h1>
<div class="text-muted">User</div>
</div>
{#if user}
<div class="page-summary-bar">
<div>
<h1>{user.username}</h1>
<div class="text-muted">User</div>
</div>
</div>
<FormGroup floating label="Username">
<Input bind:value={user.username} />
</FormGroup>
<FormGroup floating label="Username">
<Input bind:value={user.username} />
</FormGroup>
<div class="d-flex align-items-center mt-4 mb-2">
<h4 class="m-0">Credentials</h4>
<span class="ms-auto"></span>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'Password',
hash: '',
}}>Add password</Button>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'PublicKey',
key: '',
}}>Add public key</Button>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'Totp',
key: [],
}}>Add OTP</Button>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'Sso',
email: '',
}}>Add SSO</Button>
</div>
<div class="list-group list-group-flush mb-3">
{#each user.credentials as credential}
<div class="list-group-item credential">
{#if credential.kind === 'Password'}
<Fa fw icon={faKeyboard} />
<span class="type">Password</span>
{/if}
{#if credential.kind === 'PublicKey'}
<Fa fw icon={faKey} />
<span class="type">Public key</span>
<span class="text-muted ms-2">{abbreviatePublicKey(credential.key)}</span>
{/if}
{#if credential.kind === 'Totp'}
<Fa fw icon={faMobileScreen} />
<span class="type">One-time password</span>
{/if}
{#if credential.kind === 'Sso'}
<Fa fw icon={faIdBadge} />
<span class="type">Single sign-on</span>
<span class="text-muted ms-2">
{credential.email}
{#if credential.provider} ({credential.provider}){/if}
</span>
{/if}
<div class="d-flex align-items-center mt-4 mb-2">
<h4 class="m-0">Credentials</h4>
<span class="ms-auto"></span>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'Password',
hash: '',
}}>Add password</Button>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'PublicKey',
key: '',
}}>Add public key</Button>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'Totp',
key: [],
}}>Add OTP</Button>
<Button size="sm" color="link" on:click={() => editingCredential = {
kind: 'Sso',
email: '',
}}>Add SSO</Button>
</div>
<div class="list-group list-group-flush mb-3">
{#each user.credentials as credential}
<div class="list-group-item credential">
{#if credential.kind === 'Password'}
<Fa fw icon={faKeyboard} />
<span class="type">Password</span>
{/if}
{#if credential.kind === 'PublicKey'}
<Fa fw icon={faKey} />
<span class="type">Public key</span>
<span class="text-muted ms-2">{abbreviatePublicKey(credential.key)}</span>
{/if}
{#if credential.kind === 'Totp'}
<Fa fw icon={faMobileScreen} />
<span class="type">One-time password</span>
{/if}
{#if credential.kind === 'Sso'}
<Fa fw icon={faIdBadge} />
<span class="type">Single sign-on</span>
<span class="text-muted ms-2">
{credential.email}
{#if credential.provider} ({credential.provider}){/if}
</span>
{/if}
<span class="ms-auto"></span>
<a
class="ms-2"
href={''}
on:click|preventDefault={() =>
editingCredential = credential
}>
Change
</a>
<a
class="ms-2"
href={''}
on:click|preventDefault={() => deleteCredential(credential)}>
Delete
</a>
</div>
{/each}
</div>
<h4>Auth policy</h4>
<div class="list-group list-group-flush mb-3">
{#each policyProtocols as protocol}
<div class="list-group-item">
<div>
<strong>{protocol.name}</strong>
</div>
{#if possibleCredentials[protocol.id]}
{@const _possibleCredentials = assertDefined(possibleCredentials[protocol.id])}
<AuthPolicyEditor
user={user}
bind:value={policy}
possibleCredentials={_possibleCredentials}
protocolId={protocol.id}
/>
{/if}
</div>
{/each}
</div>
<h4 class="mt-4">User roles</h4>
<div class="list-group list-group-flush mb-3">
{#each allRoles as role}
<label
for="role-{role.id}"
class="list-group-item list-group-item-action d-flex align-items-center"
>
<Input
id="role-{role.id}"
class="mb-0 me-2"
type="switch"
on:change={() => toggleRole(role)}
checked={roleIsAllowed[role.id]} />
<div>{role.name}</div>
</label>
{/each}
<a
class="ms-2"
href={''}
onclick={e => {
editingCredential = credential
e.preventDefault()
}}>
Change
</a>
<a
class="ms-2"
href={''}
onclick={e => {
deleteCredential(credential)
e.preventDefault()
}}>
Delete
</a>
</div>
{/each}
</div>
<h4>Auth policy</h4>
<div class="list-group list-group-flush mb-3">
{#each policyProtocols as protocol}
<div class="list-group-item">
<div>
<strong>{protocol.name}</strong>
</div>
{#if possibleCredentials[protocol.id]}
{@const _possibleCredentials = assertDefined(possibleCredentials[protocol.id])}
<AuthPolicyEditor
user={user}
bind:value={policy!}
possibleCredentials={_possibleCredentials}
protocolId={protocol.id}
/>
{/if}
</div>
{/each}
</div>
<h4 class="mt-4">User roles</h4>
<div class="list-group list-group-flush mb-3">
{#each allRoles as role}
<label
for="role-{role.id}"
class="list-group-item list-group-item-action d-flex align-items-center"
>
<Input
id="role-{role.id}"
class="mb-0 me-2"
type="switch"
on:change={() => toggleRole(role)}
checked={roleIsAllowed[role.id]} />
<div>{role.name}</div>
</label>
{/each}
</div>
{/if}
{/await}
{#if error}
@ -266,7 +276,7 @@ function assertDefined<T>(value: T|undefined): T {
{#if editingCredential}
<UserCredentialModal
credential={editingCredential}
username={user.username}
username={user!.username}
save={saveCredential}
cancel={() => editingCredential = undefined}
/>

View file

@ -1,157 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte'
import {
Alert,
Button,
FormGroup,
Input,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from '@sveltestrap/sveltestrap'
import QRCode from 'qrcode'
import * as OTPAuth from 'otpauth'
import { faClipboard, faRefresh } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import base32Encode from 'base32-encode'
import {
Alert,
Button,
FormGroup,
Input,
Modal,
ModalBody,
ModalFooter,
} from '@sveltestrap/sveltestrap'
import QRCode from 'qrcode'
import * as OTPAuth from 'otpauth'
import { faClipboard, faRefresh } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import base32Encode from 'base32-encode'
import { api } from 'gateway/lib/api'
import type { UserAuthCredential, UserTotpCredential } from './lib/api'
import { api } from 'gateway/lib/api'
import type { UserAuthCredential, UserTotpCredential } from './lib/api'
import ModalHeader from 'common/ModalHeader.svelte'
import { onMount } from 'svelte'
export let credential: UserAuthCredential
export let username: string
export let save: () => void
export let cancel: () => void
let visible = true
let newPassword = ''
let field: HTMLInputElement|undefined
let qrImage: HTMLImageElement|undefined
let totpUri: string|undefined
let totpValidationValue: string|undefined
let validationFeedback: string|undefined
let totpValid = false
let passwordValid = false
export const totp = new OTPAuth.TOTP({
issuer: 'Warpgate',
digits: 6,
period: 30,
algorithm: 'SHA1',
})
function _save () {
if (credential.kind === 'Password') {
if (!newPassword) {
return
}
credential.hash = newPassword
}
if (credential.kind === 'PublicKey') {
if (credential.key.includes(' ')) {
const parts = credential.key.split(' ').filter(x => x)
credential.key = `${parts[0]} ${parts[1]}`
}
}
visible = false
save()
}
function _validate () : boolean {
console.debug(`Validating credentials of kind "${credential.kind}"`)
if (credential.kind === 'Totp' && totpValidationValue) {
totp.secret ??= OTPAuth.Secret.fromBase32(encodeTotpSecret(credential))
totpValid = totp.validate({ token: totpValidationValue, window: 1 }) !== null
if (!totpValid) {
validationFeedback = 'The TOTP code is not valid'
} else {
validationFeedback = undefined
}
return totpValid
} else if (credential.kind === 'Password') {
passwordValid = newPassword.trim().length > 1
if (!passwordValid) {
validationFeedback = 'Password cannot be empty or whitespace'
} else {
validationFeedback = undefined
}
return passwordValid
} else {
// TODO: Further validation
return true
}
}
function generateNewTotpKey () {
if (credential.kind === 'Totp') {
credential.key = Array.from({ length: 32 }, () => Math.floor(Math.random() * 255))
}
}
/**
* Copies the TOTP URI to the system clipboard if it is defined.
*
* @return {Promise<void>} A promise that resolves when the TOTP URI has been copied to the clipboard.
*/
async function copyTotpUri () : Promise<void> {
if (totpUri === undefined) {
return
interface Props {
credential: UserAuthCredential;
username: string;
save: () => void;
cancel: () => void;
}
const { clipboard } = navigator
return clipboard.writeText(totpUri)
}
let {
credential = $bindable(),
username,
save,
cancel,
}: Props = $props()
let visible = $state(true)
let newPassword = $state('')
let field: HTMLInputElement|undefined = $state()
let qrImage: HTMLImageElement|undefined = $state()
let totpUri: string|undefined = $state()
let totpValidationValue: string|undefined = $state()
let validationFeedback: string|undefined = $state()
let totpValid = $state(false)
let passwordValid = $state(false)
function _cancel () {
visible = false
cancel()
}
export const totp = $state(new OTPAuth.TOTP({
issuer: 'Warpgate',
digits: 6,
period: 30,
algorithm: 'SHA1',
}))
onMount(() => {
setTimeout(() => {
field?.focus()
})
})
function _save () {
/**
* Generates a TOTP (Time-based One-Time Password) secret key encoded in base32.
*
* @param {UserTotpCredential} cred - The credential containing a key for TOTP generation.
* @return {string} The base32 encoded TOTP secret key.
*/
function encodeTotpSecret (cred: UserTotpCredential) : string {
return base32Encode(new Uint8Array(cred.key), 'RFC4648')
}
$: {
if (credential.kind === 'Totp') {
if (!credential.key.length) {
generateNewTotpKey()
}
totp.label = username
totp.secret = OTPAuth.Secret.fromBase32(encodeTotpSecret(credential))
totpUri = totp.toString()
QRCode.toDataURL(totpUri, (err: Error | null | undefined, imageUrl: string) => {
if (err) {
if (credential.kind === 'Password') {
if (!newPassword) {
return
}
if (qrImage) {
qrImage.src = imageUrl
credential.hash = newPassword
}
if (credential.kind === 'PublicKey') {
if (credential.key.includes(' ')) {
const parts = credential.key.split(' ').filter(x => x)
credential.key = `${parts[0]} ${parts[1]}`
}
})
}
visible = false
save()
}
_validate()
}
function _validate () : boolean {
console.debug(`Validating credentials of kind "${credential.kind}"`)
if (credential.kind === 'Totp' && totpValidationValue) {
totp.secret ??= OTPAuth.Secret.fromBase32(encodeTotpSecret(credential))
totpValid = totp.validate({ token: totpValidationValue, window: 1 }) !== null
if (!totpValid) {
validationFeedback = 'The TOTP code is not valid'
} else {
validationFeedback = undefined
}
return totpValid
} else if (credential.kind === 'Password') {
passwordValid = newPassword.trim().length > 1
if (!passwordValid) {
validationFeedback = 'Password cannot be empty or whitespace'
} else {
validationFeedback = undefined
}
return passwordValid
} else {
// TODO: Further validation
return true
}
}
function generateNewTotpKey () {
if (credential.kind === 'Totp') {
credential.key = Array.from({ length: 32 }, () => Math.floor(Math.random() * 255))
}
}
/**
* Copies the TOTP URI to the system clipboard if it is defined.
*
* @return {Promise<void>} A promise that resolves when the TOTP URI has been copied to the clipboard.
*/
async function copyTotpUri () : Promise<void> {
if (totpUri === undefined) {
return
}
const { clipboard } = navigator
return clipboard.writeText(totpUri)
}
function _cancel () {
visible = false
cancel()
}
onMount(() => {
setTimeout(() => {
field?.focus()
})
})
/**
* Generates a TOTP (Time-based One-Time Password) secret key encoded in base32.
*
* @param {UserTotpCredential} cred - The credential containing a key for TOTP generation.
* @return {string} The base32 encoded TOTP secret key.
*/
function encodeTotpSecret (cred: UserTotpCredential) : string {
return base32Encode(new Uint8Array(cred.key), 'RFC4648')
}
$effect(() => {
if (credential.kind === 'Totp') {
if (!credential.key.length) {
generateNewTotpKey()
}
totp.label = username
totp.secret = OTPAuth.Secret.fromBase32(encodeTotpSecret(credential))
totpUri = totp.toString()
QRCode.toDataURL(totpUri, (err: Error | null | undefined, imageUrl: string) => {
if (err) {
return
}
if (qrImage) {
qrImage.src = imageUrl
}
})
}
_validate()
})
</script>
<Modal toggle={cancel} isOpen={visible}>

View file

@ -1,9 +1,9 @@
import { mount } from 'svelte'
import '../theme'
import App from './App.svelte'
new App({
mount(App, {
target: document.getElementById('app')!,
})
// eslint-disable-next-line @typescript-eslint/no-useless-empty-export
export { }

View file

@ -0,0 +1,67 @@
<script lang="ts">
// Copied from Sveltestrap and tweaked for S5 compatibility
import { fade as fadeTransition } from 'svelte/transition'
import { classnames } from './_sveltestrapUtils'
/**
* Additional CSS classes for container element.
* @type {string}
* @default ''
*/
interface Props {
class?: string;
closeAriaLabel?: string;
closeClassName?: string;
color?: string;
dismissible?: boolean;
fade?: boolean;
heading?: string;
isOpen?: boolean;
toggle?: CallableFunction;
theme?: string | undefined;
transition?: object;
headingSlot?: import('svelte').Snippet;
children: () => any
[key: string]: any
}
let {
'class': className = '',
closeAriaLabel = 'Close',
closeClassName = '',
color = 'success',
dismissible = false,
fade = true,
heading = '',
isOpen = $bindable(true),
toggle = undefined,
theme = undefined,
transition = { duration: fade ? 400 : 0 },
headingSlot,
children,
...rest
}: Props = $props()
let showClose = $derived(dismissible || toggle)
let handleToggle = $derived(toggle ?? (() => (isOpen = false)))
let classes = $derived(classnames(className, 'alert', `alert-${color}`, {
'alert-dismissible': showClose,
}))
let closeClassNames = $derived(classnames('btn-close', closeClassName))
</script>
{#if isOpen}
<div {...rest} data-bs-theme={theme} transition:fadeTransition={transition} class={classes} role="alert">
{#if heading || headingSlot}
<h4 class="alert-heading">
{heading}{@render headingSlot?.()}
</h4>
{/if}
{#if showClose}
<button type="button" class={closeClassNames} aria-label={closeAriaLabel} onclick={handleToggle as any}></button>
{/if}
{@render children?.()}
</div>
{/if}

View file

@ -3,6 +3,7 @@ import { faCheck } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import { Button, Spinner, type Color } from '@sveltestrap/sveltestrap'
// svelte-ignore non_reactive_update
enum State {
Normal = 'n',
Progress = 'p',
@ -10,30 +11,40 @@ enum State {
Done = 'd'
}
export let click: CallableFunction
export let color: Color | 'link' = 'secondary'
export let disabled = false
export let outline = false
export let type = 'submit'
let button: HTMLElement
let lastWidth = 0
let state = State.Normal
interface Props {
click: CallableFunction
color?: Color | 'link'
disabled?: boolean
outline?: boolean
type?: 'button' | 'submit' | 'reset'
class?: string
children: () => any
}
let { children, click, color = 'secondary', disabled = false, outline = false, type = 'submit', 'class': cls = '' }: Props = $props()
let button: HTMLElement | undefined = $state()
let lastWidth = $state(0)
let st = $state(State.Normal)
async function _click () {
if (!button) {
return
}
lastWidth = button.offsetWidth
state = State.Progress
st = State.Progress
setTimeout(() => {
if (state === State.Progress) {
state = State.ProgressWithSpinner
if (st === State.Progress) {
st = State.ProgressWithSpinner
}
}, 500)
try {
await click()
} finally {
state = State.Done
st = State.Done
setTimeout(() => {
if (state === State.Done) {
state = State.Normal
if (st === State.Done) {
st = State.Normal
lastWidth = 0
}
}, 1000)
@ -46,20 +57,20 @@ async function _click () {
on:click={_click}
bind:inner={button}
style="min-width: {lastWidth}px"
class={$$props.class}
class={cls}
outline={outline}
color={color}
type={type}
disabled={disabled || state === State.Progress || state === State.ProgressWithSpinner}
disabled={disabled || st === State.Progress || st === State.ProgressWithSpinner}
>
{#if state === State.Normal || state === State.Progress}
<slot />
{#if st === State.Normal || st === State.Progress}
{@render children?.()}
{/if}
<div class="overlay">
{#if state === State.ProgressWithSpinner}
{#if st === State.ProgressWithSpinner}
<Spinner size="sm" />
{/if}
{#if state === State.Done}
{#if st === State.Done}
<Fa icon={faCheck} fw />
{/if}
</div>

View file

@ -1,33 +1,44 @@
<script lang="ts">
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import { TargetKind } from 'gateway/lib/api'
import { serverInfo } from 'gateway/lib/store'
import { makeExampleSSHCommand, makeSSHUsername, makeExampleMySQLCommand, makeExampleMySQLURI, makeMySQLUsername, makeTargetURL, makeExamplePostgreSQLCommand, makePostgreSQLUsername, makeExamplePostgreSQLURI } from 'common/protocols'
import CopyButton from 'common/CopyButton.svelte'
import Alert from './Alert.svelte'
export let targetName: string|undefined
export let targetKind: TargetKind
export let targetExternalHost: string|undefined = undefined
export let username: string|undefined
export let ticketSecret: string|undefined = undefined
interface Props {
targetName?: string;
targetKind: TargetKind;
targetExternalHost?: string;
username?: string;
ticketSecret?: string;
}
$: opts = {
let {
targetName,
targetKind,
targetExternalHost = undefined,
username,
ticketSecret = undefined,
}: Props = $props()
let opts = $derived({
targetName,
username,
serverInfo: $serverInfo,
ticketSecret,
targetExternalHost,
}
$: sshUsername = makeSSHUsername(opts)
$: exampleSSHCommand = makeExampleSSHCommand(opts)
$: mySQLUsername = makeMySQLUsername(opts)
$: exampleMySQLCommand = makeExampleMySQLCommand(opts)
$: exampleMySQLURI = makeExampleMySQLURI(opts)
$: postgreSQLUsername = makePostgreSQLUsername(opts)
$: examplePostgreSQLCommand = makeExamplePostgreSQLCommand(opts)
$: examplePostgreSQLURI = makeExamplePostgreSQLURI(opts)
$: targetURL = targetName ? makeTargetURL(opts) : ''
$: authHeader = `Authorization: Warpgate ${ticketSecret}`
})
let sshUsername = $derived(makeSSHUsername(opts))
let exampleSSHCommand = $derived(makeExampleSSHCommand(opts))
let mySQLUsername = $derived(makeMySQLUsername(opts))
let exampleMySQLCommand = $derived(makeExampleMySQLCommand(opts))
let exampleMySQLURI = $derived(makeExampleMySQLURI(opts))
let postgreSQLUsername = $derived(makePostgreSQLUsername(opts))
let examplePostgreSQLCommand = $derived(makeExamplePostgreSQLCommand(opts))
let examplePostgreSQLURI = $derived(makeExamplePostgreSQLURI(opts))
let targetURL = $derived(targetName ? makeTargetURL(opts) : '')
let authHeader = $derived(`Authorization: Warpgate ${ticketSecret}`)
</script>
{#if targetKind === TargetKind.Ssh}

View file

@ -1,62 +1,79 @@
<script lang="ts">
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import { Button, type Color } from '@sveltestrap/sveltestrap'
import copyTextToClipboard from 'copy-text-to-clipboard'
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import { Button, type Color } from '@sveltestrap/sveltestrap'
import copyTextToClipboard from 'copy-text-to-clipboard'
export let text: string
export let disabled = false
export let outline = false
export let link = false
export let color: Color | 'link' = 'link'
let successVisible = false
let button: HTMLElement
async function _click () {
if (disabled) {
return
interface Props {
text: string;
disabled?: boolean;
outline?: boolean;
link?: boolean;
color?: Color | 'link';
class?: string;
children?: import('svelte').Snippet;
}
let {
text,
disabled = false,
outline = false,
link = false,
color = 'link',
'class': className = '',
children,
}: Props = $props()
let successVisible = $state(false)
let button: HTMLElement | undefined = $state()
async function _click () {
if (disabled) {
return
}
successVisible = true
copyTextToClipboard(text)
setTimeout(() => {
successVisible = false
}, 2000)
}
successVisible = true
copyTextToClipboard(text)
setTimeout(() => {
successVisible = false
}, 2000)
}
</script>
{#if link}
<!-- svelte-ignore a11y-invalid-attribute -->
<!-- svelte-ignore a11y_invalid_attribute -->
<a
href="#"
class={$$props.class}
class={className}
class:disabled={disabled}
on:click|preventDefault={_click}
onclick={e => {
_click()
e.preventDefault()
}}
bind:this={button}
>
<slot>
{#if children}{@render children()}{:else}
{#if successVisible}
Copied
{:else}
Copy
{/if}
</slot>
{/if}
</a>
{:else}
<Button
class={$$props.class}
class={className}
bind:inner={button}
on:click={_click}
outline={outline}
color={color}
disabled={disabled}
>
<slot>
{#if children}{@render children()}{:else}
{#if successVisible}
<Fa fw icon={faCheck} />
{:else}
<Fa fw icon={faCopy} />
{/if}
</slot>
{/if}
</Button>
{/if}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { Spinner } from '@sveltestrap/sveltestrap'
let visible = false
let visible = $state(false)
setTimeout(() => {
visible = true

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export interface LoadOptions {
search?: string
offset: number
@ -13,20 +13,36 @@
</script>
<script lang="ts" generics="T">
import { onDestroy } from 'svelte'
import { Subject, switchMap, map, Observable, distinctUntilChanged, share, combineLatest, tap, debounceTime } from 'rxjs'
import Pagination from './Pagination.svelte'
import { observe } from 'svelte-observable'
import { Input } from '@sveltestrap/sveltestrap'
import DelayedSpinner from './DelayedSpinner.svelte'
import { onDestroy } from 'svelte'
export let page = 0
export let pageSize: number|undefined = undefined
export let load: (_: LoadOptions) => Observable<PaginatedResponse<T>>
export let showSearch = false
interface Props {
page?: number
pageSize?: number|undefined
// eslint-disable-next-line no-undef
load: (_: LoadOptions) => Observable<PaginatedResponse<T>>
showSearch?: boolean
header?: import('svelte').Snippet<[any]>
item?: import('svelte').Snippet<[any]>
footer?: import('svelte').Snippet<[any]>
}
let filter = ''
let loaded = false
let {
page = $bindable(0),
pageSize = undefined,
load,
showSearch = false,
header,
item,
footer,
}: Props = $props()
let filter = $state('')
let loaded = $state(false)
const page$ = new Subject<number>()
const filter$ = new Subject<string>()
@ -57,6 +73,7 @@
)
const total = observe<number>(responses.pipe(map(x => x.total)), 0)
// eslint-disable-next-line no-undef
const items = observe<T[]|null>(responses.pipe(map(x => x.items)), null)
onDestroy(() => {
@ -64,8 +81,12 @@
filter$.complete()
})
$: page$.next(page)
$: filter$.next(filter)
$effect(() => {
page$.next(page)
})
$effect(() => {
filter$.next(filter)
})
filter$.subscribe(() => {
page = 0
@ -76,18 +97,18 @@
{#if showSearch}
<Input bind:value={filter} placeholder="Search..." class="flex-grow-1 border-0" />
{/if}
<slot name="header" items={items} />
{@render header?.({ items })}
</div>
{#await $items}
<DelayedSpinner />
{:then _items}
{#if _items}
<div class="list-group list-group-flush mb-3">
{#each _items as item}
<slot name="item" item={item} />
{#each _items as _item}
{@render item?.({ item: _item })}
{/each}
</div>
<slot name="footer" items={_items} />
{@render footer?.({ items: _items })}
{:else}
<DelayedSpinner />
{/if}

View file

@ -1,14 +1,11 @@
<script lang="ts">
// eslint-disable-next-line import/no-duplicates
import { onDestroy, onMount } from 'svelte'
// eslint-disable-next-line import/no-duplicates
import { get } from 'svelte/store'
import { currentThemeFile } from 'theme'
import logo from '../../public/assets/logo.svg?raw'
let element: HTMLElement|undefined
let element: HTMLElement|undefined = $state()
// eslint-disable-next-line @typescript-eslint/max-params
function colorize (r: number, g: number, b: number, dr: number, dg: number, db: number) {
element?.querySelectorAll('path').forEach((p, idx) => {
let d = idx

View file

@ -0,0 +1,36 @@
<script>
import { classnames } from './_sveltestrapUtils'
/**
* @typedef {Object} Props
* @property {string} [class]
* @property {boolean | CallableFunction | undefined} [toggle] - Determines whether the modal header includes a close button.
* @property {string} [closeAriaLabel] - The aria-label for the close button.
* @property {string} [id] - The unique id of the modal header.
* @property {any} [children]
* @property {import('svelte').Snippet} [children]
* @property {import('svelte').Snippet} [close]
*/
/** @type {Props & { [key: string]: any }} */
let {
'class': className = '',
toggle = undefined,
closeAriaLabel = 'Close',
children,
close,
} = $props()
let classes = $derived(classnames(className, 'modal-header'))
</script>
<div class={classes}>
<h5 class="modal-title">
{@render children()}
</h5>
{#if close}{@render close()}{:else}
{#if typeof toggle === 'function'}
<button type="button" onclick={() => toggle()} class="btn-close" aria-label={closeAriaLabel}></button>
{/if}
{/if}
</div>

View file

@ -1,27 +1,31 @@
<script lang="ts">
import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import { Pagination, PaginationItem, PaginationLink } from '@sveltestrap/sveltestrap'
import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import { Pagination, PaginationItem, PaginationLink } from '@sveltestrap/sveltestrap'
export let page = 0
export let pageSize = 1
export let total = 1
let pages: (number|null)[] = []
$: {
let i = 0
pages = []
let totalPages = Math.floor((total - 1) / pageSize + 1)
while (i < totalPages) {
if (i < 2 || i > totalPages - 3 || Math.abs(i - page) < 3) {
pages.push(i)
} else if (pages[pages.length - 1]) {
pages.push(null)
}
i++
interface Props {
page?: number;
pageSize?: number;
total?: number;
}
}
let { page = $bindable(0), pageSize = 1, total = 1 }: Props = $props()
let pages: (number|null)[] = $state([])
$effect(() => {
let i = 0
pages = []
let totalPages = Math.floor((total - 1) / pageSize + 1)
while (i < totalPages) {
if (i < 2 || i > totalPages - 3 || Math.abs(i - page) < 3) {
pages.push(i)
} else if (pages[pages.length - 1]) {
pages.push(null)
}
i++
}
})
</script>
<Pagination>

View file

@ -0,0 +1,25 @@
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function toClassName(value: any) {
let result = ''
if (typeof value === 'string' || typeof value === 'number') {
result += value
} else if (typeof value === 'object') {
if (Array.isArray(value)) {
result = value.map(toClassName).filter(Boolean).join(' ')
} else {
for (const key in value) {
if (value[key]) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
result && (result += ' ')
result += key
}
}
}
}
return result
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const classnames = (...args: any[]) => args.map(toClassName).filter(Boolean).join(' ')

View file

@ -1,7 +1,6 @@
import { api } from 'gateway/lib/api'
import EmbeddedUI from './EmbeddedUI.svelte'
// eslint-disable-next-line @typescript-eslint/no-useless-empty-export
export { }
navigator.serviceWorker.getRegistrations().then(registrations => {

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { Alert } from '@sveltestrap/sveltestrap'
import Router, { push, type RouteDetail } from 'svelte-spa-router'
import { wrap } from 'svelte-spa-router/wrap'
import { get } from 'svelte/store'
@ -8,6 +7,7 @@ import ThemeSwitcher from 'common/ThemeSwitcher.svelte'
import Logo from 'common/Logo.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import AuthBar from 'common/AuthBar.svelte'
import Alert from 'common/Alert.svelte'
let redirecting = false
let serverInfoPromise = reloadServerInfo()
@ -36,17 +36,17 @@ async function requireLogin (detail: RouteDetail) {
const routes = {
'/': wrap({
asyncComponent: () => import('./TargetList.svelte'),
asyncComponent: () => import('./TargetList.svelte') as any,
props: {
'on:navigation': () => redirecting = true,
},
conditions: [requireLogin],
}),
'/login': wrap({
asyncComponent: () => import('./Login.svelte'),
asyncComponent: () => import('./Login.svelte') as any,
}),
'/login/:stateId': wrap({
asyncComponent: () => import('./OutOfBandAuth.svelte'),
asyncComponent: () => import('./OutOfBandAuth.svelte') as any,
conditions: [requireLogin],
}),
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { get } from 'svelte/store'
import { querystring, replace } from 'svelte-spa-router'
import { Alert, FormGroup } from '@sveltestrap/sveltestrap'
import { FormGroup } from '@sveltestrap/sveltestrap'
import Fa from 'svelte-fa'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { faGoogle, faMicrosoft, faApple } from '@fortawesome/free-brands-svg-icons'
@ -11,16 +11,15 @@ import { reloadServerInfo } from 'gateway/lib/store'
import AsyncButton from 'common/AsyncButton.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import { stringifyError } from 'common/errors'
import Alert from 'common/Alert.svelte'
export const params: { stateId?: string } = {}
let error: string|null = null
let username = ''
let password = ''
let otp = ''
let busy = false
let otpInput: HTMLInputElement|undefined
let authState: ApiAuthState|undefined
let error: string|null = $state(null)
let username = $state('')
let password = $state('')
let otp = $state('')
let busy = $state(false)
let otpInput: HTMLInputElement|undefined = $state()
let authState: ApiAuthState|undefined = $state()
let ssoProvidersPromise = api.getSsoProviders()
const nextURL = new URLSearchParams(get(querystring)).get('next') ?? undefined
@ -148,11 +147,11 @@ async function startSSO (provider: SsoProviderDescription) {
</div>
{#if authState === ApiAuthState.OtpNeeded}
<FormGroup floating label="One-time password">
<!-- svelte-ignore a11y-autofocus -->
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={otp}
bind:this={otpInput}
on:keypress={onInputKey}
onkeypress={onInputKey}
name="otp"
autofocus
disabled={busy}
@ -161,10 +160,10 @@ async function startSSO (provider: SsoProviderDescription) {
{/if}
{#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed}
<FormGroup floating label="Username">
<!-- svelte-ignore a11y-autofocus -->
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={username}
on:keypress={onInputKey}
onkeypress={onInputKey}
name="username"
autocomplete="username"
disabled={busy}
@ -175,7 +174,7 @@ async function startSSO (provider: SsoProviderDescription) {
<FormGroup floating label="Password">
<input
bind:value={password}
on:keypress={onInputKey}
onkeypress={onInputKey}
name="password"
type="password"
autocomplete="current-password"
@ -213,7 +212,7 @@ async function startSSO (provider: SsoProviderDescription) {
<button
class="btn d-flex align-items-center w-100 mb-2 btn-outline-primary"
disabled={busy}
on:click={() => startSSO(ssoProvider)}
onclick={() => startSSO(ssoProvider)}
>
<span class="m-auto">
{#if ssoProvider.kind === SsoProviderKind.Google}
@ -236,7 +235,7 @@ async function startSSO (provider: SsoProviderDescription) {
{#if authState !== ApiAuthState.NotStarted && authState !== ApiAuthState.Failed}
<button
class="btn w-100 mt-3 btn-outline-secondary"
on:click={cancel}
onclick={cancel}
>
Cancel
</button>

View file

@ -1,13 +1,17 @@
<script lang="ts">
import { Alert } from '@sveltestrap/sveltestrap'
import { api, ApiAuthState, type AuthStateResponseInternal } from 'gateway/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import RelativeDate from 'admin/RelativeDate.svelte'
import Alert from 'common/Alert.svelte'
export let params: { stateId: string }
let authState: AuthStateResponseInternal
interface Props {
params: { stateId: string };
}
let { params }: Props = $props()
let authState: AuthStateResponseInternal | undefined = $state()
async function reload () {
authState = await api.getAuthState({ id: params.stateId })
@ -46,6 +50,7 @@ async function reject () {
{#await init()}
<DelayedSpinner />
{:then}
{#if authState}
<div class="page-summary-bar">
<h1>Authorization request</h1>
</div>
@ -53,7 +58,7 @@ async function reject () {
<div class="mb-5">
<div class="mb-2">Ensure this security key matches your authentication prompt:</div>
<div class="identification-string">
{#each authState.identificationString as char}
{#each authState?.identificationString as char}
<div class="card bg-secondary text-light">
<div class="card-body">{char}</div>
</div>
@ -97,4 +102,5 @@ async function reject () {
</AsyncButton>
</div>
{/if}
{/if}
{/await}

View file

@ -4,15 +4,13 @@ import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import ConnectionInstructions from 'common/ConnectionInstructions.svelte'
import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'
import { api, type TargetSnapshot, TargetKind } from 'gateway/lib/api'
import { createEventDispatcher } from 'svelte'
import Fa from 'svelte-fa'
import { Modal, ModalBody, ModalHeader } from '@sveltestrap/sveltestrap'
import { Modal, ModalBody } from '@sveltestrap/sveltestrap'
import { serverInfo } from './lib/store'
import { firstBy } from 'thenby'
import ModalHeader from 'common/ModalHeader.svelte'
const dispatch = createEventDispatcher()
let selectedTarget: TargetSnapshot|undefined
let selectedTarget: TargetSnapshot|undefined = $state()
function loadTargets (options: LoadOptions): Observable<PaginatedResponse<TargetSnapshot>> {
return from(api.getTargets({ search: options.search })).pipe(
@ -41,52 +39,53 @@ function selectTarget (target: TargetSnapshot) {
}
function loadURL (url: string) {
dispatch('navigation')
location.href = url
}
</script>
<ItemList load={loadTargets} showSearch={true}>
<a
slot="item" let:item={target}
class="list-group-item list-group-item-action target-item"
href={
target.kind === TargetKind.WebAdmin
? '/@warpgate/admin'
: target.kind === TargetKind.Http
? `/?warpgate-target=${target.name}`
: '/@warpgate/admin'
}
on:click|preventDefault={e => {
if (e.metaKey || e.ctrlKey) {
return
{#snippet item({ item: target })}
<a
class="list-group-item list-group-item-action target-item"
href={
target.kind === TargetKind.WebAdmin
? '/@warpgate/admin'
: target.kind === TargetKind.Http
? `/?warpgate-target=${target.name}`
: '/@warpgate/admin'
}
selectTarget(target)
}}
>
<span class="me-auto">
{#if target.kind === TargetKind.WebAdmin}
Manage Warpgate
{:else}
{target.name}
onclick={e => {
if (e.metaKey || e.ctrlKey) {
return
}
e.preventDefault()
selectTarget(target)
}}
>
<span class="me-auto">
{#if target.kind === TargetKind.WebAdmin}
Manage Warpgate
{:else}
{target.name}
{/if}
</span>
<small class="protocol text-muted ms-auto">
{#if target.kind === TargetKind.Ssh}
SSH
{/if}
{#if target.kind === TargetKind.MySql}
MySQL
{/if}
{#if target.kind === TargetKind.Postgres}
PostgreSQL
{/if}
</small>
{#if target.kind === TargetKind.Http || target.kind === TargetKind.WebAdmin}
<Fa icon={faArrowRight} fw />
{/if}
</span>
<small class="protocol text-muted ms-auto">
{#if target.kind === TargetKind.Ssh}
SSH
{/if}
{#if target.kind === TargetKind.MySql}
MySQL
{/if}
{#if target.kind === TargetKind.Postgres}
PostgreSQL
{/if}
</small>
{#if target.kind === TargetKind.Http || target.kind === TargetKind.WebAdmin}
<Fa icon={faArrowRight} fw />
{/if}
</a>
</a>
{/snippet}
</ItemList>
<Modal isOpen={!!selectedTarget} toggle={() => selectedTarget = undefined}>

View file

@ -1,9 +1,9 @@
import { mount } from 'svelte'
import '../theme'
import App from './App.svelte'
new App({
mount(App, {
target: document.getElementById('app')!,
})
// eslint-disable-next-line @typescript-eslint/no-useless-empty-export
export { }

View file

@ -1,14 +1,14 @@
import { UAParser } from 'ua-parser-js'
function escapeUnix (arg: string): string {
if (!/^[A-Za-z0-9_\/-]+$/.test(arg)) {
if (!/^[A-Za-z0-9_/-]+$/.test(arg)) {
return ('\'' + arg.replace(/'/g, '\'"\'"\'') + '\'').replace(/''/g, '')
}
return arg
}
function escapeWin (arg: string): string {
if (!/^[A-Za-z0-9_\/-]+$/.test(arg)) {
if (!/^[A-Za-z0-9_/-]+$/.test(arg)) {
return '"' + arg.replace(/"/g, '""') + '"'
}
return arg

View file

@ -1,7 +1,8 @@
import { mount } from 'svelte'
import Login from './Login.svelte'
const app = {}
new Login({
mount(Login, {
target: document.getElementById('app')!,
})

View file

@ -37,7 +37,6 @@ export function setCurrentTheme (theme: ThemeName): void {
localStorage.setItem('theme', theme)
currentTheme.set(theme)
if (theme === 'auto') {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
loadTheme('dark')
} else {

View file

@ -5,6 +5,9 @@ const config = {
compilerOptions: {
enableSourcemap: true,
dev: true,
compatibility: {
componentApi: 4,
},
},
preprocess: sveltePreprocess({
sourceMap: true,

File diff suppressed because it is too large Load diff