Demo images / Build & push demo images (push) Successful in 4m22s
Promotion to Trusted is a privilege escalation — a Trusted peer can read node state, be deployed to, and is exempt from the `!= Untrusted` gates federation/DWN/messaging use. It must therefore cost a fresh proof that the person at the keyboard is the operator, not merely that a session cookie exists. Same reasoning as node.rotate-identity and TOTP setup, both of which already re-verify. Both entry points are covered: - `federation.invite` gates on the RESOLVED level, not on an explicit request for Trusted: "Link Your Nodes" sends no `trust_level` at all and falls through to the Trusted default. The invite is a bearer grant of Trusted to whoever redeems it, so minting it IS the escalation. Observer invites are untouched. - `federation.set-trust` gates only when the peer is not already Trusted, so the dropdown re-emitting its own value doesn't demand a password for a no-op. Demotion is deliberately NOT gated: making something less privileged must never be harder than leaving it alone, or the safe action becomes the inconvenient one. The backend is the sole authority on what counts as an escalation — it returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and retries only on that, so the rule lives in exactly one place and the frontend never pre-judges. TrustPasswordModal.vue (modelled on RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps back to the node's real level on change, since a cancelled or failed promotion would otherwise leave the dropdown displaying a level the node never accepted. The operator path stamps TrustSource::Manual; set_trust_level grew an `Option<TrustSource>` so automatic adjustments (the discovery-handshake demotion safety net) pass None and leave the recorded provenance alone rather than laundering an uninvited-join peer into looking approved. Follow-up, deliberately out of scope: `federation.join` also reaches Trusted when redeeming someone else's Trusted invite, with no re-auth. Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 lines
2.4 KiB
Vue
75 lines
2.4 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div v-if="visible" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="handleClose">
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
|
<h3 class="text-lg font-semibold text-white mb-2">Confirm Trusted Access</h3>
|
|
<p class="text-sm text-white/60 mb-4">{{ context }}</p>
|
|
<input
|
|
ref="passwordInput"
|
|
v-model="password"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
placeholder="Enter your node password to confirm"
|
|
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
|
|
@keyup.enter="submit"
|
|
/>
|
|
<p v-if="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
|
|
<div class="flex gap-3">
|
|
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
|
<button
|
|
@click="submit"
|
|
:disabled="busy || !password"
|
|
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
|
>
|
|
{{ busy ? 'Verifying…' : 'Grant Trusted' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, nextTick, watch } from 'vue'
|
|
|
|
const props = defineProps<{
|
|
visible: boolean
|
|
/** What is about to be granted, in the operator's terms. */
|
|
context: string
|
|
busy: boolean
|
|
error: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
confirm: [password: string]
|
|
}>()
|
|
|
|
const password = ref('')
|
|
const passwordInput = ref<HTMLInputElement | null>(null)
|
|
|
|
function submit() {
|
|
if (!password.value || props.busy) return
|
|
emit('confirm', password.value)
|
|
}
|
|
|
|
function handleClose() {
|
|
password.value = ''
|
|
emit('close')
|
|
}
|
|
|
|
// Never leave the password sitting in memory once the modal is dismissed,
|
|
// and put the cursor where the operator has to type anyway.
|
|
watch(() => props.visible, async (val) => {
|
|
if (!val) {
|
|
password.value = ''
|
|
return
|
|
}
|
|
await nextTick()
|
|
passwordInput.value?.focus()
|
|
})
|
|
</script>
|