Deletions, group moves and validation

This commit is contained in:
2025-07-15 16:53:37 +02:00
parent 412a84150e
commit 3efc4d7fa5
11 changed files with 268 additions and 52 deletions

View File

@ -7,7 +7,7 @@ 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
* DONE Install the required libraries
+ [X] Flowbite
+ [X] Shoelace
+ [X] tailwind
@ -15,9 +15,26 @@ I suppose this can be rendered from OpenAPI?
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
* DONE Set up login page (basic)
+ [X] Create an authentication state store
+ [X] Use the rendered API to send login details, and receive tokens
+ [X] Consider adding a refresh token thing to the API
* Create the master/detail page
* DONE Create the master/detail page
* Views
** Secret View
+ [X] Add dropdown
+ [X] Implement delete
+ [X] Implement move
* Navigation
** DONE Implement hard links
+ All selections can be done to the state,
except the tab selection
* Error handling
+ [X] Check styling on sl-alert
+ [X] Ensure that its z-index is above any popups
+ [X] Refine field-based validation in ClientForm for other fields
+ [ ] Implement validation in SecretForm

View File

@ -1,21 +1,10 @@
<template>
<sl-alert
v-for="alert in alerts.alerts"
:key="alert.id"
:variant="alert.type"
open
closable
:duration="['warning', 'error'].includes(alert.type) ? Infinity : 3000"
@sl-after-hide="alerts.removeAlert(alert.id)"
>{{ alert.message }}</sl-alert
>
<ShowAlerts />
<RouterView />
</template>
<script setup lang="ts">
import { useAuthTokenStore } from '@/store/auth'
import { useAlertsStore } from '@/store/useAlertsStore'
import ShowAlerts from '@/components/common/ShowAlerts.vue'
import { RouterView } from 'vue-router'
const alerts = useAlertsStore()
</script>

View File

@ -208,7 +208,6 @@ const props = defineProps<Props>()
const shouldPaginate = toRef(() => props.paginate ?? true)
const auditFilter = toRef(() => props.auditFilter)
console.log(auditFilter.value)
const auditList = ref<AuditListResult>([])
const auditEntries = computed(() => auditList.value?.results)
const totalEntries = computed(() => auditList.value?.total)

View File

@ -80,10 +80,7 @@ const name = ref('')
const description = ref('')
const sourcePrefix = ref('')
const policies = ref(['0.0.0.0/0', '::/0'])
// This key is only here during testing.
const publicKey = ref(
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC737Yj7mbuBLDNbAuNGqhFF4Cvzd/ROq/QeQX0QIcPyZOoUtpXc7R/JIrdL6DXkPYXpN/IrUFoSeJQjV9Le+ewVxYELUPVhF0/nQhpBNE1Rjx2PRtJlfmywG5VRStgPQ+DSTDtgm4L0wPpnJiH3udkq/JFMHEYrVAF40QqNmR7AqYo1ZfEFk8YcQGb/S29JxWigq0qoJyufFENmSGNmabjqPAWJEf/oshMPaxwlDfTdmjeUWkPtsm10gi98XCwtnVCAVYZdVKeLSNpQCKUYVYWlycpahNczaITY9lehcMtux79uXTk2d4difra1Q4guw8oorUp1eRn/Al0BPeRb7x9WdgRs8wVY1kPD2796CTAQMkeBrOzGxwzwWhTf1XOuHG/wB5O2QSbcC6aMW9KAFmcCF+AOMb8Mv2Y5D7l/gbp938qTyZJ8ivP1/fy/88CWr+mrv5yP4HOZmNCyC9nMlAvrS/Kkg0tFU+NHFkDsmWpT3oar+VvGzkImEF6ip6Mzk8= testkey',
)
const publicKey = ref('')
const nameField = ref<HTMLSlInputElement>()
const sourceField = ref<HTMLSlInputElement>()
@ -169,15 +166,27 @@ function validatePublicKey() {
setFieldValidation(publicKeyField, '')
}
function resetValidation() {
setFieldValidation(nameField, '')
setFieldValidation(sourceField, '')
setFieldValidation(publicKeyField, '')
}
watch(
() => props.errors,
(errors) => {
resetValidation()
const nameErrors = errors.filter((e) => e.loc.includes('name'))
const sourceErrors = errors.filter((e) => e.loc.includes('source'))
const publicKeyError = errors.filter((e) => e.loc.includes('public_key'))
if (nameErrors.length > 0) {
console.log(nameErrors)
setFieldValidation(nameField, nameErrors[0].msg)
} else {
setFieldValidation(nameField, '')
}
if (sourceErrors.length > 0) {
setFieldValidation(sourceField, sourceErrors[0].msg)
}
if (publicKeyError.length > 0) {
setFieldValidation(publicKeyField, publicKeyErrors[0].msg)
}
},
)

