mirror of
https://github.com/warp-tech/warpgate.git
synced 2024-11-15 04:21:59 +08:00
Svelte 5 (#1101)
This commit is contained in:
parent
a903fdb811
commit
f1d565b6ea
44 changed files with 1522 additions and 1369 deletions
|
@ -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/**
|
178
warpgate-web/eslint.config.mjs
Normal file
178
warpgate-web/eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 { }
|
||||
|
|
67
warpgate-web/src/common/Alert.svelte
Normal file
67
warpgate-web/src/common/Alert.svelte
Normal 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}
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Spinner } from '@sveltestrap/sveltestrap'
|
||||
|
||||
let visible = false
|
||||
let visible = $state(false)
|
||||
|
||||
setTimeout(() => {
|
||||
visible = true
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
36
warpgate-web/src/common/ModalHeader.svelte
Normal file
36
warpgate-web/src/common/ModalHeader.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
25
warpgate-web/src/common/_sveltestrapUtils.ts
Normal file
25
warpgate-web/src/common/_sveltestrapUtils.ts
Normal 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(' ')
|
|
@ -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 => {
|
||||
|
|
|
@ -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],
|
||||
}),
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 { }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { mount } from 'svelte'
|
||||
import Login from './Login.svelte'
|
||||
|
||||
const app = {}
|
||||
new Login({
|
||||
mount(Login, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue