Add new vue-based frontend
This commit is contained in:
9
packages/sshecret-frontend/.editorconfig
Normal file
9
packages/sshecret-frontend/.editorconfig
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
1
packages/sshecret-frontend/.envrc
Normal file
1
packages/sshecret-frontend/.envrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
export SSHECRET_FRONTEND_API_BASE_URL="http://localhost:8822"
|
||||||
1
packages/sshecret-frontend/.gitattributes
vendored
Normal file
1
packages/sshecret-frontend/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
30
packages/sshecret-frontend/.gitignore
vendored
Normal file
30
packages/sshecret-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
6
packages/sshecret-frontend/.prettierrc.json
Normal file
6
packages/sshecret-frontend/.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
9
packages/sshecret-frontend/.vscode/extensions.json
vendored
Normal file
9
packages/sshecret-frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"vitest.explorer",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
45
packages/sshecret-frontend/README.md
Normal file
45
packages/sshecret-frontend/README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# sshecret-frontend
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
23
packages/sshecret-frontend/TODO.org
Normal file
23
packages/sshecret-frontend/TODO.org
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#+title: Todo
|
||||||
|
|
||||||
|
I'm apparently rewriting the frontend in vue.
|
||||||
|
|
||||||
|
So, this is the steps I need to take.
|
||||||
|
|
||||||
|
* DONE Write a typescript API client.
|
||||||
|
I suppose this can be rendered from OpenAPI?
|
||||||
|
|
||||||
|
* Install the required libraries
|
||||||
|
+ [X] Flowbite
|
||||||
|
+ [X] Shoelace
|
||||||
|
+ [X] tailwind
|
||||||
|
|
||||||
|
I've set up flowbite-vue here.
|
||||||
|
https://flowbite-vue.com/pages/getting-started
|
||||||
|
* DONE Set up base page
|
||||||
|
* Set up login page (basic)
|
||||||
|
+ [ ] Create an authentication state store
|
||||||
|
+ [ ] Use the rendered API to send login details, and receive tokens
|
||||||
|
+ [ ] Consider adding a refresh token thing to the API
|
||||||
|
|
||||||
|
* Create the master/detail page
|
||||||
1
packages/sshecret-frontend/env.d.ts
vendored
Normal file
1
packages/sshecret-frontend/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
28
packages/sshecret-frontend/eslint.config.ts
Normal file
28
packages/sshecret-frontend/eslint.config.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginVitest from '@vitest/eslint-plugin'
|
||||||
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginVitest.configs.recommended,
|
||||||
|
files: ['src/**/__tests__/*'],
|
||||||
|
},
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
16
packages/sshecret-frontend/index.html
Normal file
16
packages/sshecret-frontend/index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sshecret Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
id="app"
|
||||||
|
class="bg-gray-50 text-gray-900 dark:bg-gray-900 min-h-screen flex flex-col dark:bg-gray-900"
|
||||||
|
></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
packages/sshecret-frontend/openapi-ts.config.ts
Normal file
19
packages/sshecret-frontend/openapi-ts.config.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "@hey-api/openapi-ts"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
input: "./openapi.json",
|
||||||
|
output: "./src/client",
|
||||||
|
// exportSchemas: true,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "@hey-api/sdk",
|
||||||
|
// NOTE: this doesn't allow tree-shaking
|
||||||
|
asClass: true,
|
||||||
|
operationId: true,
|
||||||
|
classNameBuilder: 'SshecretAdmin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '@hey-api/client-axios',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
1
packages/sshecret-frontend/openapi.json
Normal file
1
packages/sshecret-frontend/openapi.json
Normal file
File diff suppressed because one or more lines are too long
8712
packages/sshecret-frontend/package-lock.json
generated
Normal file
8712
packages/sshecret-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
packages/sshecret-frontend/package.json
Normal file
56
packages/sshecret-frontend/package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "sshecret-frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"generate-client": "openapi-ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shoelace-style/shoelace": "^2.20.1",
|
||||||
|
"@shoelace-style/vue-sl-model": "^1.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"flowbite": "^3.1.2",
|
||||||
|
"flowbite-vue": "^0.2.1",
|
||||||
|
"is-cidr": "^5.1.1",
|
||||||
|
"is-ip": "^5.0.1",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@hey-api/openapi-ts": "^0.77.0",
|
||||||
|
"@tsconfig/node22": "^22.0.1",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"@vitest/eslint-plugin": "^1.1.39",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.5.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.30.1",
|
||||||
|
"eslint-plugin-vue": "~10.0.0",
|
||||||
|
"jiti": "^2.4.2",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
|
"npm-run-all2": "^7.0.2",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "3.5.3",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"vite": "^6.2.4",
|
||||||
|
"vite-plugin-vue-devtools": "^7.7.2",
|
||||||
|
"vitest": "^3.1.1",
|
||||||
|
"vue-tsc": "^2.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
packages/sshecret-frontend/public/favicon.ico
Normal file
BIN
packages/sshecret-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
8
packages/sshecret-frontend/src/App.vue
Normal file
8
packages/sshecret-frontend/src/App.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthTokenStore } from '@/store/auth'
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
32
packages/sshecret-frontend/src/api/interceptors.ts
Normal file
32
packages/sshecret-frontend/src/api/interceptors.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { client } from '@/client/client.gen'
|
||||||
|
import { useAuthTokenStore } from '@/store/auth.ts'
|
||||||
|
|
||||||
|
|
||||||
|
client.instance.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async error => {
|
||||||
|
|
||||||
|
const originalRequest = error.config
|
||||||
|
console.log(originalRequest)
|
||||||
|
if (originalRequest.url.includes("/refresh")) {
|
||||||
|
const auth = useAuthTokenStore()
|
||||||
|
auth.logout()
|
||||||
|
return Promise.reject("Refresh failed - logged out")
|
||||||
|
}
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
console.log("Got 401")
|
||||||
|
const auth = useAuthTokenStore()
|
||||||
|
if (auth.isLoggedIn) {
|
||||||
|
const refreshed = await auth.refresh()
|
||||||
|
if (refreshed) {
|
||||||
|
return client.instance.request(error.config)
|
||||||
|
}
|
||||||
|
auth.logout()
|
||||||
|
return Promise.reject("Could not refresh token")
|
||||||
|
}
|
||||||
|
return Promise.reject("Could not refresh token")
|
||||||
|
}
|
||||||
|
return Promise.reject("Could not refresh token")
|
||||||
|
}
|
||||||
|
)
|
||||||
14
packages/sshecret-frontend/src/api/types.ts
Normal file
14
packages/sshecret-frontend/src/api/types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export type PageRequest = {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SshecretObjectType {
|
||||||
|
Client = "Client",
|
||||||
|
ClientSecret = "ClientSecret",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SshecretObject = {
|
||||||
|
objectType: SshecretObjectType,
|
||||||
|
id: string,
|
||||||
|
}
|
||||||
26
packages/sshecret-frontend/src/assets/base.css
Normal file
26
packages/sshecret-frontend/src/assets/base.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
sl-divider:not([vertical]) {
|
||||||
|
border-top: solid var(--width) var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
sl-divider[vertical] {
|
||||||
|
border-left: solid var(--width) var(--color);
|
||||||
|
}
|
||||||
|
sl-menu-item::part(prefix) {
|
||||||
|
padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
sl-menu-item::part(base) {
|
||||||
|
padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small);
|
||||||
|
}
|
||||||
|
sl-menu {
|
||||||
|
padding: var(--sl-spacing-x-small) 0;
|
||||||
|
border: solid var(--sl-panel-border-width) var(--sl-panel-border-color);
|
||||||
|
}
|
||||||
|
sl-menu-label {
|
||||||
|
padding: var(--sl-spacing-2x-small) var(--sl-spacing-x-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
20
packages/sshecret-frontend/src/assets/logo.svg
Normal file
20
packages/sshecret-frontend/src/assets/logo.svg
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 504 494" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-300,-220)">
|
||||||
|
<g transform="matrix(1.60539,0,0,1.60539,-319.922,-249.523)">
|
||||||
|
<g transform="matrix(1,0,0,1,8.07567,42.4671)">
|
||||||
|
<path d="M535,250L561.806,335L613.462,308.734L605.179,367.467L691.924,367.467L621.746,420L661.954,462.5L605.179,472.533L631.985,557.533L561.806,505L535,557.533L508.194,505L438.015,557.533L464.821,472.533L408.046,462.5L448.254,420L378.076,367.467L464.821,367.467L456.538,308.734L508.194,335L535,250Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.8125,0,0,0.8125,331.826,19.4716)">
|
||||||
|
<path d="M246.833,633.448C236.413,655.429 209.771,670 180,670L180,557.533C209.771,557.533 236.413,572.104 246.833,594.085L275.336,594.085C285.418,572.104 311.195,557.533 340,557.533L340,670C311.195,670 285.418,655.429 275.336,633.448L246.833,633.448Z" style="fill:rgb(180,17,4);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,258.076,2.46711)">
|
||||||
|
<ellipse cx="250" cy="415" rx="20" ry="15" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,328.076,2.46711)">
|
||||||
|
<ellipse cx="250" cy="415" rx="20" ry="15" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
packages/sshecret-frontend/src/assets/main.css
Normal file
1
packages/sshecret-frontend/src/assets/main.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
@import './base.css';
|
||||||
1
packages/sshecret-frontend/src/assets/vue-logo.svg
Normal file
1
packages/sshecret-frontend/src/assets/vue-logo.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
16
packages/sshecret-frontend/src/client/client.gen.ts
Normal file
16
packages/sshecret-frontend/src/client/client.gen.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { ClientOptions } from './types.gen';
|
||||||
|
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
|
||||||
|
|
||||||
|
export const client = createClient(createConfig<ClientOptions>());
|
||||||
115
packages/sshecret-frontend/src/client/client/client.ts
Normal file
115
packages/sshecret-frontend/src/client/client/client.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import type { AxiosError, RawAxiosRequestHeaders } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import type { Client, Config } from './types';
|
||||||
|
import {
|
||||||
|
buildUrl,
|
||||||
|
createConfig,
|
||||||
|
mergeConfigs,
|
||||||
|
mergeHeaders,
|
||||||
|
setAuthParams,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
export const createClient = (config: Config = {}): Client => {
|
||||||
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { auth, ...configWithoutAuth } = _config;
|
||||||
|
const instance = axios.create(configWithoutAuth);
|
||||||
|
|
||||||
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
|
const setConfig = (config: Config): Config => {
|
||||||
|
_config = mergeConfigs(_config, config);
|
||||||
|
instance.defaults = {
|
||||||
|
...instance.defaults,
|
||||||
|
..._config,
|
||||||
|
// @ts-expect-error
|
||||||
|
headers: mergeHeaders(instance.defaults.headers, _config.headers),
|
||||||
|
};
|
||||||
|
return getConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
const request: Client['request'] = async (options) => {
|
||||||
|
const opts = {
|
||||||
|
..._config,
|
||||||
|
...options,
|
||||||
|
axios: options.axios ?? _config.axios ?? instance,
|
||||||
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.security) {
|
||||||
|
await setAuthParams({
|
||||||
|
...opts,
|
||||||
|
security: opts.security,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.requestValidator) {
|
||||||
|
await opts.requestValidator(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.body && opts.bodySerializer) {
|
||||||
|
opts.body = opts.bodySerializer(opts.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildUrl(opts);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// assign Axios here for consistency with fetch
|
||||||
|
const _axios = opts.axios!;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { auth, ...optsWithoutAuth } = opts;
|
||||||
|
const response = await _axios({
|
||||||
|
...optsWithoutAuth,
|
||||||
|
baseURL: opts.baseURL as string,
|
||||||
|
data: opts.body,
|
||||||
|
headers: opts.headers as RawAxiosRequestHeaders,
|
||||||
|
// let `paramsSerializer()` handle query params if it exists
|
||||||
|
params: opts.paramsSerializer ? opts.query : undefined,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
let { data } = response;
|
||||||
|
|
||||||
|
if (opts.responseType === 'json') {
|
||||||
|
if (opts.responseValidator) {
|
||||||
|
await opts.responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.responseTransformer) {
|
||||||
|
data = await opts.responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: data ?? {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as AxiosError;
|
||||||
|
if (opts.throwOnError) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
e.error = e.response?.data ?? {};
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl,
|
||||||
|
delete: (options) => request({ ...options, method: 'DELETE' }),
|
||||||
|
get: (options) => request({ ...options, method: 'GET' }),
|
||||||
|
getConfig,
|
||||||
|
head: (options) => request({ ...options, method: 'HEAD' }),
|
||||||
|
instance,
|
||||||
|
options: (options) => request({ ...options, method: 'OPTIONS' }),
|
||||||
|
patch: (options) => request({ ...options, method: 'PATCH' }),
|
||||||
|
post: (options) => request({ ...options, method: 'POST' }),
|
||||||
|
put: (options) => request({ ...options, method: 'PUT' }),
|
||||||
|
request,
|
||||||
|
setConfig,
|
||||||
|
} as Client;
|
||||||
|
};
|
||||||
21
packages/sshecret-frontend/src/client/client/index.ts
Normal file
21
packages/sshecret-frontend/src/client/client/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export type { Auth } from '../core/auth';
|
||||||
|
export type { QuerySerializerOptions } from '../core/bodySerializer';
|
||||||
|
export {
|
||||||
|
formDataBodySerializer,
|
||||||
|
jsonBodySerializer,
|
||||||
|
urlSearchParamsBodySerializer,
|
||||||
|
} from '../core/bodySerializer';
|
||||||
|
export { buildClientParams } from '../core/params';
|
||||||
|
export { createClient } from './client';
|
||||||
|
export type {
|
||||||
|
Client,
|
||||||
|
ClientOptions,
|
||||||
|
Config,
|
||||||
|
CreateClientConfig,
|
||||||
|
Options,
|
||||||
|
OptionsLegacyParser,
|
||||||
|
RequestOptions,
|
||||||
|
RequestResult,
|
||||||
|
TDataShape,
|
||||||
|
} from './types';
|
||||||
|
export { createConfig } from './utils';
|
||||||
178
packages/sshecret-frontend/src/client/client/types.ts
Normal file
178
packages/sshecret-frontend/src/client/client/types.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import type {
|
||||||
|
AxiosError,
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosResponse,
|
||||||
|
AxiosStatic,
|
||||||
|
CreateAxiosDefaults,
|
||||||
|
} from 'axios';
|
||||||
|
|
||||||
|
import type { Auth } from '../core/auth';
|
||||||
|
import type {
|
||||||
|
Client as CoreClient,
|
||||||
|
Config as CoreConfig,
|
||||||
|
} from '../core/types';
|
||||||
|
|
||||||
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
|
extends Omit<CreateAxiosDefaults, 'auth' | 'baseURL' | 'headers' | 'method'>,
|
||||||
|
CoreConfig {
|
||||||
|
/**
|
||||||
|
* Axios implementation. You can use this option to provide a custom
|
||||||
|
* Axios instance.
|
||||||
|
*
|
||||||
|
* @default axios
|
||||||
|
*/
|
||||||
|
axios?: AxiosStatic;
|
||||||
|
/**
|
||||||
|
* Base URL for all requests made by this client.
|
||||||
|
*/
|
||||||
|
baseURL?: T['baseURL'];
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| CreateAxiosDefaults['headers']
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| (string | number | boolean)[]
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* Throw an error instead of returning it in the response?
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
throwOnError?: T['throwOnError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions<
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
> extends Config<{
|
||||||
|
throwOnError: ThrowOnError;
|
||||||
|
}> {
|
||||||
|
/**
|
||||||
|
* Any body that you want to add to your request.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
|
*/
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Security mechanism(s) to use for the request.
|
||||||
|
*/
|
||||||
|
security?: ReadonlyArray<Auth>;
|
||||||
|
url: Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestResult<
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
> = ThrowOnError extends true
|
||||||
|
? Promise<
|
||||||
|
AxiosResponse<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData
|
||||||
|
>
|
||||||
|
>
|
||||||
|
: Promise<
|
||||||
|
| (AxiosResponse<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData
|
||||||
|
> & { error: undefined })
|
||||||
|
| (AxiosError<
|
||||||
|
TError extends Record<string, unknown> ? TError[keyof TError] : TError
|
||||||
|
> & {
|
||||||
|
data: undefined;
|
||||||
|
error: TError extends Record<string, unknown>
|
||||||
|
? TError[keyof TError]
|
||||||
|
: TError;
|
||||||
|
})
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
baseURL?: string;
|
||||||
|
throwOnError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MethodFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<ThrowOnError>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError>;
|
||||||
|
|
||||||
|
type RequestFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<ThrowOnError>, 'method'> &
|
||||||
|
Pick<Required<RequestOptions<ThrowOnError>>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError>;
|
||||||
|
|
||||||
|
type BuildUrlFn = <
|
||||||
|
TData extends {
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
options: Pick<TData, 'url'> & Omit<Options<TData>, 'axios'>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
|
||||||
|
instance: AxiosInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
|
override?: Config<ClientOptions & T>,
|
||||||
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export interface TDataShape {
|
||||||
|
body?: unknown;
|
||||||
|
headers?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
query?: unknown;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
|
export type Options<
|
||||||
|
TData extends TDataShape = TDataShape,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
> = OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'path' | 'query' | 'url'> &
|
||||||
|
Omit<TData, 'url'>;
|
||||||
|
|
||||||
|
export type OptionsLegacyParser<
|
||||||
|
TData = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
> = TData extends { body?: any }
|
||||||
|
? TData extends { headers?: any }
|
||||||
|
? OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'headers' | 'url'> & TData
|
||||||
|
: OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'url'> &
|
||||||
|
TData &
|
||||||
|
Pick<RequestOptions<ThrowOnError>, 'headers'>
|
||||||
|
: TData extends { headers?: any }
|
||||||
|
? OmitKeys<RequestOptions<ThrowOnError>, 'headers' | 'url'> &
|
||||||
|
TData &
|
||||||
|
Pick<RequestOptions<ThrowOnError>, 'body'>
|
||||||
|
: OmitKeys<RequestOptions<ThrowOnError>, 'url'> & TData;
|
||||||
286
packages/sshecret-frontend/src/client/client/utils.ts
Normal file
286
packages/sshecret-frontend/src/client/client/utils.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { getAuthToken } from '../core/auth';
|
||||||
|
import type {
|
||||||
|
QuerySerializer,
|
||||||
|
QuerySerializerOptions,
|
||||||
|
} from '../core/bodySerializer';
|
||||||
|
import type { ArraySeparatorStyle } from '../core/pathSerializer';
|
||||||
|
import {
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from '../core/pathSerializer';
|
||||||
|
import type { Client, ClientOptions, Config, RequestOptions } from './types';
|
||||||
|
|
||||||
|
interface PathSerializer {
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
|
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
|
let url = _url;
|
||||||
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
|
if (matches) {
|
||||||
|
for (const match of matches) {
|
||||||
|
let explode = false;
|
||||||
|
let name = match.substring(1, match.length - 1);
|
||||||
|
let style: ArraySeparatorStyle = 'simple';
|
||||||
|
|
||||||
|
if (name.endsWith('*')) {
|
||||||
|
explode = true;
|
||||||
|
name = name.substring(0, name.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith('.')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'label';
|
||||||
|
} else if (name.startsWith(';')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'matrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = path[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeArrayParam({ explode, name, style, value }),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeObjectParam({
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
valueOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'matrix') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
`;${serializePrimitiveParam({
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceValue = encodeURIComponent(
|
||||||
|
style === 'label' ? `.${value as string}` : (value as string),
|
||||||
|
);
|
||||||
|
url = url.replace(match, replaceValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createQuerySerializer = <T = unknown>({
|
||||||
|
allowReserved,
|
||||||
|
array,
|
||||||
|
object,
|
||||||
|
}: QuerySerializerOptions = {}) => {
|
||||||
|
const querySerializer = (queryParams: T) => {
|
||||||
|
const search: string[] = [];
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const name in queryParams) {
|
||||||
|
const value = queryParams[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const serializedArray = serializeArrayParam({
|
||||||
|
allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'form',
|
||||||
|
value,
|
||||||
|
...array,
|
||||||
|
});
|
||||||
|
if (serializedArray) search.push(serializedArray);
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
const serializedObject = serializeObjectParam({
|
||||||
|
allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'deepObject',
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
...object,
|
||||||
|
});
|
||||||
|
if (serializedObject) search.push(serializedObject);
|
||||||
|
} else {
|
||||||
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
});
|
||||||
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return search.join('&');
|
||||||
|
};
|
||||||
|
return querySerializer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAuthParams = async ({
|
||||||
|
security,
|
||||||
|
...options
|
||||||
|
}: Pick<Required<RequestOptions>, 'security'> &
|
||||||
|
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Record<any, unknown>;
|
||||||
|
}) => {
|
||||||
|
for (const auth of security) {
|
||||||
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = auth.name ?? 'Authorization';
|
||||||
|
|
||||||
|
switch (auth.in) {
|
||||||
|
case 'query':
|
||||||
|
if (!options.query) {
|
||||||
|
options.query = {};
|
||||||
|
}
|
||||||
|
options.query[name] = token;
|
||||||
|
break;
|
||||||
|
case 'cookie': {
|
||||||
|
const value = `${name}=${token}`;
|
||||||
|
if ('Cookie' in options.headers && options.headers['Cookie']) {
|
||||||
|
options.headers['Cookie'] = `${options.headers['Cookie']}; ${value}`;
|
||||||
|
} else {
|
||||||
|
options.headers['Cookie'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'header':
|
||||||
|
default:
|
||||||
|
options.headers[name] = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildUrl: Client['buildUrl'] = (options) => {
|
||||||
|
const url = getUrl({
|
||||||
|
path: options.path,
|
||||||
|
// let `paramsSerializer()` handle query params if it exists
|
||||||
|
query: !options.paramsSerializer ? options.query : undefined,
|
||||||
|
querySerializer:
|
||||||
|
typeof options.querySerializer === 'function'
|
||||||
|
? options.querySerializer
|
||||||
|
: createQuerySerializer(options.querySerializer),
|
||||||
|
url: options.url,
|
||||||
|
});
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUrl = ({
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
querySerializer,
|
||||||
|
url: _url,
|
||||||
|
}: {
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
querySerializer: QuerySerializer;
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||||
|
let url = pathUrl;
|
||||||
|
if (path) {
|
||||||
|
url = defaultPathSerializer({ path, url });
|
||||||
|
}
|
||||||
|
let search = query ? querySerializer(query) : '';
|
||||||
|
if (search.startsWith('?')) {
|
||||||
|
search = search.substring(1);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
url += `?${search}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
|
const config = { ...a, ...b };
|
||||||
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special Axios headers keywords allowing to set headers by request method.
|
||||||
|
*/
|
||||||
|
export const axiosHeadersKeywords = [
|
||||||
|
'common',
|
||||||
|
'delete',
|
||||||
|
'get',
|
||||||
|
'head',
|
||||||
|
'patch',
|
||||||
|
'post',
|
||||||
|
'put',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const mergeHeaders = (
|
||||||
|
...headers: Array<Required<Config>['headers'] | undefined>
|
||||||
|
): Record<any, unknown> => {
|
||||||
|
const mergedHeaders: Record<any, unknown> = {};
|
||||||
|
for (const header of headers) {
|
||||||
|
if (!header || typeof header !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator = Object.entries(header);
|
||||||
|
|
||||||
|
for (const [key, value] of iterator) {
|
||||||
|
if (
|
||||||
|
axiosHeadersKeywords.includes(
|
||||||
|
key as (typeof axiosHeadersKeywords)[number],
|
||||||
|
) &&
|
||||||
|
typeof value === 'object'
|
||||||
|
) {
|
||||||
|
mergedHeaders[key] = {
|
||||||
|
...(mergedHeaders[key] as Record<any, unknown>),
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
} else if (value === null) {
|
||||||
|
delete mergedHeaders[key];
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
// @ts-expect-error
|
||||||
|
mergedHeaders[key] = [...(mergedHeaders[key] ?? []), v as string];
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
// assume object headers are meant to be JSON stringified, i.e. their
|
||||||
|
// content value in OpenAPI specification is 'application/json'
|
||||||
|
mergedHeaders[key] =
|
||||||
|
typeof value === 'object' ? JSON.stringify(value) : (value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
|
...override,
|
||||||
|
});
|
||||||
40
packages/sshecret-frontend/src/client/core/auth.ts
Normal file
40
packages/sshecret-frontend/src/client/core/auth.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
/**
|
||||||
|
* Which part of the request do we use to send the auth?
|
||||||
|
*
|
||||||
|
* @default 'header'
|
||||||
|
*/
|
||||||
|
in?: 'header' | 'query' | 'cookie';
|
||||||
|
/**
|
||||||
|
* Header or query parameter name.
|
||||||
|
*
|
||||||
|
* @default 'Authorization'
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
scheme?: 'basic' | 'bearer';
|
||||||
|
type: 'apiKey' | 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthToken = async (
|
||||||
|
auth: Auth,
|
||||||
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const token =
|
||||||
|
typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'bearer') {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'basic') {
|
||||||
|
return `Basic ${btoa(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
84
packages/sshecret-frontend/src/client/core/bodySerializer.ts
Normal file
84
packages/sshecret-frontend/src/client/core/bodySerializer.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type {
|
||||||
|
ArrayStyle,
|
||||||
|
ObjectStyle,
|
||||||
|
SerializerOptions,
|
||||||
|
} from './pathSerializer';
|
||||||
|
|
||||||
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export type BodySerializer = (body: any) => any;
|
||||||
|
|
||||||
|
export interface QuerySerializerOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
array?: SerializerOptions<ArrayStyle>;
|
||||||
|
object?: SerializerOptions<ObjectStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeFormDataPair = (data: FormData, key: string, value: unknown) => {
|
||||||
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeUrlSearchParamsPair = (
|
||||||
|
data: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formDataBodySerializer = {
|
||||||
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
|
body: T,
|
||||||
|
) => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeFormDataPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonBodySerializer = {
|
||||||
|
bodySerializer: <T>(body: T) =>
|
||||||
|
JSON.stringify(body, (_key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const urlSearchParamsBodySerializer = {
|
||||||
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
|
body: T,
|
||||||
|
) => {
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.toString();
|
||||||
|
},
|
||||||
|
};
|
||||||
141
packages/sshecret-frontend/src/client/core/params.ts
Normal file
141
packages/sshecret-frontend/src/client/core/params.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||||
|
|
||||||
|
export type Field =
|
||||||
|
| {
|
||||||
|
in: Exclude<Slot, 'body'>;
|
||||||
|
key: string;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in: Extract<Slot, 'body'>;
|
||||||
|
key?: string;
|
||||||
|
map?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Fields {
|
||||||
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
|
args?: ReadonlyArray<Field>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
|
$body_: 'body',
|
||||||
|
$headers_: 'headers',
|
||||||
|
$path_: 'path',
|
||||||
|
$query_: 'query',
|
||||||
|
};
|
||||||
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
|
type KeyMap = Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
in: Slot;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of fields) {
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
map.set(config.key, {
|
||||||
|
in: config.in,
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (config.args) {
|
||||||
|
buildKeyMap(config.args, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
body: unknown;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripEmptySlots = (params: Params) => {
|
||||||
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
|
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||||
|
delete params[slot as Slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildClientParams = (
|
||||||
|
args: ReadonlyArray<unknown>,
|
||||||
|
fields: FieldsConfig,
|
||||||
|
) => {
|
||||||
|
const params: Params = {
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
path: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
|
for (const [index, arg] of args.entries()) {
|
||||||
|
if (fields[index]) {
|
||||||
|
config = fields[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
const field = map.get(config.key)!;
|
||||||
|
const name = field.map || config.key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
|
} else {
|
||||||
|
params.body = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
|
const field = map.get(key);
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
const name = field.map || key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
|
} else {
|
||||||
|
const extra = extraPrefixes.find(([prefix]) =>
|
||||||
|
key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extra) {
|
||||||
|
const [prefix, slot] = extra;
|
||||||
|
(params[slot] as Record<string, unknown>)[
|
||||||
|
key.slice(prefix.length)
|
||||||
|
] = value;
|
||||||
|
} else {
|
||||||
|
for (const [slot, allowed] of Object.entries(
|
||||||
|
config.allowExtra ?? {},
|
||||||
|
)) {
|
||||||
|
if (allowed) {
|
||||||
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripEmptySlots(params);
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
179
packages/sshecret-frontend/src/client/core/pathSerializer.ts
Normal file
179
packages/sshecret-frontend/src/client/core/pathSerializer.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
interface SerializeOptions<T>
|
||||||
|
extends SerializePrimitiveOptions,
|
||||||
|
SerializerOptions<T> {}
|
||||||
|
|
||||||
|
interface SerializePrimitiveOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializerOptions<T> {
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
explode: boolean;
|
||||||
|
style: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||||
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
|
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||||
|
export type ObjectStyle = 'form' | 'deepObject';
|
||||||
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return ',';
|
||||||
|
case 'pipeDelimited':
|
||||||
|
return '|';
|
||||||
|
case 'spaceDelimited':
|
||||||
|
return '%20';
|
||||||
|
default:
|
||||||
|
return ',';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeArrayParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
|
value: unknown[];
|
||||||
|
}) => {
|
||||||
|
if (!explode) {
|
||||||
|
const joinedValues = (
|
||||||
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
|
).join(separatorArrayNoExplode(style));
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
case 'simple':
|
||||||
|
return joinedValues;
|
||||||
|
default:
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorArrayExplode(style);
|
||||||
|
const joinedValues = value
|
||||||
|
.map((v) => {
|
||||||
|
if (style === 'label' || style === 'simple') {
|
||||||
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: v as string,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializePrimitiveParam = ({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}: SerializePrimitiveParam) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
throw new Error(
|
||||||
|
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeObjectParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
valueOnly,
|
||||||
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
|
value: Record<string, unknown> | Date;
|
||||||
|
valueOnly?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style !== 'deepObject' && !explode) {
|
||||||
|
let values: string[] = [];
|
||||||
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
|
values = [
|
||||||
|
...values,
|
||||||
|
key,
|
||||||
|
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const joinedValues = values.join(',');
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
default:
|
||||||
|
return joinedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorObjectExplode(style);
|
||||||
|
const joinedValues = Object.entries(value)
|
||||||
|
.map(([key, v]) =>
|
||||||
|
serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||||
|
value: v as string,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
|
};
|
||||||
104
packages/sshecret-frontend/src/client/core/types.ts
Normal file
104
packages/sshecret-frontend/src/client/core/types.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import type { Auth, AuthToken } from './auth';
|
||||||
|
import type {
|
||||||
|
BodySerializer,
|
||||||
|
QuerySerializer,
|
||||||
|
QuerySerializerOptions,
|
||||||
|
} from './bodySerializer';
|
||||||
|
|
||||||
|
export interface Client<
|
||||||
|
RequestFn = never,
|
||||||
|
Config = unknown,
|
||||||
|
MethodFn = never,
|
||||||
|
BuildUrlFn = never,
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Returns the final request URL.
|
||||||
|
*/
|
||||||
|
buildUrl: BuildUrlFn;
|
||||||
|
connect: MethodFn;
|
||||||
|
delete: MethodFn;
|
||||||
|
get: MethodFn;
|
||||||
|
getConfig: () => Config;
|
||||||
|
head: MethodFn;
|
||||||
|
options: MethodFn;
|
||||||
|
patch: MethodFn;
|
||||||
|
post: MethodFn;
|
||||||
|
put: MethodFn;
|
||||||
|
request: RequestFn;
|
||||||
|
setConfig: (config: Config) => Config;
|
||||||
|
trace: MethodFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/**
|
||||||
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
|
* added to the request payload as defined by its `security` array.
|
||||||
|
*/
|
||||||
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
|
/**
|
||||||
|
* A function for serializing request body parameter. By default,
|
||||||
|
* {@link JSON.stringify()} will be used.
|
||||||
|
*/
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| RequestInit['headers']
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| (string | number | boolean)[]
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* The request method.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
|
*/
|
||||||
|
method?:
|
||||||
|
| 'CONNECT'
|
||||||
|
| 'DELETE'
|
||||||
|
| 'GET'
|
||||||
|
| 'HEAD'
|
||||||
|
| 'OPTIONS'
|
||||||
|
| 'PATCH'
|
||||||
|
| 'POST'
|
||||||
|
| 'PUT'
|
||||||
|
| 'TRACE';
|
||||||
|
/**
|
||||||
|
* A function for serializing request query parameters. By default, arrays
|
||||||
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
|
* style, and reserved characters are percent-encoded.
|
||||||
|
*
|
||||||
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
|
* API function is used.
|
||||||
|
*
|
||||||
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
|
*/
|
||||||
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
|
/**
|
||||||
|
* A function validating request data. This is useful if you want to ensure
|
||||||
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function transforming response data before it's returned. This is useful
|
||||||
|
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||||
|
*/
|
||||||
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function validating response data. This is useful if you want to ensure
|
||||||
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
|
* the transformers and returned to the user.
|
||||||
|
*/
|
||||||
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
3
packages/sshecret-frontend/src/client/index.ts
Normal file
3
packages/sshecret-frontend/src/client/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
export * from './types.gen';
|
||||||
|
export * from './sdk.gen';
|
||||||
534
packages/sshecret-frontend/src/client/sdk.gen.ts
Normal file
534
packages/sshecret-frontend/src/client/sdk.gen.ts
Normal file
@ -0,0 +1,534 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { type Options as ClientOptions, type TDataShape, type Client, urlSearchParamsBodySerializer } from './client';
|
||||||
|
import type { GetHealthHealthGetData, GetHealthHealthGetResponses, LoginForAccessTokenApiV1TokenPostData, LoginForAccessTokenApiV1TokenPostResponses, LoginForAccessTokenApiV1TokenPostErrors, RefreshTokenApiV1RefreshPostData, RefreshTokenApiV1RefreshPostResponses, RefreshTokenApiV1RefreshPostErrors, GetClientsApiV1ClientsGetData, GetClientsApiV1ClientsGetResponses, CreateClientApiV1ClientsPostData, CreateClientApiV1ClientsPostResponses, CreateClientApiV1ClientsPostErrors, GetClientsTerseApiV1ClientsTerseGetData, GetClientsTerseApiV1ClientsTerseGetResponses, QueryClientsApiV1QueryClientsGetData, QueryClientsApiV1QueryClientsGetResponses, QueryClientsApiV1QueryClientsGetErrors, DeleteClientApiV1ClientsIdDeleteData, DeleteClientApiV1ClientsIdDeleteResponses, DeleteClientApiV1ClientsIdDeleteErrors, GetClientApiV1ClientsIdGetData, GetClientApiV1ClientsIdGetResponses, GetClientApiV1ClientsIdGetErrors, UpdateClientApiV1ClientsIdPutData, UpdateClientApiV1ClientsIdPutResponses, UpdateClientApiV1ClientsIdPutErrors, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteData, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteResponses, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteErrors, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutData, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutResponses, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutErrors, UpdateClientPoliciesApiV1ClientsIdPoliciesPutData, UpdateClientPoliciesApiV1ClientsIdPoliciesPutResponses, UpdateClientPoliciesApiV1ClientsIdPoliciesPutErrors, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutData, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutResponses, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutErrors, GetSecretNamesApiV1SecretsGetData, GetSecretNamesApiV1SecretsGetResponses, AddSecretApiV1SecretsPostData, AddSecretApiV1SecretsPostResponses, AddSecretApiV1SecretsPostErrors, DeleteSecretApiV1SecretsNameDeleteData, DeleteSecretApiV1SecretsNameDeleteResponses, DeleteSecretApiV1SecretsNameDeleteErrors, GetSecretApiV1SecretsNameGetData, GetSecretApiV1SecretsNameGetResponses, GetSecretApiV1SecretsNameGetErrors, UpdateSecretApiV1SecretsNamePutData, UpdateSecretApiV1SecretsNamePutResponses, UpdateSecretApiV1SecretsNamePutErrors, GetSecretGroupsApiV1SecretsGroupsGetData, GetSecretGroupsApiV1SecretsGroupsGetResponses, GetSecretGroupsApiV1SecretsGroupsGetErrors, AddSecretGroupApiV1SecretsGroupsPostData, AddSecretGroupApiV1SecretsGroupsPostResponses, AddSecretGroupApiV1SecretsGroupsPostErrors, DeleteSecretGroupApiV1SecretsGroupsGroupNameDeleteData, DeleteSecretGroupApiV1SecretsGroupsGroupNameDeleteResponses, DeleteSecretGroupApiV1SecretsGroupsGroupNameDeleteErrors, GetSecretGroupApiV1SecretsGroupsGroupNameGetData, GetSecretGroupApiV1SecretsGroupsGroupNameGetResponses, GetSecretGroupApiV1SecretsGroupsGroupNameGetErrors, RemoveSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDeleteData, RemoveSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDeleteResponses, RemoveSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDeleteErrors, MoveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePostData, MoveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePostResponses, MoveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePostErrors, MoveGroupApiV1SecretsGroupGroupNameParentParentNamePostData, MoveGroupApiV1SecretsGroupGroupNameParentParentNamePostResponses, MoveGroupApiV1SecretsGroupGroupNameParentParentNamePostErrors, MoveGroupToRootApiV1SecretsGroupGroupNameParentDeleteData, MoveGroupToRootApiV1SecretsGroupGroupNameParentDeleteResponses, MoveGroupToRootApiV1SecretsGroupGroupNameParentDeleteErrors } from './types.gen';
|
||||||
|
import { client as _heyApiClient } from './client.gen';
|
||||||
|
|
||||||
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||||
|
/**
|
||||||
|
* You can provide a client instance returned by `createClient()` instead of
|
||||||
|
* individual options. This might be also useful if you want to implement a
|
||||||
|
* custom client.
|
||||||
|
*/
|
||||||
|
client?: Client;
|
||||||
|
/**
|
||||||
|
* You can pass arbitrary values through the `meta` object. This can be
|
||||||
|
* used to access values that aren't defined as part of the SDK function.
|
||||||
|
*/
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SshecretAdmin {
|
||||||
|
/**
|
||||||
|
* Get Health
|
||||||
|
* Provide simple health check.
|
||||||
|
*/
|
||||||
|
public static getHealthHealthGet<ThrowOnError extends boolean = false>(options?: Options<GetHealthHealthGetData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetHealthHealthGetResponses, unknown, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
url: '/health',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login For Access Token
|
||||||
|
* Login user and generate token.
|
||||||
|
*/
|
||||||
|
public static loginForAccessTokenApiV1TokenPost<ThrowOnError extends boolean = false>(options: Options<LoginForAccessTokenApiV1TokenPostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<LoginForAccessTokenApiV1TokenPostResponses, LoginForAccessTokenApiV1TokenPostErrors, ThrowOnError>({
|
||||||
|
...urlSearchParamsBodySerializer,
|
||||||
|
responseType: 'json',
|
||||||
|
url: '/api/v1/token',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token
|
||||||
|
* Refresh access token.
|
||||||
|
*/
|
||||||
|
public static refreshTokenApiV1RefreshPost<ThrowOnError extends boolean = false>(options: Options<RefreshTokenApiV1RefreshPostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<RefreshTokenApiV1RefreshPostResponses, RefreshTokenApiV1RefreshPostErrors, ThrowOnError>({
|
||||||
|
...urlSearchParamsBodySerializer,
|
||||||
|
responseType: 'json',
|
||||||
|
url: '/api/v1/refresh',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Clients
|
||||||
|
* Get clients.
|
||||||
|
*/
|
||||||
|
public static getClientsApiV1ClientsGet<ThrowOnError extends boolean = false>(options?: Options<GetClientsApiV1ClientsGetData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetClientsApiV1ClientsGetResponses, unknown, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Client
|
||||||
|
* Create a new client.
|
||||||
|
*/
|
||||||
|
public static createClientApiV1ClientsPost<ThrowOnError extends boolean = false>(options: Options<CreateClientApiV1ClientsPostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<CreateClientApiV1ClientsPostResponses, CreateClientApiV1ClientsPostErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Clients Terse
|
||||||
|
* Get a list of client ids and names.
|
||||||
|
*/
|
||||||
|
public static getClientsTerseApiV1ClientsTerseGet<ThrowOnError extends boolean = false>(options?: Options<GetClientsTerseApiV1ClientsTerseGetData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetClientsTerseApiV1ClientsTerseGetResponses, unknown, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/terse/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query Clients
|
||||||
|
* Query clients.
|
||||||
|
*/
|
||||||
|
public static queryClientsApiV1QueryClientsGet<ThrowOnError extends boolean = false>(options?: Options<QueryClientsApiV1QueryClientsGetData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? _heyApiClient).get<QueryClientsApiV1QueryClientsGetResponses, QueryClientsApiV1QueryClientsGetErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/query/clients/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Client
|
||||||
|
* Delete a client.
|
||||||
|
*/
|
||||||
|
public static deleteClientApiV1ClientsIdDelete<ThrowOnError extends boolean = false>(options: Options<DeleteClientApiV1ClientsIdDeleteData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).delete<DeleteClientApiV1ClientsIdDeleteResponses, DeleteClientApiV1ClientsIdDeleteErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Client
|
||||||
|
* Get a client.
|
||||||
|
*/
|
||||||
|
public static getClientApiV1ClientsIdGet<ThrowOnError extends boolean = false>(options: Options<GetClientApiV1ClientsIdGetData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).get<GetClientApiV1ClientsIdGetResponses, GetClientApiV1ClientsIdGetErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Client
|
||||||
|
* Update a client.
|
||||||
|
*/
|
||||||
|
public static updateClientApiV1ClientsIdPut<ThrowOnError extends boolean = false>(options: Options<UpdateClientApiV1ClientsIdPutData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).put<UpdateClientApiV1ClientsIdPutResponses, UpdateClientApiV1ClientsIdPutErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Secret From Client
|
||||||
|
* Delete a secret from a client.
|
||||||
|
*/
|
||||||
|
public static deleteSecretFromClientApiV1ClientsIdSecretsSecretNameDelete<ThrowOnError extends boolean = false>(options: Options<DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).delete<DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteResponses, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}/secrets/{secret_name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Secret To Client
|
||||||
|
* Add secret to a client.
|
||||||
|
*/
|
||||||
|
public static addSecretToClientApiV1ClientsIdSecretsSecretNamePut<ThrowOnError extends boolean = false>(options: Options<AddSecretToClientApiV1ClientsIdSecretsSecretNamePutData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).put<AddSecretToClientApiV1ClientsIdSecretsSecretNamePutResponses, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}/secrets/{secret_name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Client Policies
|
||||||
|
* Update the client access policies.
|
||||||
|
*/
|
||||||
|
public static updateClientPoliciesApiV1ClientsIdPoliciesPut<ThrowOnError extends boolean = false>(options: Options<UpdateClientPoliciesApiV1ClientsIdPoliciesPutData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).put<UpdateClientPoliciesApiV1ClientsIdPoliciesPutResponses, UpdateClientPoliciesApiV1ClientsIdPoliciesPutErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}/policies',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Client Public Key
|
||||||
|
* Update client public key.
|
||||||
|
*
|
||||||
|
* Updating the public key will invalidate the current secrets, so these well
|
||||||
|
* be resolved first, and re-encrypted using the new key.
|
||||||
|
*/
|
||||||
|
public static updateClientPublicKeyApiV1ClientsIdPublicKeyPut<ThrowOnError extends boolean = false>(options: Options<UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).put<UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutResponses, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/clients/{id}/public-key',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Secret Names
|
||||||
|
* Get Secret Names.
|
||||||
|
*/
|
||||||
|
public static getSecretNamesApiV1SecretsGet<ThrowOnError extends boolean = false>(options?: Options<GetSecretNamesApiV1SecretsGetData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetSecretNamesApiV1SecretsGetResponses, unknown, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Secret
|
||||||
|
* Create a secret.
|
||||||
|
*/
|
||||||
|
public static addSecretApiV1SecretsPost<ThrowOnError extends boolean = false>(options: Options<AddSecretApiV1SecretsPostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<AddSecretApiV1SecretsPostResponses, AddSecretApiV1SecretsPostErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Secret
|
||||||
|
* Delete secret.
|
||||||
|
*/
|
||||||
|
public static deleteSecretApiV1SecretsNameDelete<ThrowOnError extends boolean = false>(options: Options<DeleteSecretApiV1SecretsNameDeleteData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).delete<DeleteSecretApiV1SecretsNameDeleteResponses, DeleteSecretApiV1SecretsNameDeleteErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/{name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Secret
|
||||||
|
* Get a secret.
|
||||||
|
*/
|
||||||
|
public static getSecretApiV1SecretsNameGet<ThrowOnError extends boolean = false>(options: Options<GetSecretApiV1SecretsNameGetData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).get<GetSecretApiV1SecretsNameGetResponses, GetSecretApiV1SecretsNameGetErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/{name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Secret
|
||||||
|
*/
|
||||||
|
public static updateSecretApiV1SecretsNamePut<ThrowOnError extends boolean = false>(options: Options<UpdateSecretApiV1SecretsNamePutData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).put<UpdateSecretApiV1SecretsNamePutResponses, UpdateSecretApiV1SecretsNamePutErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/{name}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Secret Groups
|
||||||
|
* Get secret groups.
|
||||||
|
*/
|
||||||
|
public static getSecretGroupsApiV1SecretsGroupsGet<ThrowOnError extends boolean = false>(options?: Options<GetSecretGroupsApiV1SecretsGroupsGetData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetSecretGroupsApiV1SecretsGroupsGetResponses, GetSecretGroupsApiV1SecretsGroupsGetErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/groups/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Secret Group
|
||||||
|
* Create a secret grouping.
|
||||||
|
*/
|
||||||
|
public static addSecretGroupApiV1SecretsGroupsPost<ThrowOnError extends boolean = false>(options: Options<AddSecretGroupApiV1SecretsGroupsPostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<AddSecretGroupApiV1SecretsGroupsPostResponses, AddSecretGroupApiV1SecretsGroupsPostErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/groups/',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Secret Group
|
||||||
|
* Remove a group.
|
||||||
|
*
|
||||||
|
* Entries within the group will be moved to the root.
|
||||||
|
* This also includes nested entries further down from the group.
|
||||||
|
*/
|
||||||
|
public static deleteSecretGroupApiV1SecretsGroupsGroupNameDelete<ThrowOnError extends boolean = false>(options: Options<DeleteSecretGroupApiV1SecretsGroupsGroupNameDeleteData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).delete<DeleteSecretGroupApiV1SecretsGroupsGroupNameDeleteResponses, DeleteSecretGroupApiV1SecretsGroupsGroupNameDeleteErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/groups/{group_name}/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Secret Group
|
||||||
|
* Get a specific secret group.
|
||||||
|
*/
|
||||||
|
public static getSecretGroupApiV1SecretsGroupsGroupNameGet<ThrowOnError extends boolean = false>(options: Options<GetSecretGroupApiV1SecretsGroupsGroupNameGetData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).get<GetSecretGroupApiV1SecretsGroupsGroupNameGetResponses, GetSecretGroupApiV1SecretsGroupsGroupNameGetErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/groups/{group_name}/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Secret From Group
|
||||||
|
* Remove a secret from a group.
|
||||||
|
*
|
||||||
|
* Secret will be moved to the root group.
|
||||||
|
*/
|
||||||
|
public static removeSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDelete<ThrowOnError extends boolean = false>(options: Options<RemoveSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDeleteData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).delete<RemoveSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDeleteResponses, RemoveSecretFromGroupApiV1SecretsGroupsGroupNameSecretNameDeleteErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/groups/{group_name}/{secret_name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move Secret To Group
|
||||||
|
* Move a secret to a group.
|
||||||
|
*/
|
||||||
|
public static moveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePost<ThrowOnError extends boolean = false>(options: Options<MoveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<MoveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePostResponses, MoveSecretToGroupApiV1SecretsGroupsGroupNameSecretNamePostErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/groups/{group_name}/{secret_name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move Group
|
||||||
|
* Move a group.
|
||||||
|
*/
|
||||||
|
public static moveGroupApiV1SecretsGroupGroupNameParentParentNamePost<ThrowOnError extends boolean = false>(options: Options<MoveGroupApiV1SecretsGroupGroupNameParentParentNamePostData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).post<MoveGroupApiV1SecretsGroupGroupNameParentParentNamePostResponses, MoveGroupApiV1SecretsGroupGroupNameParentParentNamePostErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/group/{group_name}/parent/{parent_name}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move Group To Root
|
||||||
|
* Move a group to the root.
|
||||||
|
*/
|
||||||
|
public static moveGroupToRootApiV1SecretsGroupGroupNameParentDelete<ThrowOnError extends boolean = false>(options: Options<MoveGroupToRootApiV1SecretsGroupGroupNameParentDeleteData, ThrowOnError>) {
|
||||||
|
return (options.client ?? _heyApiClient).delete<MoveGroupToRootApiV1SecretsGroupGroupNameParentDeleteResponses, MoveGroupToRootApiV1SecretsGroupGroupNameParentDeleteErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/secrets/group/{group_name}/parent/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1180
packages/sshecret-frontend/src/client/types.gen.ts
Normal file
1180
packages/sshecret-frontend/src/client/types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import HelloWorld from '../HelloWorld.vue'
|
||||||
|
|
||||||
|
describe('HelloWorld', () => {
|
||||||
|
it('renders properly', () => {
|
||||||
|
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
||||||
|
expect(wrapper.text()).toContain('Hello Vitest')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-end px-4">
|
||||||
|
<sl-dropdown>
|
||||||
|
<div slot="trigger">
|
||||||
|
<sl-icon-button name="three-dots"></sl-icon-button>
|
||||||
|
</div>
|
||||||
|
<sl-menu>
|
||||||
|
<sl-menu-item value="edit" @click="updateDrawerOpen = true"> Edit client </sl-menu-item>
|
||||||
|
<sl-menu-item value="delete">
|
||||||
|
<span class="text-red-600" @click="showConfirm = true">Delete client</span>
|
||||||
|
</sl-menu-item>
|
||||||
|
</sl-menu>
|
||||||
|
</sl-dropdown>
|
||||||
|
</div>
|
||||||
|
<sl-tab-group>
|
||||||
|
<sl-tab slot="nav" panel="client_data">Client Data</sl-tab>
|
||||||
|
<sl-tab slot="nav" panel="events">Events</sl-tab>
|
||||||
|
|
||||||
|
<sl-tab-panel name="client_data">
|
||||||
|
<div id="client_details">
|
||||||
|
<div class="w-full p-2">
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h3 class="text-base/7 font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{{ localClient.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="mt-1 max-w-2xl text-sm/6 text-gray-500 dark:text-gray-100"
|
||||||
|
v-if="localClient.description"
|
||||||
|
>
|
||||||
|
{{ localClient.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 border-t border-gray-100">
|
||||||
|
<dl class="divide-y divide-gray-100">
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Client ID</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
{{ localClient.id }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
Client Description
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
{{ localClient.description }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
Client Version
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
{{ localClient.version }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Public Key</dt>
|
||||||
|
<dd
|
||||||
|
class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300 truncate"
|
||||||
|
>
|
||||||
|
{{ localClient.public_key }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
Assigned Secrets
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
{{ clientSecretCount }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
Allowed sources
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
{{ clientPolicies }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sl-tab-panel>
|
||||||
|
<sl-tab-panel name="events"> </sl-tab-panel>
|
||||||
|
</sl-tab-group>
|
||||||
|
|
||||||
|
<sl-drawer label="Edit Client" :open="updateDrawerOpen" @sl-hide="updateDrawerOpen = false">
|
||||||
|
<ClientForm @submit="updateClient" @cancel="updateDrawerOpen = false" :client="client" />
|
||||||
|
</sl-drawer>
|
||||||
|
<sl-dialog label="Are you sure?" :open="showConfirm">
|
||||||
|
Are you sure you want to delete this client?
|
||||||
|
<div slot="footer">
|
||||||
|
<sl-button variant="default" @click="showConfirm = false" class="mr-2">Cancel</sl-button>
|
||||||
|
<sl-button variant="danger" @click="deleteClient">Delete</sl-button>
|
||||||
|
</div>
|
||||||
|
</sl-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { Client, ClientCreate } from '@/client/types.gen'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/button/button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/dialog/dialog.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/divider/divider.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/drawer/drawer.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/menu/menu.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/menu-item/menu-item.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/menu-label/menu-label.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tab/tab.js'
|
||||||
|
import ClientForm from '@/components/clients/ClientForm.vue'
|
||||||
|
const props = defineProps<{ client: Client }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', data: ClientCreate): void
|
||||||
|
(e: 'deleted', data: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localClient = ref({ ...props.client })
|
||||||
|
|
||||||
|
const updateDrawerOpen = ref<boolean>(false)
|
||||||
|
const showConfirm = ref<boolean>(false)
|
||||||
|
const clientSecretCount = computed(() => localClient.value.secrets.length)
|
||||||
|
const clientPolicies = computed(() => localClient.value.policies.join(', '))
|
||||||
|
async function updateClient(data: ClientCreate) {
|
||||||
|
emit('update', data)
|
||||||
|
updateDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient() {
|
||||||
|
showConfirm.value = false
|
||||||
|
emit('deleted', localClient.value.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
236
packages/sshecret-frontend/src/components/clients/ClientForm.vue
Normal file
236
packages/sshecret-frontend/src/components/clients/ClientForm.vue
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<form @submit.prevent="submitForm" class="client-form" ref="clientCreateForm">
|
||||||
|
<sl-input
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
help-text="Name of the client, usually the hostname"
|
||||||
|
:disabled="isEdit"
|
||||||
|
:value="name"
|
||||||
|
@blur="checkName"
|
||||||
|
@input="name = $event.target.value"
|
||||||
|
ref="nameField"
|
||||||
|
></sl-input>
|
||||||
|
<br />
|
||||||
|
<sl-input
|
||||||
|
label="Description"
|
||||||
|
autocomplete="off"
|
||||||
|
help-text="Optional description of the client"
|
||||||
|
:value="description"
|
||||||
|
@input="description = $event.target.value"
|
||||||
|
></sl-input>
|
||||||
|
<br />
|
||||||
|
<sl-input
|
||||||
|
label="Permitted Sources"
|
||||||
|
:value="sourcePrefix"
|
||||||
|
@input="sourcePrefix = $event.target.value"
|
||||||
|
@blur="addPolicy"
|
||||||
|
@keydown.enter.prevent="addPolicy"
|
||||||
|
autocomplete="off"
|
||||||
|
help-text="Enter the source IP addresses or IP prefixes that the client may connect from. Press enter or go to next field to add"
|
||||||
|
ref="sourceField"
|
||||||
|
>
|
||||||
|
<sl-icon-button name="plus-circle" slot="suffix" @click="addPolicy"></sl-icon-button>
|
||||||
|
</sl-input>
|
||||||
|
<div class="my-2 border-b border-gray-100">
|
||||||
|
<p>Added sources</p>
|
||||||
|
<sl-tag
|
||||||
|
class="mr-1 mb-1"
|
||||||
|
v-for="(prefix, index) in policies"
|
||||||
|
size="small"
|
||||||
|
pill
|
||||||
|
removable
|
||||||
|
@sl-remove="removePolicy(index)"
|
||||||
|
>
|
||||||
|
{{ prefix }}
|
||||||
|
</sl-tag>
|
||||||
|
</div>
|
||||||
|
<sl-textarea
|
||||||
|
label="Public Key"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
help-text="Enter the RSA public key of the client."
|
||||||
|
ref="publicKeyField"
|
||||||
|
:value="publicKey"
|
||||||
|
@sl-input="publicKey = $event.target.value"
|
||||||
|
@blur="validatePublicKey"
|
||||||
|
></sl-textarea>
|
||||||
|
<br />
|
||||||
|
<sl-button type="submit" variant="primary" v-if="isEdit">Update Client</sl-button>
|
||||||
|
<sl-button type="submit" variant="primary" v-else>Create Client</sl-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { isIP } from 'is-ip'
|
||||||
|
import isCidr from 'is-cidr'
|
||||||
|
|
||||||
|
import '@shoelace-style/shoelace/dist/components/button/button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/input/input.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tag/tag.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/textarea/textarea.js'
|
||||||
|
import type { ClientCreate } from '@/client/types.gen'
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const sourcePrefix = ref('')
|
||||||
|
const policies = ref(['0.0.0.0/0', '::/0'])
|
||||||
|
const publicKey = ref('')
|
||||||
|
|
||||||
|
const nameField = ref<HTMLSlInputElement>()
|
||||||
|
const sourceField = ref<HTMLSlInputElement>()
|
||||||
|
const publicKeyField = ref<HTMLSlInputElement>()
|
||||||
|
const clientCreateForm = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const props = defineProps<{ client?: Client | null }>()
|
||||||
|
const emit = defineEmits<{ (e: 'submit', data: ClientCreate): void; (e: 'cancel'): void }>()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.client)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.client,
|
||||||
|
(client) => {
|
||||||
|
if (client) {
|
||||||
|
name.value = client.name
|
||||||
|
if (client.description) {
|
||||||
|
description.value = client.description
|
||||||
|
}
|
||||||
|
publicKey.value = client.public_key
|
||||||
|
policies.value = client.policies
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function setFieldValidation(field: Ref<HTMLSlInputElement>, errorMessage: string = '') {
|
||||||
|
// Set validation on a field
|
||||||
|
field.value?.setCustomValidity(errorMessage)
|
||||||
|
field.value?.reportValidity()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPolicy() {
|
||||||
|
if (!sourcePrefix.value) {
|
||||||
|
setFieldValidation(sourceField)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isIP(sourcePrefix.value) && !isCidr(sourcePrefix.value)) {
|
||||||
|
setFieldValidation(sourceField, 'Invalid IP address or prefix')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldValidation(sourceField)
|
||||||
|
if (!policies.value.includes(sourcePrefix.value)) {
|
||||||
|
policies.value.push(sourcePrefix.value)
|
||||||
|
}
|
||||||
|
sourcePrefix.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePolicy(index: number) {
|
||||||
|
policies.value.splice(index, 1)
|
||||||
|
if (policies.value.length == 0) {
|
||||||
|
setFieldValidation(sourceField, 'Must have at least one source defined')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkName() {
|
||||||
|
nameField.value.reportValidity()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePublicKey() {
|
||||||
|
const pubkey = publicKey.value
|
||||||
|
const defaultError = 'Invalid public key. Must be a valid ssh-rsa key.'
|
||||||
|
if (!pubkey.startsWith('ssh-rsa ')) {
|
||||||
|
setFieldValidation(publicKeyField, defaultError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parts = publicKey.value.split(' ')
|
||||||
|
if (parts.length < 2) {
|
||||||
|
setFieldValidation(publicKeyField, defaultError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parts[1].length < 10) {
|
||||||
|
setFieldValidation(publicKeyField, 'SSH Key looks too short to be valid.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldValidation(publicKeyField, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (clientCreateForm.value?.checkValidity()) {
|
||||||
|
let clientDescription: string | null = null
|
||||||
|
if (description.value) {
|
||||||
|
clientDescription = description.value
|
||||||
|
}
|
||||||
|
const clientCreate: ClientCreate = {
|
||||||
|
name: name.value,
|
||||||
|
description: clientDescription,
|
||||||
|
public_key: publicKey.value.trim(),
|
||||||
|
sources: [...policies.value],
|
||||||
|
}
|
||||||
|
emit('submit', clientCreate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.client-form sl-input,
|
||||||
|
.client-form sl-select,
|
||||||
|
.client-form sl-checkbox {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--sl-spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* user invalid styles */
|
||||||
|
.client-form sl-input[data-user-invalid]::part(base),
|
||||||
|
.client-form sl-select[data-user-invalid]::part(combobox),
|
||||||
|
.client-form sl-checkbox[data-user-invalid]::part(control) {
|
||||||
|
border-color: var(--sl-color-danger-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form [data-user-invalid]::part(form-control-label),
|
||||||
|
.client-form [data-user-invalid]::part(form-control-help-text),
|
||||||
|
.client-form sl-checkbox[data-user-invalid]::part(label) {
|
||||||
|
color: var(--sl-color-danger-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-checkbox[data-user-invalid]::part(control) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-input:focus-within[data-user-invalid]::part(base),
|
||||||
|
.client-form sl-select:focus-within[data-user-invalid]::part(combobox),
|
||||||
|
.client-form sl-checkbox:focus-within[data-user-invalid]::part(control) {
|
||||||
|
border-color: var(--sl-color-danger-600);
|
||||||
|
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User valid styles */
|
||||||
|
.client-form sl-input[data-user-valid]::part(base),
|
||||||
|
.client-form sl-select[data-user-valid]::part(combobox),
|
||||||
|
.client-form sl-checkbox[data-user-valid]::part(control) {
|
||||||
|
border-color: var(--sl-color-success-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form [data-user-valid]::part(form-control-label),
|
||||||
|
.client-form [data-user-valid]::part(form-control-help-text),
|
||||||
|
.client-form sl-checkbox[data-user-valid]::part(label) {
|
||||||
|
color: var(--sl-color-success-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-checkbox[data-user-valid]::part(control) {
|
||||||
|
background-color: var(--sl-color-success-600);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-input:focus-within[data-user-valid]::part(base),
|
||||||
|
.client-form sl-select:focus-within[data-user-valid]::part(combobox),
|
||||||
|
.client-form sl-checkbox:focus-within[data-user-valid]::part(control) {
|
||||||
|
border-color: var(--sl-color-success-600);
|
||||||
|
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<sl-tree-item :id="itemId" data-type="secret" :data-name="name" :data-parent-id="parent_id">
|
||||||
|
<sl-icon name="file-lock2"> </sl-icon>
|
||||||
|
<span class="px-2">{{ name }}</span>
|
||||||
|
</sl-tree-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
const props = defineProps<{
|
||||||
|
parentId: string
|
||||||
|
name: string
|
||||||
|
selected?: boolean
|
||||||
|
}>()
|
||||||
|
const itemId = computed(() => `client-${props.parent_id}-secret-${props.name}`)
|
||||||
|
</script>
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<sl-tab-group>
|
||||||
|
<sl-tab slot="nav" panel="skeleton"><sl-skeleton></sl-skeleton></sl-tab>
|
||||||
|
|
||||||
|
<sl-tab-panel name="skeleton">
|
||||||
|
<div id="client_details">
|
||||||
|
<div class="w-full p-2">
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h3 class="text-base/7 font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm/6 text-gray-500 dark:text-gray-100">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 border-t border-gray-100">
|
||||||
|
<dl class="divide-y divide-gray-100">
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
<sl-skeleton effect="pulse"></sl-skeleton>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sl-tab-panel>
|
||||||
|
</sl-tab-group>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
sl-tab sl-skeleton {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
h3 sl-skeleton {
|
||||||
|
width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p sl-skeleton {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tab/tab.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/skeleton/skeleton.js'
|
||||||
|
</script>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div id="client-tree-items" class="flowbite-init-target flex flex-col h-full min-h-0">
|
||||||
|
<sl-tree-item v-bind:id="itemId" data-type="client" :data-client-id="id">
|
||||||
|
<sl-icon name="person-fill-lock"> </sl-icon>
|
||||||
|
<span class="px-2">{{ name }}</span>
|
||||||
|
<slot />
|
||||||
|
</sl-tree-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
const itemId = computed(() => `client-${props.id}`)
|
||||||
|
</script>
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<form @submit.prevent="submitForm" class="client-form" ref="clientCreateForm">
|
||||||
|
<sl-input
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
help-text="Name of the client, usually the hostname"
|
||||||
|
:value="name"
|
||||||
|
@blur="checkName"
|
||||||
|
@input="name = $event.target.value"
|
||||||
|
ref="nameField"
|
||||||
|
></sl-input>
|
||||||
|
<br />
|
||||||
|
<sl-input
|
||||||
|
label="Description"
|
||||||
|
autocomplete="off"
|
||||||
|
help-text="Optional description of the client"
|
||||||
|
:value="description"
|
||||||
|
@input="description = $event.target.value"
|
||||||
|
></sl-input>
|
||||||
|
<br />
|
||||||
|
<sl-input
|
||||||
|
label="Permitted Sources"
|
||||||
|
:value="sourcePrefix"
|
||||||
|
@input="sourcePrefix = $event.target.value"
|
||||||
|
@blur="addPolicy"
|
||||||
|
@keydown.enter.prevent="addPolicy"
|
||||||
|
autocomplete="off"
|
||||||
|
help-text="Enter the source IP addresses or IP prefixes that the client may connect from. Press enter or go to next field to add"
|
||||||
|
ref="sourceField"
|
||||||
|
>
|
||||||
|
<sl-icon-button name="plus-circle" slot="suffix" @click="addPolicy"></sl-icon-button>
|
||||||
|
</sl-input>
|
||||||
|
<div class="my-2 border-b border-gray-100">
|
||||||
|
<p>Added sources</p>
|
||||||
|
<sl-tag
|
||||||
|
class="mr-1 mb-1"
|
||||||
|
v-for="(prefix, index) in policies"
|
||||||
|
size="small"
|
||||||
|
pill
|
||||||
|
removable
|
||||||
|
@sl-remove="removePolicy(index)"
|
||||||
|
>
|
||||||
|
{{ prefix }}
|
||||||
|
</sl-tag>
|
||||||
|
</div>
|
||||||
|
<sl-textarea
|
||||||
|
label="Public Key"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
help-text="Enter the RSA public key of the client."
|
||||||
|
ref="publicKeyField"
|
||||||
|
:value="publicKey"
|
||||||
|
@sl-input="publicKey = $event.target.value"
|
||||||
|
@blur="validatePublicKey"
|
||||||
|
></sl-textarea>
|
||||||
|
<br />
|
||||||
|
<sl-button type="submit" variant="primary">Create Client</sl-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { isIP } from 'is-ip'
|
||||||
|
import isCidr from 'is-cidr'
|
||||||
|
|
||||||
|
import '@shoelace-style/shoelace/dist/components/button/button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/input/input.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tag/tag.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/textarea/textarea.js'
|
||||||
|
import type { ClientCreate } from '@/client/types.gen'
|
||||||
|
import { SshecretAdmin } from '@/client/sdk.gen'
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const sourcePrefix = ref('')
|
||||||
|
const policies = ref(['0.0.0.0/0', '::/0'])
|
||||||
|
const publicKey = ref([])
|
||||||
|
|
||||||
|
const nameField = ref<HTMLSlInputElement>()
|
||||||
|
const sourceField = ref<HTMLSlInputElement>()
|
||||||
|
const publicKeyField = ref<HTMLSlInputElement>()
|
||||||
|
const clientCreateForm = ref<HTMLElement>()
|
||||||
|
|
||||||
|
function setFieldValidation(field: Ref<HTMLSlInputElement>, errorMessage: string = '') {
|
||||||
|
// Set validation on a field
|
||||||
|
field.value?.setCustomValidity(errorMessage)
|
||||||
|
field.value?.reportValidity()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPolicy() {
|
||||||
|
if (!sourcePrefix.value) {
|
||||||
|
setFieldValidation(sourceField)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isIP(sourcePrefix.value) && !isCidr(sourcePrefix.value)) {
|
||||||
|
setFieldValidation(sourceField, 'Invalid IP address or prefix')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldValidation(sourceField)
|
||||||
|
if (!policies.value.includes(sourcePrefix.value)) {
|
||||||
|
policies.value.push(sourcePrefix.value)
|
||||||
|
}
|
||||||
|
sourcePrefix.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePolicy(index: number) {
|
||||||
|
policies.value.splice(index, 1)
|
||||||
|
if (policies.value.length == 0) {
|
||||||
|
setFieldValidation(sourceField, 'Must have at least one source defined')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkName() {
|
||||||
|
nameField.value.reportValidity()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePublicKey() {
|
||||||
|
const pubkey = publicKey.value
|
||||||
|
const defaultError = 'Invalid public key. Must be a valid ssh-rsa key.'
|
||||||
|
if (!pubkey.startsWith('ssh-rsa ')) {
|
||||||
|
setFieldValidation(publicKeyField, defaultError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parts = publicKey.value.split(' ')
|
||||||
|
if (parts.length < 2) {
|
||||||
|
setFieldValidation(publicKeyField, defaultError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parts[1].length < 10) {
|
||||||
|
setFieldValidation(publicKeyField, 'SSH Key looks too short to be valid.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldValidation(publicKeyField, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (clientCreateForm.value?.checkValidity()) {
|
||||||
|
let clientDescription: string | null = null
|
||||||
|
if (description.value) {
|
||||||
|
clientDescription = description.value
|
||||||
|
}
|
||||||
|
const clientCreate: ClientCreate = {
|
||||||
|
name: name.value,
|
||||||
|
description: clientDescription,
|
||||||
|
public_key: publicKey.value.trim(),
|
||||||
|
sources: [...policies.value],
|
||||||
|
}
|
||||||
|
const response = await SshecretAdmin.createClientApiV1ClientsPost({ body: clientCreate })
|
||||||
|
console.log(response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.client-form sl-input,
|
||||||
|
.client-form sl-select,
|
||||||
|
.client-form sl-checkbox {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--sl-spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* user invalid styles */
|
||||||
|
.client-form sl-input[data-user-invalid]::part(base),
|
||||||
|
.client-form sl-select[data-user-invalid]::part(combobox),
|
||||||
|
.client-form sl-checkbox[data-user-invalid]::part(control) {
|
||||||
|
border-color: var(--sl-color-danger-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form [data-user-invalid]::part(form-control-label),
|
||||||
|
.client-form [data-user-invalid]::part(form-control-help-text),
|
||||||
|
.client-form sl-checkbox[data-user-invalid]::part(label) {
|
||||||
|
color: var(--sl-color-danger-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-checkbox[data-user-invalid]::part(control) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-input:focus-within[data-user-invalid]::part(base),
|
||||||
|
.client-form sl-select:focus-within[data-user-invalid]::part(combobox),
|
||||||
|
.client-form sl-checkbox:focus-within[data-user-invalid]::part(control) {
|
||||||
|
border-color: var(--sl-color-danger-600);
|
||||||
|
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User valid styles */
|
||||||
|
.client-form sl-input[data-user-valid]::part(base),
|
||||||
|
.client-form sl-select[data-user-valid]::part(combobox),
|
||||||
|
.client-form sl-checkbox[data-user-valid]::part(control) {
|
||||||
|
border-color: var(--sl-color-success-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form [data-user-valid]::part(form-control-label),
|
||||||
|
.client-form [data-user-valid]::part(form-control-help-text),
|
||||||
|
.client-form sl-checkbox[data-user-valid]::part(label) {
|
||||||
|
color: var(--sl-color-success-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-checkbox[data-user-valid]::part(control) {
|
||||||
|
background-color: var(--sl-color-success-600);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form sl-input:focus-within[data-user-valid]::part(base),
|
||||||
|
.client-form sl-select:focus-within[data-user-valid]::part(combobox),
|
||||||
|
.client-form sl-checkbox:focus-within[data-user-valid]::part(control) {
|
||||||
|
border-color: var(--sl-color-success-600);
|
||||||
|
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
class="iconify iconify--mdi"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
56
packages/sshecret-frontend/src/components/layout/Navbar.vue
Normal file
56
packages/sshecret-frontend/src/components/layout/Navbar.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<header class="h-14 bg-white border-b border-gray-200 flex items-center px-4 shadow-sm z-10">
|
||||||
|
<nav class="w-full">
|
||||||
|
<div class="flex flex-wrap items-center justify-between px-4 h-14 w-full">
|
||||||
|
<slot />
|
||||||
|
<!-- Left: Logo -->
|
||||||
|
<a href="/" class="flex items-center space-x-2 rtl:space-x-reverse">
|
||||||
|
<img src="@/assets/logo.svg" class="h-6" alt="Sshecret Logo" />
|
||||||
|
<span class="self-center text-xl font-semibold whitespace-nowrap">Sshecret</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Right: Avatar -->
|
||||||
|
<div class="relative">
|
||||||
|
<sl-dropdown>
|
||||||
|
<sl-button variant="default" size="small" circle slot="trigger">
|
||||||
|
<sl-avatar label="User avatar"></sl-avatar>
|
||||||
|
</sl-button>
|
||||||
|
<sl-menu-item>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||||
|
role="menuitem"
|
||||||
|
>Change Password</a
|
||||||
|
>
|
||||||
|
</sl-menu-item>
|
||||||
|
<sl-menu-item>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||||
|
role="menuitem"
|
||||||
|
@click="logout"
|
||||||
|
>Logout</a
|
||||||
|
>
|
||||||
|
</sl-menu-item>
|
||||||
|
</sl-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/button/button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/avatar/avatar.js'
|
||||||
|
|
||||||
|
import { useAuthTokenStore } from '@/store/auth'
|
||||||
|
const auth = useAuthTokenStore()
|
||||||
|
const router = useRouter()
|
||||||
|
function logout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<sl-select
|
||||||
|
label="Select clients"
|
||||||
|
:value="selectedClients"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
@sl-change="selectedClients = $event.target.value"
|
||||||
|
id="selectClientDialog"
|
||||||
|
>
|
||||||
|
<template v-for="client in clients">
|
||||||
|
<sl-option :value="client.id">{{ client.name }}</sl-option>
|
||||||
|
</template>
|
||||||
|
</sl-select>
|
||||||
|
</div>
|
||||||
|
<div slot="footer">
|
||||||
|
<sl-button
|
||||||
|
size="medium"
|
||||||
|
variant="success"
|
||||||
|
outline
|
||||||
|
@click="submitAdd"
|
||||||
|
:disabled="selectedClients.length === 0"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<sl-icon slot="prefix" name="person-plus"></sl-icon>
|
||||||
|
Submit
|
||||||
|
</sl-button>
|
||||||
|
<sl-button size="medium" outline @click="cancelAdd">Cancel</sl-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
import '@shoelace-style/shoelace/dist/components/button/button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/option/option.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/select/select.js'
|
||||||
|
import { SshecretAdmin } from '@/client'
|
||||||
|
import type { ClientReference } from '@/client'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addClients', data: string[]): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const clients = ref<ClientReference[]>([])
|
||||||
|
const selectedClients = ref<string[]>([])
|
||||||
|
|
||||||
|
async function getClients() {
|
||||||
|
// Get just names and IDs of the clients
|
||||||
|
const response = await SshecretAdmin.getClientsTerseApiV1ClientsTerseGet()
|
||||||
|
if (response.data) {
|
||||||
|
clients.value = response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAdd() {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAdd() {
|
||||||
|
emit('addClients', selectedClients.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getClients)
|
||||||
|
</script>
|
||||||
@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-end px-4">
|
||||||
|
<button
|
||||||
|
id="secret-menu-button"
|
||||||
|
class="inline-block text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-1.5"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open dropdown</span>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 3"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<sl-tab-group>
|
||||||
|
<sl-tab slot="nav" panel="secret_data">Secret Data</sl-tab>
|
||||||
|
<sl-tab slot="nav" panel="events">Events</sl-tab>
|
||||||
|
|
||||||
|
<sl-tab-panel name="secret_data">
|
||||||
|
<div id="secret_details">
|
||||||
|
<div class="w-full p-2">
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h3 class="text-base/7 font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{{ secret.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="mt-1 max-w-2xl text-sm/6 text-gray-500 dark:text-gray-100"
|
||||||
|
v-if="secret?.description"
|
||||||
|
>
|
||||||
|
{{ secret?.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 border-t border-gray-100" v-if="secret">
|
||||||
|
<dl class="divide-y divide-gray-100">
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
Clients with this secret
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">
|
||||||
|
<div v-if="clients">
|
||||||
|
<sl-tag
|
||||||
|
class="mr-2"
|
||||||
|
removable
|
||||||
|
v-for="client in clients"
|
||||||
|
:key="client.id"
|
||||||
|
@sl-remove="removeClient(client.id)"
|
||||||
|
>
|
||||||
|
{{ client.name }}
|
||||||
|
</sl-tag>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 float-right">
|
||||||
|
<sl-button size="medium" variant="success" outline @click="addDialog = true">
|
||||||
|
<sl-icon slot="prefix" name="person-plus"></sl-icon>
|
||||||
|
Add to client
|
||||||
|
</sl-button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||||
|
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Secret Value</dt>
|
||||||
|
<dd
|
||||||
|
class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300"
|
||||||
|
v-if="secret.secret"
|
||||||
|
>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<div class="relative w-full">
|
||||||
|
<sl-input
|
||||||
|
type="password"
|
||||||
|
placeholder="Secret Value"
|
||||||
|
:value="secretValue"
|
||||||
|
class="secretValueInput"
|
||||||
|
@input="secretValue = $event.target.value"
|
||||||
|
password-toggle
|
||||||
|
></sl-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-2.5 mb-2">
|
||||||
|
<sl-button variant="warning" outline :disabled="!secretChanged"
|
||||||
|
>Update</sl-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
<dd
|
||||||
|
class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300"
|
||||||
|
v-else
|
||||||
|
>
|
||||||
|
Secret is not managed, and can only decrypted by the client
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t border-gray-100" v-else>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
class="w-full p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sl-tab-panel>
|
||||||
|
<sl-tab-panel name="events"> </sl-tab-panel>
|
||||||
|
</sl-tab-group>
|
||||||
|
<sl-drawer
|
||||||
|
id="addDialogDrawer"
|
||||||
|
:open="addDialog"
|
||||||
|
label="Add secret to clients"
|
||||||
|
@sl-hide="handleHide"
|
||||||
|
>
|
||||||
|
<AddSecretToClients
|
||||||
|
@addClients="addSecretToClients"
|
||||||
|
@cancel="addDialog = false"
|
||||||
|
v-if="addDialog"
|
||||||
|
/>
|
||||||
|
</sl-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/button/button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/details/details.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/drawer/drawer.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/dialog/dialog.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/input/input.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tag/tag.js'
|
||||||
|
|
||||||
|
import type { SecretView } from '@/client/types.gen.ts'
|
||||||
|
|
||||||
|
import AddSecretToClients from '@/components/secrets/AddSecretToClients.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ secret: SecretView }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', data: string): void
|
||||||
|
(e: 'delete'): void
|
||||||
|
(e: 'addClient', data: string): void
|
||||||
|
(e: 'removeClient', data: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const secret = ref<Secret>(props.secret)
|
||||||
|
|
||||||
|
const clients = computed(() => props.secret?.clients ?? [])
|
||||||
|
|
||||||
|
const secretValue = ref<string | null>({ ...props.secret?.secret })
|
||||||
|
|
||||||
|
const addDialog = ref<boolean>(false)
|
||||||
|
|
||||||
|
const secretChanged = computed(() => {
|
||||||
|
if (!secretValue.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return secretValue.value !== secret.value.secret
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleHide(event) {
|
||||||
|
const targetId = event.target.id
|
||||||
|
if (targetId === 'addDialogDrawer') {
|
||||||
|
addDialog.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSecret() {
|
||||||
|
emit('update', secretValue.value)
|
||||||
|
}
|
||||||
|
function addSecretToClients(clientIds: string[]) {
|
||||||
|
addDialog.value = false
|
||||||
|
clientIds.forEach((clientId) => emit('addClient', clientId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClient(clientId: string) {
|
||||||
|
emit('removeClient', clientId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
class="w-full p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||||
|
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
23
packages/sshecret-frontend/src/composables/useDebounce.ts
Normal file
23
packages/sshecret-frontend/src/composables/useDebounce.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ref, watch, type Ref, type WatchSource, type UnwrapRef } from 'vue'
|
||||||
|
|
||||||
|
export function useDebounce<T>(
|
||||||
|
source: WatchSource<T>,
|
||||||
|
delay = 300
|
||||||
|
): Ref<UnwrapRef<T>> {
|
||||||
|
const debounced = ref<T>() as Ref<UnwrapRef<T>>
|
||||||
|
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
watch(
|
||||||
|
source,
|
||||||
|
(newValue) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
debounced.value = newValue as UnwrapRef<T>
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return debounced
|
||||||
|
}
|
||||||
46
packages/sshecret-frontend/src/composables/usePagination.ts
Normal file
46
packages/sshecret-frontend/src/composables/usePagination.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
|
||||||
|
export function usePagination(totalItems: number | Ref<number>, itemsPerPage = 20) {
|
||||||
|
const pageNum = ref(1)
|
||||||
|
|
||||||
|
const total = computed(() => {
|
||||||
|
return typeof totalItems === 'number' ? totalItems : totalItems.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const offset = computed(() => (pageNum.value - 1) * itemsPerPage)
|
||||||
|
const firstResult = computed(() => (total.value === 0 ? 0 : offset.value + 1))
|
||||||
|
const lastResult = computed(() => {
|
||||||
|
const last = offset.value + itemsPerPage
|
||||||
|
return last > total.value ? total.value : last
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage))
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page < 1) pageNum.value = 1
|
||||||
|
else if (page > totalPages.value) pageNum.value = totalPages.value
|
||||||
|
else pageNum.value = page
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
goToPage(pageNum.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
goToPage(pageNum.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageNum,
|
||||||
|
offset,
|
||||||
|
firstResult,
|
||||||
|
lastResult,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
goToPage,
|
||||||
|
nextPage,
|
||||||
|
prevPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/sshecret-frontend/src/env.d.ts
vendored
Normal file
9
packages/sshecret-frontend/src/env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly SSHECRET_FRONTEND_API_BASE_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
34
packages/sshecret-frontend/src/main.ts
Normal file
34
packages/sshecret-frontend/src/main.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
// Added for shoelace.
|
||||||
|
// See note on CDB usage: https://shoelace.style/frameworks/vue
|
||||||
|
import '@shoelace-style/shoelace/dist/themes/light.css'
|
||||||
|
import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path'
|
||||||
|
|
||||||
|
setBasePath('https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/')
|
||||||
|
import ShoelaceModelDirective from '@shoelace-style/vue-sl-model'
|
||||||
|
|
||||||
|
// End of shoelace import
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import '@/api/interceptors'
|
||||||
|
import { useAuthTokenStore } from '@/store/auth'
|
||||||
|
import { client } from '@/client/client.gen'
|
||||||
|
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.SSHECRET_FRONTEND_API_BASE_URL
|
||||||
|
|
||||||
|
client.setConfig({ baseURL })
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ShoelaceModelDirective)
|
||||||
|
|
||||||
|
useAuthTokenStore().loadFromStorage()
|
||||||
|
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
35
packages/sshecret-frontend/src/router/index.ts
Normal file
35
packages/sshecret-frontend/src/router/index.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import LoginPage from '@/views/LoginPage.vue'
|
||||||
|
import WorkspaceView from '@/views/WorkspaceView.vue'
|
||||||
|
import { useAuthTokenStore } from '@/store/auth'
|
||||||
|
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'clientList',
|
||||||
|
component: WorkspaceView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _, next) => {
|
||||||
|
const auth = useAuthTokenStore()
|
||||||
|
if (!auth.isLoggedIn && to.meta.requiresAuth) {
|
||||||
|
next({ name: 'login' })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// const router = createRouter({
|
||||||
|
// history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
// })
|
||||||
|
|
||||||
|
export default router
|
||||||
78
packages/sshecret-frontend/src/store/auth.ts
Normal file
78
packages/sshecret-frontend/src/store/auth.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import { client } from '@/client/client.gen'
|
||||||
|
import { SshecretAdmin } from '@/client'
|
||||||
|
import type { Token } from '@/client'
|
||||||
|
|
||||||
|
export function setAuthToken(token: string | null) {
|
||||||
|
client.setConfig({
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthTokenStore = defineStore('authtoken', {
|
||||||
|
state: () => ({
|
||||||
|
accessToken: '' as string,
|
||||||
|
refreshToken: '' as string,
|
||||||
|
isLoggedIn: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async login(username: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await SshecretAdmin.loginForAccessTokenApiV1TokenPost({ body: { username, password } })
|
||||||
|
const tokenData: Token = response.data
|
||||||
|
const accessToken = tokenData.access_token
|
||||||
|
const refreshToken = tokenData.refresh_token
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.isLoggedIn = true
|
||||||
|
localStorage.setItem('accessToken', accessToken)
|
||||||
|
localStorage.setItem('refreshToken', refreshToken)
|
||||||
|
setAuthToken(this.accessToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refresh(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log("Refreshing token")
|
||||||
|
const response = await SshecretAdmin.refreshTokenApiV1RefreshPost({ body: { grant_type: "refresh_token", refresh_token: this.refreshToken } })
|
||||||
|
const tokenData: Token = response.data
|
||||||
|
const accessToken = tokenData.access_token
|
||||||
|
const refreshToken = tokenData.refresh_token
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
localStorage.setItem('accessToken', accessToken)
|
||||||
|
localStorage.setItem('refreshToken', refreshToken)
|
||||||
|
setAuthToken(this.accessToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadFromStorage() {
|
||||||
|
// Load token from user storage.
|
||||||
|
const accessToken = localStorage.getItem('accessToken')
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken')
|
||||||
|
if (accessToken && refreshToken) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.isLoggedIn = true
|
||||||
|
setAuthToken(accessToken)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
this.accessToken = ''
|
||||||
|
this.refreshToken = ''
|
||||||
|
this.isLoggedIn = false
|
||||||
|
localStorage.removeItem('accessToken')
|
||||||
|
localStorage.removeItem('refreshToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
138
packages/sshecret-frontend/src/store/useTreeState.ts
Normal file
138
packages/sshecret-frontend/src/store/useTreeState.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { SshecretObjectType } from '@/api/types'
|
||||||
|
import type { SshecretObject } from '@/api/types'
|
||||||
|
import { SshecretAdmin } from '@/client'
|
||||||
|
import type { ClientQueryResult, Client, SecretView } from '@/client'
|
||||||
|
|
||||||
|
export const useTreeState = defineStore('treeState', {
|
||||||
|
state: () => ({
|
||||||
|
selected: null as SshecretObject | null,
|
||||||
|
parent: null as SshecretObject | null,
|
||||||
|
clients: null as ClientQueryResult | null,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
selectClient(id: string) {
|
||||||
|
this.selected = { objectType: SshecretObjectType.Client, id: id }
|
||||||
|
},
|
||||||
|
selectSecret(name: string, parent?: string) {
|
||||||
|
this.selected = { objectType: SshecretObjectType.ClientSecret, id: name }
|
||||||
|
if (parent) {
|
||||||
|
this.parent = { objectType: SshecretObjectType.Client, id: parent }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unselect() {
|
||||||
|
this.selected = null
|
||||||
|
},
|
||||||
|
async loadClients(offset: number = 0, limit: number = 100): Promise<number> {
|
||||||
|
const response = await SshecretAdmin.queryClientsApiV1QueryClientsGet({
|
||||||
|
query: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.data) {
|
||||||
|
this.clients = response.data
|
||||||
|
return response.data.total_results
|
||||||
|
}
|
||||||
|
this.clients = null
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
async queryClients(query: string, offset: number = 0, limit: number = 100): Promise<number> {
|
||||||
|
// Query or search. Result is the number of hits.
|
||||||
|
const response = await SshecretAdmin.queryClientsApiV1QueryClientsGet({
|
||||||
|
query: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
name__like: `%${query}%`,
|
||||||
|
order_by: "name",
|
||||||
|
order_reverse: false,
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.data) {
|
||||||
|
this.clients = response.data
|
||||||
|
return response.data.total_results
|
||||||
|
}
|
||||||
|
this.clients = null
|
||||||
|
return 0
|
||||||
|
|
||||||
|
},
|
||||||
|
async getClient(id: string | null = null): Promise<Client> {
|
||||||
|
if (!id && this.selected?.objectType === SshecretObjectType.Client) {
|
||||||
|
id = this.selected.id
|
||||||
|
} else if (!id) {
|
||||||
|
throw "No client selected"
|
||||||
|
}
|
||||||
|
if (!this.clients) {
|
||||||
|
await this.loadClients()
|
||||||
|
}
|
||||||
|
if (this.clients) {
|
||||||
|
const existing = this.clients.clients.find(c => c.id === id)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await SshecretAdmin.getClientApiV1ClientsIdGet({ path: { id: id } })
|
||||||
|
if (response.data) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
throw "Client not found"
|
||||||
|
},
|
||||||
|
async getSecret(name: string): Promise<SecretView> {
|
||||||
|
const response = await SshecretAdmin.getSecretApiV1SecretsNameGet({ path: { name: name } })
|
||||||
|
if (response.data) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
throw "Secret not found"
|
||||||
|
},
|
||||||
|
async getSelected(): Promise<Client | SecretView | null> {
|
||||||
|
if (!this.selected) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (this.selected.objectType === SshecretObjectType.Client) {
|
||||||
|
return await this.getClient(this.selected.id)
|
||||||
|
} else if (this.selected.objectType === SshecretObjectType.ClientSecret) {
|
||||||
|
return await this.getSecret(this.selected.id)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw "Invalid object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshClient(id: string): Promise<boolean> {
|
||||||
|
// Refresh a client
|
||||||
|
if (this.clients) {
|
||||||
|
const clientIndex = this.clients.clients.findIndex(c => c.id === id)
|
||||||
|
if (clientIndex >= 0) {
|
||||||
|
console.log("found client at index: ", clientIndex, this.clients.clients[clientIndex])
|
||||||
|
const response = await SshecretAdmin.getClientApiV1ClientsIdGet({ path: { id: id } })
|
||||||
|
if (response.data) {
|
||||||
|
const newClient = response.data
|
||||||
|
this.clients.clients = this.clients.clients.map(c => c.id === id ? newClient : c)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
clientSelected() {
|
||||||
|
if (!this.selected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this.selected.objectType === SshecretObjectType.Client) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
secretSelected() {
|
||||||
|
if (!this.selected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this.selected.objectType === SshecretObjectType.ClientSecret) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
70
packages/sshecret-frontend/src/views/LoginPage.vue
Normal file
70
packages/sshecret-frontend/src/views/LoginPage.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||||
|
<div v-if="error" class="w-screen absolute top-0 left-0 z-50">
|
||||||
|
<sl-alert variant="danger" open v-if="error">
|
||||||
|
<sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
|
||||||
|
<strong>Login failed.</strong><br />
|
||||||
|
Please check your username and password, and try again.
|
||||||
|
</sl-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-md w-full bg-white rounded-xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Sign In</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||||
|
placeholder="Username"
|
||||||
|
autocomplete="username"
|
||||||
|
required=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAuthTokenStore } from '@/store/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/alert/alert.js'
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
const auth = useAuthTokenStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function submitLogin() {
|
||||||
|
error.value = false
|
||||||
|
const success = await auth.login(username.value, password.value)
|
||||||
|
if (success) {
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
30
packages/sshecret-frontend/src/views/WorkspaceView.vue
Normal file
30
packages/sshecret-frontend/src/views/WorkspaceView.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<MasterDetail>
|
||||||
|
<template #master>
|
||||||
|
<ClientTreeList />
|
||||||
|
</template>
|
||||||
|
<template #detail v-if="treeState.selected">
|
||||||
|
<ClientDetailView
|
||||||
|
v-if="treeState.selected.objectType === SshecretObjectType.Client"
|
||||||
|
:clientId="treeState.selected.id"
|
||||||
|
:key="treeState.selected.id"
|
||||||
|
/>
|
||||||
|
<SecretDetailView
|
||||||
|
v-else-if="treeState.selected.objectType === SshecretObjectType.ClientSecret"
|
||||||
|
:secretName="treeState.selected.id"
|
||||||
|
:parentId="null"
|
||||||
|
:key="treeState.selected.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MasterDetail>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import MasterDetail from '@/views/layout/MasterDetail.vue'
|
||||||
|
import ClientTreeList from '@/views/Clients/ClientTreeList.vue'
|
||||||
|
import ClientDetailView from '@/views/clients/ClientDetailView.vue'
|
||||||
|
import SecretDetailView from '@/views/secrets/SecretDetailView.vue'
|
||||||
|
import { SshecretObjectType } from '@/api/types'
|
||||||
|
import { useTreeState } from '@/store/useTreeState'
|
||||||
|
|
||||||
|
const treeState = useTreeState()
|
||||||
|
</script>
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<ClientDetail :client="client" @update="updateClient" @deleted="deleteClient" v-if="client" />
|
||||||
|
<ClientSkeleton v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import ClientSkeleton from '@/components/clients/ClientSkeleton.vue'
|
||||||
|
import ClientDetail from '@/components/clients/ClientDetail.vue'
|
||||||
|
import type { ClientCreate } from '@/client'
|
||||||
|
import { SshecretAdmin } from '@/client'
|
||||||
|
import { useTreeState } from '@/store/useTreeState'
|
||||||
|
|
||||||
|
const props = defineProps<{ clientId: string | null }>()
|
||||||
|
|
||||||
|
const client = ref<Client>()
|
||||||
|
|
||||||
|
const treeState = useTreeState()
|
||||||
|
|
||||||
|
async function loadClient() {
|
||||||
|
if (!props.clientId) return
|
||||||
|
client.value = await treeState.getClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient(clientId: string) {
|
||||||
|
console.log(`Delete ${localClient.value.id}`)
|
||||||
|
const response = await SshecretAdmin.deleteClientApiV1ClientsIdDelete({
|
||||||
|
path: { id: clientId },
|
||||||
|
})
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.error(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('clientDeleted', clientId)
|
||||||
|
props.clientId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateClient(updated: ClientCreate) {
|
||||||
|
const response = await SshecretAdmin.updateClientApiV1ClientsIdPut({
|
||||||
|
path: { id: localClient.value.id },
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
client.value = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadClient)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.client_id,
|
||||||
|
() => loadClient(),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
28
packages/sshecret-frontend/src/views/clients/ClientList.vue
Normal file
28
packages/sshecret-frontend/src/views/clients/ClientList.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<h1 class="text-2xl mb-4">Clients</h1>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li v-for="client in clients" :key="client.id">
|
||||||
|
<strong>{{ client.name }}</strong>
|
||||||
|
<span v-if="client.description">({{ client.description }})</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree/tree.js'
|
||||||
|
|
||||||
|
import { SshecretAdmin } from '@/client/sdk.gen'
|
||||||
|
|
||||||
|
import MasterDetail from '@/components/layout/MasterDetail.vue'
|
||||||
|
|
||||||
|
const clients = ref<any[]>([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const response = await SshecretAdmin.getClientsApiV1ClientsGet()
|
||||||
|
clients.value = response.data
|
||||||
|
})
|
||||||
|
</script>
|
||||||
278
packages/sshecret-frontend/src/views/clients/ClientTreeList.vue
Normal file
278
packages/sshecret-frontend/src/views/clients/ClientTreeList.vue
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full min-h-0">
|
||||||
|
<div class="tree-header mb-2 grid grid-cols-2 place-content-between">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Client List</h1>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex w-full justify-end">
|
||||||
|
<sl-icon-button
|
||||||
|
name="plus-square"
|
||||||
|
label="Add Client"
|
||||||
|
@click="createDrawerOpen = !createDrawerOpen"
|
||||||
|
></sl-icon-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-full">
|
||||||
|
<label
|
||||||
|
for="client-search"
|
||||||
|
name="client-search"
|
||||||
|
class="mb-2 text-xs font-medium text-gray-900 sr-only dark:text-white"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<sl-input
|
||||||
|
type="search"
|
||||||
|
size="small"
|
||||||
|
placeholder="search"
|
||||||
|
autocomplete="off"
|
||||||
|
clearable
|
||||||
|
:value="clientQuery"
|
||||||
|
@input="clientQuery = $event.target.value"
|
||||||
|
@sl-clear="clearSearch"
|
||||||
|
>
|
||||||
|
<sl-icon name="search" slot="prefix"></sl-icon>
|
||||||
|
</sl-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col h-full min-h-0">
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<sl-tree class="w-full" @sl-selection-change="itemSelected" v-if="treeState.clients">
|
||||||
|
<template v-for="client in treeState.clients.clients">
|
||||||
|
<ClientTreeItem :id="client.id" :name="client.name" :selected="false">
|
||||||
|
<template v-for="secret in client.secrets">
|
||||||
|
<ClientSecretTreeItem :parentId="client.id" :name="secret" />
|
||||||
|
</template>
|
||||||
|
</ClientTreeItem>
|
||||||
|
</template>
|
||||||
|
</sl-tree>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="shrink-0 mt-4 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800"
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
>
|
||||||
|
<div class="mt-4 text-center flex items-center flex-col">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
Showing
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ firstResult }}</span>
|
||||||
|
to
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ lastResult }}</span>
|
||||||
|
of
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ totalClients }}</span>
|
||||||
|
Entries
|
||||||
|
</span>
|
||||||
|
<div class="inline-flex mt-2 xs:mt-0">
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="flex items-center -space-x-px h-8 text-sm">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
:disabled="pageNum <= 1"
|
||||||
|
@click="prevPage"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Previous</span>
|
||||||
|
<svg
|
||||||
|
class="w-2.5 h-2.5 rtl:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 6 10"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 1 1 5l4 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-for="n in totalPages">
|
||||||
|
<button
|
||||||
|
class="z-10 flex items-center justify-center px-3 h-8 leading-tight text-blue-600 border border-blue-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white"
|
||||||
|
disabled
|
||||||
|
v-if="n === pageNum"
|
||||||
|
>
|
||||||
|
{{ n }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
@click="pageNum = n"
|
||||||
|
v-else
|
||||||
|
>
|
||||||
|
{{ n }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="pageNum >= totalPages"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Next</span>
|
||||||
|
<svg
|
||||||
|
class="w-2.5 h-2.5 rtl:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 6 10"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m1 9 4-4-4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<sl-drawer label="Create Client" :open="createDrawerOpen" @sl-hide="createDrawerOpen = false">
|
||||||
|
<ClientForm @submit="createClient" @cancel="createDrawerOpen = false" :key="createFormKey" />
|
||||||
|
</sl-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree/tree.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/input/input.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/drawer/drawer.js'
|
||||||
|
|
||||||
|
import { usePagination } from '@/composables/usePagination'
|
||||||
|
|
||||||
|
import { SshecretAdmin } from '@/client/sdk.gen'
|
||||||
|
|
||||||
|
import type { Client, ClientCreate } from '@/client/types.gen'
|
||||||
|
|
||||||
|
import { useTreeState } from '@/store/useTreeState'
|
||||||
|
|
||||||
|
import MasterDetail from '@/components/layout/MasterDetail.vue'
|
||||||
|
import ClientTreeItem from '@/components/clients/ClientTreeItem.vue'
|
||||||
|
import ClientSecretTreeItem from '@/components/clients/ClientSecretTreeItem.vue'
|
||||||
|
import ClientForm from '@/components/clients/ClientForm.vue'
|
||||||
|
|
||||||
|
import { useDebounce } from '@/composables/useDebounce'
|
||||||
|
const treeState = useTreeState()
|
||||||
|
|
||||||
|
const clientsPerPage = 20
|
||||||
|
const totalClients = computed(() => treeState.clients?.total_clients)
|
||||||
|
|
||||||
|
const clients = computed(() => treeState.clients.clients)
|
||||||
|
const selectedClient = ref<Client | null>(null)
|
||||||
|
const selectedSecret = ref<string | null>(null)
|
||||||
|
|
||||||
|
const createFormKey = ref<number>(0)
|
||||||
|
const createDrawerOpen = ref<boolean>(false)
|
||||||
|
|
||||||
|
const clientQuery = ref('')
|
||||||
|
|
||||||
|
const debouncedQuery = useDebounce(clientQuery, 300)
|
||||||
|
|
||||||
|
const { pageNum, offset, firstResult, lastResult, totalPages, nextPage, prevPage } = usePagination(
|
||||||
|
totalClients,
|
||||||
|
clientsPerPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
if (clientQuery.value) {
|
||||||
|
console.log('Search term: ', clientQuery.value)
|
||||||
|
await treeState.queryClients(clientQuery.value, offset.value, clientsPerPage)
|
||||||
|
console.log(`Got ${fetchedClients} results`)
|
||||||
|
} else {
|
||||||
|
await treeState.loadClients(offset.value, clientsPerPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClient(updated: Client) {
|
||||||
|
const index = clients.value.findIndex((c) => c.name === updated.name)
|
||||||
|
console.log(`UpdateClient fired: ${updated.name} => ${index}`)
|
||||||
|
if (index >= 0) {
|
||||||
|
clients.value[index] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemSelected(event: Event) {
|
||||||
|
if (event.detail.selection.length == 0) {
|
||||||
|
treeState.unselect()
|
||||||
|
} else {
|
||||||
|
const el = event.detail.selection[0] as HTMLElement
|
||||||
|
const childType = el.dataset.type
|
||||||
|
if (childType == 'client') {
|
||||||
|
const clientId = el.dataset.clientId
|
||||||
|
treeState.selectClient(clientId)
|
||||||
|
} else if (childType == 'secret') {
|
||||||
|
const secretName = el.dataset.name
|
||||||
|
const parentId = el.dataset.parentId
|
||||||
|
treeState.selectSecret(secretName, parentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient(data: ClientCreate) {
|
||||||
|
const response = await SshecretAdmin.createClientApiV1ClientsPost({ body: data })
|
||||||
|
console.log(response.data)
|
||||||
|
clients.value.unshift(response.data)
|
||||||
|
totalClients.value += 1
|
||||||
|
createDrawerOpen.value = false
|
||||||
|
createFormKey.value += 1
|
||||||
|
treeState.value.selected = true
|
||||||
|
treeState.value.item_type = 'secret'
|
||||||
|
treeState.value.client = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clientDeleted(id: string) {
|
||||||
|
const index = clients.value.findIndex((c) => c.id === id)
|
||||||
|
console.log(`Client Deleted event received: ID: ${id} => ${index}`)
|
||||||
|
if (index >= 0) {
|
||||||
|
clients.value.splice(index, 1)
|
||||||
|
treeState.value.selected = false
|
||||||
|
treeState.value.item_type = null
|
||||||
|
treeState.value.client = null
|
||||||
|
await loadClients()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearchEvent() {
|
||||||
|
if (pageNum.value != 1) {
|
||||||
|
pageNum.value = 1
|
||||||
|
} else {
|
||||||
|
await loadClients()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSearch() {
|
||||||
|
clientQuery.value = ''
|
||||||
|
await handleSearchEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch the search query
|
||||||
|
watch(debouncedQuery, async () => {
|
||||||
|
console.log('Handling search event.')
|
||||||
|
await handleSearchEvent()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadClients)
|
||||||
|
watch([offset, pageNum], loadClients)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
sl-input::part(prefix) {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
sl-input::part(base) {
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<MasterDetail>
|
||||||
|
<template #master>
|
||||||
|
<div class="flex flex-col h-full min-h-0">
|
||||||
|
<div class="tree-header mb-2 grid grid-cols-2 place-content-between">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Client List</h1>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex w-full justify-end">
|
||||||
|
<sl-icon-button
|
||||||
|
name="plus-square"
|
||||||
|
label="Add Client"
|
||||||
|
@click="createDrawerOpen = !createDrawerOpen"
|
||||||
|
></sl-icon-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-full">
|
||||||
|
<label
|
||||||
|
for="client-search"
|
||||||
|
name="client-search"
|
||||||
|
class="mb-2 text-xs font-medium text-gray-900 sr-only dark:text-white"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<sl-input type="search" size="small" placeholder="search" clearable>
|
||||||
|
<template v-slot:prefix>
|
||||||
|
<sl-icon name="search" ></sl-icon>
|
||||||
|
</template>
|
||||||
|
</sl-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col h-full min-h-0">
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<sl-tree class="w-full" @sl-selection-change="itemSelected">
|
||||||
|
<template v-for="client in clients">
|
||||||
|
<ClientTreeItem :id="client.id" :name="client.name" :selected="false">
|
||||||
|
<template v-for="secret in client.secrets">
|
||||||
|
<ClientSecretTreeItem :parent_id="client.id" :name="secret" />
|
||||||
|
</template>
|
||||||
|
</ClientTreeItem>
|
||||||
|
</template>
|
||||||
|
</sl-tree>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="shrink-0 mt-4 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800"
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
>
|
||||||
|
<div class="mt-4 text-center flex items-center flex-col">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
Showing
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ firstResult }}</span>
|
||||||
|
to
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ lastResult }}</span>
|
||||||
|
of
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ totalClients }}</span>
|
||||||
|
Entries
|
||||||
|
</span>
|
||||||
|
<div class="inline-flex mt-2 xs:mt-0">
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="flex items-center -space-x-px h-8 text-sm">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
:disabled="pageNum <= 1"
|
||||||
|
@click="prevPage"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Previous</span>
|
||||||
|
<svg
|
||||||
|
class="w-2.5 h-2.5 rtl:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 6 10"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 1 1 5l4 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-for="n in totalPages">
|
||||||
|
<button
|
||||||
|
class="z-10 flex items-center justify-center px-3 h-8 leading-tight text-blue-600 border border-blue-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white"
|
||||||
|
disabled
|
||||||
|
v-if="n === pageNum"
|
||||||
|
>
|
||||||
|
{{ n }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
@click="pageNum = n"
|
||||||
|
v-else
|
||||||
|
>
|
||||||
|
{{ n }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="pageNum >= totalPages"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Next</span>
|
||||||
|
<svg
|
||||||
|
class="w-2.5 h-2.5 rtl:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 6 10"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m1 9 4-4-4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #detail v-if="treeState.selected">
|
||||||
|
<ClientDetail
|
||||||
|
:client="treeState.client"
|
||||||
|
:key="treeState.client.id"
|
||||||
|
v-if="treeState.item_type == 'client'"
|
||||||
|
@update="updateClient"
|
||||||
|
@deleted="clientDeleted"
|
||||||
|
/>
|
||||||
|
<SecretDetail
|
||||||
|
:secret_name="treeState.secret_name"
|
||||||
|
:key="treeState.secret_name"
|
||||||
|
v-if="treeState.item_type == 'secret'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MasterDetail>
|
||||||
|
<sl-drawer label="Create Client" :open="createDrawerOpen" @sl-hide="createDrawerOpen = false">
|
||||||
|
<ClientForm @submit="createClient" @cancel="createDrawerOpen = false" :key="createFormKey" />
|
||||||
|
</sl-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree-item/tree-item.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/tree/tree.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/input/input.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/drawer/drawer.js'
|
||||||
|
|
||||||
|
import { usePagination } from '@/composables/usePagination'
|
||||||
|
|
||||||
|
import { SshecretAdmin } from '@/client/sdk.gen'
|
||||||
|
|
||||||
|
import type { Client, ClientCreate } from '@/client/types.gen'
|
||||||
|
|
||||||
|
import { useTreeState } from '@/store/useTreeState'
|
||||||
|
|
||||||
|
import MasterDetail from '@/components/layout/MasterDetail.vue'
|
||||||
|
import ClientTreeItem from '@/components/clients/ClientTreeItem.vue'
|
||||||
|
import ClientSecretTreeItem from '@/components/clients/ClientSecretTreeItem.vue'
|
||||||
|
import ClientDetail from '@/components/clients/ClientDetail.vue'
|
||||||
|
import SecretDetail from '@/components/secrets/SecretDetail.vue'
|
||||||
|
import ClientForm from '@/components/clients/ClientForm.vue'
|
||||||
|
|
||||||
|
const clientsPerPage = 20
|
||||||
|
const totalClients = ref<number>(0)
|
||||||
|
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
|
interface TreeState {
|
||||||
|
selected: boolean
|
||||||
|
item_type?: string
|
||||||
|
secret_name?: string
|
||||||
|
client?: Client
|
||||||
|
}
|
||||||
|
const treeState = ref<TreeState>({ selected: false })
|
||||||
|
const selectedClient = ref<Client | null>(null)
|
||||||
|
const selectedSecret = ref<string | null>(null)
|
||||||
|
|
||||||
|
const createFormKey = ref<number>(0)
|
||||||
|
const createDrawerOpen = ref<boolean>(false)
|
||||||
|
|
||||||
|
const { pageNum, offset, firstResult, lastResult, totalPages, nextPage, prevPage } = usePagination(
|
||||||
|
totalClients,
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
const response = await SshecretAdmin.queryClientsApiV1QueryClientsGet({
|
||||||
|
query: {
|
||||||
|
offset: offset.value,
|
||||||
|
limit: clientsPerPage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
clients.value = response.data.clients
|
||||||
|
totalClients.value = response.data.total_results
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClient(updated: Client) {
|
||||||
|
const index = clients.value.findIndex((c) => c.name === updated.name)
|
||||||
|
console.log(`UpdateClient fired: ${updated.name} => ${index}`)
|
||||||
|
if (index >= 0) {
|
||||||
|
clients.value[index] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemSelected(event: Event) {
|
||||||
|
if (event.detail.selection.length == 0) {
|
||||||
|
treeState.value.selected = false
|
||||||
|
treeState.value.client = null
|
||||||
|
treeState.value.secret_name = null
|
||||||
|
treeState.value.item_type = null
|
||||||
|
} else {
|
||||||
|
console.log('Something else was selected')
|
||||||
|
treeState.value.selected = true
|
||||||
|
|
||||||
|
const el = event.detail.selection[0] as HTMLElement
|
||||||
|
const childType = el.dataset.type
|
||||||
|
if (childType == 'client') {
|
||||||
|
const clientId = el.dataset.clientId
|
||||||
|
console.log(`Selected client ${clientId}`)
|
||||||
|
const targetClient = clients.value.find((client) => client.id === clientId)
|
||||||
|
treeState.value.item_type = 'client'
|
||||||
|
treeState.value.client = targetClient
|
||||||
|
treeState.value.secret_name = null
|
||||||
|
} else if (childType == 'secret') {
|
||||||
|
const secretName = el.dataset.name
|
||||||
|
treeState.value.item_type = 'secret'
|
||||||
|
treeState.value.secret_name = secretName
|
||||||
|
treeState.value.client = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient(data: ClientCreate) {
|
||||||
|
const response = await SshecretAdmin.createClientApiV1ClientsPost({ body: data })
|
||||||
|
console.log(response.data)
|
||||||
|
clients.value.unshift(response.data)
|
||||||
|
totalClients.value += 1
|
||||||
|
createDrawerOpen.value = false
|
||||||
|
createFormKey.value += 1
|
||||||
|
treeState.value.selected = true
|
||||||
|
treeState.value.item_type = 'secret'
|
||||||
|
treeState.value.client = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clientDeleted(id: string) {
|
||||||
|
const index = clients.value.findIndex((c) => c.id === id)
|
||||||
|
console.log(`Client Deleted event received: ID: ${id} => ${index}`)
|
||||||
|
if (index >= 0) {
|
||||||
|
clients.value.splice(index, 1)
|
||||||
|
treeState.value.selected = false
|
||||||
|
treeState.value.item_type = null
|
||||||
|
treeState.value.client = null
|
||||||
|
await loadClients()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadClients)
|
||||||
|
watch([offset, pageNum], loadClients)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
sl-input::part(prefix) {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
sl-input::part(base) {
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
packages/sshecret-frontend/src/views/layout/MasterDetail.vue
Normal file
45
packages/sshecret-frontend/src/views/layout/MasterDetail.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<navbar>
|
||||||
|
<button
|
||||||
|
id="sidebar-toggle"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="master-pane"
|
||||||
|
class="lg:hidden text-gray-600 hover:text-gray-900 focus:outline-none"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
@click="masterHidden = !masterHidden"
|
||||||
|
>
|
||||||
|
<sl-icon name="list" class="text-xl"></sl-icon>
|
||||||
|
</button>
|
||||||
|
</navbar>
|
||||||
|
|
||||||
|
<main id="content" class="flex-1 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<div class="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
|
<aside
|
||||||
|
id="master-pane"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col overflow-hidden lg:block lg:w-80 w-full shrink-0 border-r bg-white border-gray-200 p-4 dark:bg-gray-800 dark:border-gray-700',
|
||||||
|
{ hidden: masterHidden },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="master" />
|
||||||
|
</aside>
|
||||||
|
<section id="detail-pane" class="flex-1 flex overflow-y-auto bg-white p-4 dark:bg-gray-800">
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<slot name="detail">
|
||||||
|
<p class="p-4 text-gray-500 dark:text-gray-200">Select an item to view details</p>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
|
||||||
|
import Navbar from '@/components/layout/Navbar.vue'
|
||||||
|
|
||||||
|
const masterHidden = ref(true)
|
||||||
|
</script>
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<SecretDetail
|
||||||
|
:secret="secret"
|
||||||
|
@update="updateSecretValue"
|
||||||
|
@delete="deleteSecret"
|
||||||
|
@addClient="addSecretToClient"
|
||||||
|
@removeClient="removeClientSecret"
|
||||||
|
v-if="secret"
|
||||||
|
/>
|
||||||
|
<SecretSkeleton v-else />
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import SecretDetail from '@/components/secrets/SecretDetail.vue'
|
||||||
|
import SecretSkeleton from '@/components/secrets/SecretSkeleton.vue'
|
||||||
|
import { useTreeState } from '@/store/useTreeState'
|
||||||
|
|
||||||
|
import type { SecretView } from '@/client/types.gen.ts'
|
||||||
|
import { SshecretAdmin } from '@/client'
|
||||||
|
|
||||||
|
const props = defineProps<{ secretName: string | null; parentId: string | null }>()
|
||||||
|
const secret = ref<SecretView>()
|
||||||
|
|
||||||
|
const treeState = useTreeState()
|
||||||
|
|
||||||
|
async function loadSecret() {
|
||||||
|
if (!props.secretName) return
|
||||||
|
secret.value = await treeState.getSecret(props.secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSecretValue(value: string) {
|
||||||
|
// Update a secret value
|
||||||
|
await SshecretAdmin.updateSecretApiV1SecretsNamePut({
|
||||||
|
path: {
|
||||||
|
name: props.secretName,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (props.parentId) {
|
||||||
|
await treeState.refreshClient(props.parentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSecret(clients: string[]) {
|
||||||
|
// Delete the whole secret
|
||||||
|
if (props.secretName) {
|
||||||
|
await SshecretAdmin.deleteSecretApiV1SecretsNameDelete({ path: { name: props.secretName } })
|
||||||
|
for (const clientId in clients) {
|
||||||
|
await treeState.refreshClient(clientId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSecretToClient(clientId: string) {
|
||||||
|
if (props.secretName) {
|
||||||
|
console.log('Add Secret to client', props.secretName, clientId)
|
||||||
|
await SshecretAdmin.addSecretToClientApiV1ClientsIdSecretsSecretNamePut({
|
||||||
|
path: {
|
||||||
|
id: clientId,
|
||||||
|
secret_name: props.secretName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await treeState.refreshClient(clientId)
|
||||||
|
await loadSecret()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeClientSecret(clientId: string) {
|
||||||
|
if (props.secretName) {
|
||||||
|
await SshecretAdmin.deleteSecretFromClientApiV1ClientsIdSecretsSecretNameDelete({
|
||||||
|
path: {
|
||||||
|
id: clientId,
|
||||||
|
secret_name: props.secretName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await treeState.refreshClient(clientId)
|
||||||
|
await loadSecret()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(loadSecret)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.secretName,
|
||||||
|
() => loadSecret(),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
12
packages/sshecret-frontend/tsconfig.app.json
Normal file
12
packages/sshecret-frontend/tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/sshecret-frontend/tsconfig.json
Normal file
17
packages/sshecret-frontend/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@shoelace-style/shoelace/dist/types/vue"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/sshecret-frontend/tsconfig.node.json
Normal file
19
packages/sshecret-frontend/tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/sshecret-frontend/tsconfig.vitest.json
Normal file
11
packages/sshecret-frontend/tsconfig.vitest.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||||
|
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/sshecret-frontend/vite.config.ts
Normal file
29
packages/sshecret-frontend/vite.config.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
/** @type {import('vite').UserConfig} */
|
||||||
|
export default defineConfig({
|
||||||
|
envPrefix: "SSHECRET_FRONTEND_",
|
||||||
|
plugins: [
|
||||||
|
vue({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
// Don't emit warnings for custom tags.
|
||||||
|
isCustomElement: (tag) => tag.includes('-'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
vueDevTools(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
14
packages/sshecret-frontend/vitest.config.ts
Normal file
14
packages/sshecret-frontend/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user