View File

@ -0,0 +1,36 @@
<template>
<sl-alert
:variant="alert.type"
closable
:duration="['warning', 'error', 'danger'].includes(alert.type) ? Infinity : 3000"
ref="alertElement"
@sl-after-hide="alerts.removeAlert(alert.id)"
>
<sl-icon slot="icon" :name="alert.icon"></sl-icon>
<strong class="alert-title">{{ alert.title }}</strong
><br />
{{ alert.message }}
</sl-alert>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAlertsStore, Alert } from '@/store/useAlertsStore'
const alerts = useAlertsStore()
const props = defineProps<{ alert: Alert }>()
const alertElement = ref<HTMLSlAlertElement>()
function showToast() {
alertElement.value.toast()
}
onMounted(showToast)
</script>
<style>
.alert-title::first-letter {
text-transform: capitalize;
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<template v-for="alert in alerts.alerts" :key="alert.id">
<AlertToast :alert="alert" />
</template>
</template>
<script setup lang="ts">
import { useAlertsStore } from '@/store/useAlertsStore'
import AlertToast from '@/components/common/AlertToast.vue'
const alerts = useAlertsStore()
</script>

View File

@ -16,9 +16,12 @@
<sl-button variant="default" outline @click="emit('cancel')"> Cancel</sl-button>
</template>
<script setup lang="ts">
import { ref, onMounted, toRef } from 'vue'
import { SshecretAdmin } from '@/client'
import type { ClientSecretGroup } from '@/client'
import { ref, onMounted, toRef } from 'vue'
import { assertSdkResponseOk } from '@/api/assertSdkResponseOk'
import { useAlertsStore } from '@/store/useAlertsStore'
const props = defineProps<{ self: string }>()
const groups = ref<ClientSecretGroup[]>([])
@ -33,8 +36,11 @@ async function getGroups() {
const response = await SshecretAdmin.getSecretGroupsApiV1SecretsGroupsGet({
query: { flat: true },
})
if (response.data) {
groups.value = response.data.groups
try {
const responseData = assertSdkResponseOk(response)
groups.value = responseData.groups
} catch (err) {
alerts.showAlert(err.message, 'error', 'Could not fetch groups from the backend')
}
}

View File

@ -0,0 +1,58 @@
<template>
<template v-if="groups">
<sl-select
placeholder="Target parent"
hoist
:value="selectedPath"
@sl-change="selectedPath = $event.target.value"
>
<sl-option v-for="group in groups" :key="group.path" :value="group.path">{{
group.path
}}</sl-option>
</sl-select>
</template>
<sl-button variant="success" outline @click="selectPath" class="mr-2"> Move</sl-button>
<sl-button variant="default" outline @click="emit('cancel')"> Cancel</sl-button>
</template>
<script setup lang="ts">
import { SshecretAdmin } from '@/client'
import type { ClientSecretGroup } from '@/client'
import { ref, onMounted, toRef } from 'vue'
import { assertSdkResponseOk } from '@/api/assertSdkResponseOk'
import { useAlertsStore } from '@/store/useAlertsStore'
interface Props {
existingPath?: string
}
const props = defineProps<Props>()
const groups = ref<ClientSecretGroup[]>([])
const emit = defineEmits<{ (e: 'selected', data: string): void; (e: 'cancel'): void }>()
const alerts = useAlertsStore()
const selectedPath = ref('')
async function getGroups() {
const response = await SshecretAdmin.getSecretGroupsApiV1SecretsGroupsGet({
query: { flat: true },
})
try {
const responseData = assertSdkResponseOk(response)
if (props.existingPath) {
groups.value = responseData.groups.filter((entry) => entry.path !== props.existingPath)
} else {
groups.value = responseData.groups
}
} catch (err) {
alerts.showAlert(err.message, 'error', 'Could not fetch groups from the backend')
}
}
function selectPath() {
emit('selected', selectedPath.value)
}
onMounted(getGroups)
</script>

View File

@ -100,12 +100,25 @@
>
{{ secret.group.path }}
<div class="mt-2 float-right">
<sl-button size="medium" variant="default" outline>
<sl-button size="medium" variant="default" outline @click="showMove = true">
<sl-icon slot="prefix" name="box-arrow-in-right"></sl-icon>
Move
</sl-button>
</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
>
Not in any group
<div class="mt-2 float-right">
<sl-button size="medium" variant="success" outline @click="showMove = true">
<sl-icon slot="prefix" name="folder-plus"></sl-icon>
Add to group
</sl-button>
</div>
</dd>
</div>
</dl>
</div>
@ -179,6 +192,13 @@
<sl-button variant="danger" @click="deleteSecret">Delete</sl-button>
</template>
</Dialog>
<Dialog label="Move secret to group" :open="showMove" @hide="showMove = false">
<MoveSecretGroup
:existingPath="existingGroupPath"
@selected="moveGroup"
@cancel="showMove = false"
/>
</Dialog>
</template>
<script setup lang="ts">
@ -188,6 +208,7 @@ import AuditTable from '@/components/audit/AuditTable.vue'
import AddSecretToClients from '@/components/secrets/AddSecretToClients.vue'
import Dialog from '@/components/common/Dialog.vue'
import MoveSecretGroup from '@/components/secrets/MoveSecretGroup.vue'
const props = defineProps<{ secret: SecretView }>()
const emit = defineEmits<{
@ -195,17 +216,15 @@ const emit = defineEmits<{
(e: 'delete'): void
(e: 'addClient', data: string): void
(e: 'removeClient', data: string): void
(e: 'moveGroup', data: string): void
}>()
const secret = ref<Secret>(props.secret)
const clients = computed(() => props.secret?.clients ?? [])
const secretValue = ref<string | null>(secret.value?.secret)
const addDialog = ref<boolean>(false)
const showConfirm = ref<boolean>(false)
const showMove = ref<boolean>(false)
const secretChanged = computed(() => {
if (!secretValue.value) {
@ -226,6 +245,13 @@ function handleHide(event) {
}
}
function moveGroup(path: string) {
emit('moveGroup', path)
showMove.value = false
}
const existingGroupPath = computed(() => secret.value.group?.path)
function updateSecret() {
emit('update', secretValue.value)
}

View File

@ -4,7 +4,17 @@ import { defineStore } from 'pinia'
interface Alert {
id: number
message: string
type: 'info' | 'success' | 'warning' | 'error'
title?: string
type: 'info' | 'success' | 'warning' | 'error' | 'danger'
icon: string
}
const iconMap = {
'info': 'info-circle',
'success': 'check2-circle',
'warning': 'exclamation-triangle',
'error': 'exclamation-octagon',
'danger': 'exclamation-octagon',
}
export const useAlertsStore = defineStore('alerts', {
@ -12,11 +22,24 @@ export const useAlertsStore = defineStore('alerts', {
alerts: [] as Alert[],
}),
actions: {
showAlert(message: string, type: Alert['type'] = 'info') {
showAlert(message: string, type: Alert['type'] = 'info', title?: string) {
if (type === 'error') {
type = 'danger'
}
if (!title) {
if (type === 'danger') {
title = 'Error'
} else {
title = type
}
}
const icon = iconMap[type]
this.alerts.push({
id: Date.now(),
message,
title,
type,
icon,
})
},
removeAlert(id: number) {

View File

@ -8,6 +8,7 @@
@delete="deleteSecret"
@addClient="addSecretToClient"
@removeClient="removeClientSecret"
@moveGroup="moveGroup"
v-if="secret"
/>
</div>
@ -17,16 +18,20 @@ import { ref, watch, onMounted } from 'vue'
import SecretDetail from '@/components/secrets/SecretDetail.vue'
import SecretSkeleton from '@/components/secrets/SecretSkeleton.vue'
import NotFound from '@/components/common/NotFound.vue'
import { assertSdkResponseOk } from '@/api/assertSdkResponseOk'
import { ApiError, NotFoundError, ValidationError } from '@/api/errors'
import { useTreeState } from '@/store/useTreeState'
import { useAlertsStore } from '@/store/useAlertsStore'
import { useRouter } from 'vue-router'
import type { SecretView } from '@/client/types.gen.ts'
import { SshecretAdmin } from '@/client'
const alerts = useAlertsStore()
const props = defineProps<{ id: string | null; parentId: string | null }>()
const secret = ref<SecretView>()
const router = useRouter()
const treeState = useTreeState()
@ -68,38 +73,75 @@ async function updateSecretValue(value: string) {
async function deleteSecret(clients: string[]) {
// Delete the whole secret
if (props.id) {
await SshecretAdmin.deleteSecretApiV1SecretsNameDelete({ path: { name: props.id } })
for (const clientId in clients) {
await treeState.refreshClient(clientId)
const response = await SshecretAdmin.deleteSecretApiV1SecretsNameDelete({
path: { name: props.id },
})
try {
assertSdkResponseOk(response)
for (const clientId in clients) {
await treeState.refreshClient(clientId)
}
await treeState.getSecretGroups()
treeState.bumpGroupRevision()
router.go(-1)
} catch (err) {
alerts.showAlert(err.message, 'error', 'Error deleting secret')
}
await treeState.getSecretGroups()
treeState.bumpGroupRevision()
}
}
async function addSecretToClient(clientId: string) {
if (props.id) {
await SshecretAdmin.addSecretToClientApiV1ClientsIdSecretsSecretNamePut({
const response = await SshecretAdmin.addSecretToClientApiV1ClientsIdSecretsSecretNamePut({
path: {
id: clientId,
secret_name: props.id,
},
})
await treeState.refreshClient(clientId)
await loadSecret()
try {
assertSdkResponseOk(response)
await treeState.refreshClient(clientId)
await loadSecret()
} catch (err) {
alerts.showAlert(err.message, 'error', 'Failed to add secret to client')
}
}
}
async function removeClientSecret(clientId: string) {
if (props.id) {
await SshecretAdmin.deleteSecretFromClientApiV1ClientsIdSecretsSecretNameDelete({
path: {
id: clientId,
secret_name: props.id,
},
})
await treeState.refreshClient(clientId)
const response =
await SshecretAdmin.deleteSecretFromClientApiV1ClientsIdSecretsSecretNameDelete({
path: {
id: clientId,
secret_name: props.id,
},
})
try {
assertSdkResponseOk(response)
await treeState.refreshClient(clientId)
await loadSecret()
} catch (err) {
alerts.showAlert(err.message, 'error', 'Failed to remove secret from client')
}
}
}
async function moveGroup(path) {
// Move secret to a group.
const data = {
secret_name: secret.value.name,
group_path: path,
}
const response = await SshecretAdmin.assignSecretGroupApiV1SecretsSetGroupPost({ body: data })
try {
assertSdkResponseOk(response)
await loadSecret()
await treeState.getSecretGroups()
treeState.bumpGroupRevision()
alerts.showAlert('Secret moved', 'success')
} catch (err) {
alerts.showAlert(err.message, 'error', 'Failed to move secret to group')
}
}
onMounted(loadSecret)