wraith/frontend/components/connections/HostEditDialog.vue
Vantz Stockwell 93811b59cb fix(security): auth hardening — httpOnly cookies, Argon2id passwords, TOTP encryption, rate limiting
C-2: JWT moved from localStorage to httpOnly cookie (eliminates XSS token theft)
C-3: WebSocket auth via short-lived single-use tickets (JWT no longer in URLs)
H-1: JWT expiry reduced from 7 days to 4 hours
H-3: TOTP secrets encrypted at rest with vault EncryptionService (auto-migrates plaintext)
H-6: Rate limiting via @nestjs/throttler (60 req/min global, tighten on auth)
H-8: Constant-time login — Argon2id verify runs against dummy hash for non-existent users
H-9: Password hashing upgraded from bcrypt(10) to Argon2id (auto-upgrades on login)
H-10: Credential list API no longer returns encrypted blobs
H-16: Admin pages use Nuxt route middleware instead of client-side guard
Plus: auth bootstrap plugin, cookie-parser middleware, all frontend Authorization headers removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:24:35 -04:00

250 lines
8.7 KiB
Vue

<script setup lang="ts">
const props = defineProps<{
visible: boolean
host: any | null
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'saved'): void
}>()
const connections = useConnectionStore()
const auth = useAuthStore()
const form = ref({
name: '',
hostname: '',
port: 22,
protocol: 'ssh' as 'ssh' | 'rdp',
groupId: null as number | null,
credentialId: null as number | null,
tags: [] as string[],
notes: '',
color: '',
})
const tagInput = ref('')
const saving = ref(false)
const error = ref('')
const isEdit = computed(() => !!props.host?.id)
const title = computed(() => isEdit.value ? 'Edit Host' : 'New Host')
const groupOptions = computed(() => {
const opts: { label: string; value: number | null }[] = [{ label: 'No Group', value: null }]
for (const g of connections.groups) {
opts.push({ label: g.name, value: g.id })
}
return opts
})
const credentials = ref<any[]>([])
const credentialOptions = computed(() => {
const opts: { label: string; value: number | null }[] = [{ label: 'No Credential', value: null }]
for (const c of credentials.value) {
opts.push({ label: c.name, value: c.id })
}
return opts
})
async function loadCredentials() {
try {
credentials.value = await $fetch('/api/credentials')
} catch {
credentials.value = []
}
}
watch(() => props.visible, (v) => {
if (v) {
loadCredentials()
if (props.host?.id) {
form.value = {
name: props.host.name || '',
hostname: props.host.hostname || '',
port: props.host.port || 22,
protocol: props.host.protocol || 'ssh',
groupId: props.host.groupId ?? null,
credentialId: props.host.credentialId ?? null,
tags: [...(props.host.tags || [])],
notes: props.host.notes || '',
color: props.host.color || '',
}
} else {
form.value = {
name: '',
hostname: '',
port: 22,
protocol: 'ssh',
groupId: props.host?.groupId ?? null,
credentialId: null,
tags: [],
notes: '',
color: '',
}
}
error.value = ''
}
})
watch(() => form.value.protocol, (proto) => {
if (proto === 'rdp' && form.value.port === 22) form.value.port = 3389
if (proto === 'ssh' && form.value.port === 3389) form.value.port = 22
})
function addTag() {
const t = tagInput.value.trim()
if (t && !form.value.tags.includes(t)) {
form.value.tags.push(t)
}
tagInput.value = ''
}
function removeTag(tag: string) {
form.value.tags = form.value.tags.filter((t) => t !== tag)
}
function close() {
emit('update:visible', false)
}
async function save() {
error.value = ''
saving.value = true
try {
const payload = {
...form.value,
port: Number(form.value.port),
color: form.value.color || undefined,
notes: form.value.notes || undefined,
}
if (isEdit.value) {
await connections.updateHost(props.host.id, payload)
} else {
await connections.createHost(payload)
}
emit('saved')
close()
} catch (e: any) {
error.value = e.data?.message || 'Failed to save host'
} finally {
saving.value = false
}
}
</script>
<template>
<div v-if="visible" class="fixed inset-0 z-[9999] flex items-center justify-center">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/70" @click="close" />
<!-- Dialog -->
<div class="relative bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-[480px] max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-800">
<h3 class="text-lg font-semibold text-white">{{ title }}</h3>
<button @click="close" class="text-gray-500 hover:text-white text-xl leading-none">&times;</button>
</div>
<!-- Body -->
<div class="px-5 py-4 space-y-4">
<!-- Name -->
<div>
<label class="block text-sm text-gray-400 mb-1">Name *</label>
<input v-model="form.name" type="text" placeholder="My Server" autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
<!-- Hostname + Port -->
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1">Hostname / IP *</label>
<input v-model="form.hostname" type="text" placeholder="192.168.1.1"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
<div class="w-24">
<label class="block text-sm text-gray-400 mb-1">Port</label>
<input v-model.number="form.port" type="number"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
</div>
<!-- Protocol -->
<div>
<label class="block text-sm text-gray-400 mb-1">Protocol</label>
<select v-model="form.protocol"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
</div>
<!-- Group -->
<div>
<label class="block text-sm text-gray-400 mb-1">Group</label>
<select v-model="form.groupId"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option v-for="opt in groupOptions" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<!-- Credential -->
<div>
<label class="block text-sm text-gray-400 mb-1">Credential</label>
<select v-model="form.credentialId"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option v-for="opt in credentialOptions" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<!-- Color -->
<div>
<label class="block text-sm text-gray-400 mb-1">Color (optional)</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="h-8 w-12 rounded cursor-pointer bg-gray-800 border-0" />
<span class="text-xs text-gray-500">{{ form.color || 'None' }}</span>
<button v-if="form.color" @click="form.color = ''" class="text-xs text-gray-600 hover:text-red-400">Clear</button>
</div>
</div>
<!-- Tags -->
<div>
<label class="block text-sm text-gray-400 mb-1">Tags</label>
<div class="flex gap-2 mb-2">
<input v-model="tagInput" type="text" placeholder="Add tag..."
class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:border-sky-500 focus:outline-none"
@keydown.enter.prevent="addTag" />
<button @click="addTag" class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-sm rounded text-gray-300">Add</button>
</div>
<div class="flex flex-wrap gap-1">
<span v-for="tag in form.tags" :key="tag"
class="inline-flex items-center gap-1 bg-gray-800 text-gray-300 text-xs px-2 py-1 rounded">
{{ tag }}
<button @click="removeTag(tag)" class="text-gray-500 hover:text-red-400">&times;</button>
</span>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm text-gray-400 mb-1">Notes</label>
<textarea v-model="form.notes" rows="3" placeholder="Optional notes..."
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:border-sky-500 focus:outline-none resize-none" />
</div>
<!-- Error -->
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-800">
<button @click="close" class="px-4 py-2 text-sm text-gray-400 hover:text-white rounded">Cancel</button>
<button @click="save" :disabled="saving || !form.name || !form.hostname"
class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded disabled:opacity-50">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</template>