UI/细节调整
This commit is contained in:
@@ -12,10 +12,25 @@
|
||||
* 2. **新建时先向要一个 id**。密码按 hostId 存取,若等 save_host 之后再生成 id,
|
||||
* 用户在新建对话框里填的密码就没有归属。因此用 `terminal_new_host_id`
|
||||
* 预取 id,使「新建」与「编辑」走完全相同的流程。
|
||||
*
|
||||
* # 已知主机(known_hosts)在这里而不是独立页(2026-09-22)
|
||||
*
|
||||
* 「这台主机我信任过吗、指纹变过没有」是**浏览主机列表时顺手核对**的信息,
|
||||
* 此前它占着「凭据」页的一个分区,于是核对信任要跨 tab,而每个 tab 里都只有
|
||||
* 半张信息。现在三层都落在本页:
|
||||
*
|
||||
* - 主机行的**信任徽标**(未连接过 / 已信任 / 变更 N 次)——一眼可见;
|
||||
* - 徽标点开的 **HostTrustPopover**——各算法指纹、变更历史、「忘记」;
|
||||
* - 列表末尾的 **OrphanKnownHosts**——没有对应主机配置的记录(配置已删或
|
||||
* 从未保存过配置),它们照样会拦截连接,不能失去入口。
|
||||
*
|
||||
* 整份 known_hosts 的维护动作(导入 / 导出 / 清空)是低频操作,收进工具条的
|
||||
* 「已知主机」下拉(`KnownHostsMenu`),不占页面主线。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Download,
|
||||
@@ -51,14 +66,24 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { StateBlock, StatusBadge } from '@/components/common'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { blankHost, type SshHost } from './hostForm'
|
||||
import type { HostView } from '@/types/terminal'
|
||||
import HostTrustBadge from './HostTrustBadge.vue'
|
||||
import KnownHostsMenu from './KnownHostsMenu.vue'
|
||||
import OrphanKnownHosts from './OrphanKnownHosts.vue'
|
||||
import { addressLabel, groupByAddress, recordsForHost, trustState } from './hostTrust'
|
||||
import PageToolbar from '@/components/layout/PageToolbar.vue'
|
||||
import type { HostView, KnownHost } from '@/types/terminal'
|
||||
|
||||
const logger = createLogger('terminal')
|
||||
const store = useTerminalStore()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'connect', hostId: string): void }>()
|
||||
// 只声明 connect:本页现在是模块级 Tabs 的一页,不再需要「返回终端」语义
|
||||
// (导航由 Tabs 承担),此前声明的 close 事件从未被触发过。
|
||||
const emit = defineEmits<{ (e: 'connect', hostId: string): void }>()
|
||||
|
||||
// ===== 编辑对话框 =====
|
||||
|
||||
@@ -237,6 +262,45 @@ async function toggleFavorite(v: HostView) {
|
||||
/** 可用密钥(供认证方式下拉使用) */
|
||||
const availableKeys = computed(() => store.keys.filter(k => k.fileExists))
|
||||
|
||||
// ===== 主机密钥信任(known_hosts) =====
|
||||
|
||||
/**
|
||||
* 记录按地址归组一次,供本页反复查(每行徽标 + 详情 popover + 顶部提示)。
|
||||
* 匹配规则与「孤儿」判定共用 `hostTrust.ts` 里的同一套实现 —— 分头实现迟早
|
||||
* 会出现「徽标说已信任、点开却找不到记录」这类自相矛盾。
|
||||
*/
|
||||
const groupedKnownHosts = computed(() => groupByAddress(store.knownHosts))
|
||||
|
||||
/** 某台主机对应的指纹记录(可能为空 = 还没成功连过) */
|
||||
function recordsOf(v: HostView): KnownHost[] {
|
||||
return recordsForHost(groupedKnownHosts.value, v.config)
|
||||
}
|
||||
|
||||
/** 指纹曾变更过的主机数(顶部风险提示的数据源) */
|
||||
const changedHostCount = computed(
|
||||
() => store.hosts.filter(v => trustState(recordsOf(v)) === 'changed').length
|
||||
)
|
||||
|
||||
// ===== 忘记某台主机(信任详情 popover 的破坏性动作) =====
|
||||
|
||||
const pendingForget = ref<KnownHost | null>(null)
|
||||
const forgetBusy = ref(false)
|
||||
|
||||
async function submitForget() {
|
||||
const r = pendingForget.value
|
||||
if (!r) return
|
||||
forgetBusy.value = true
|
||||
try {
|
||||
await store.forgetHost(r.host, r.port, r.keyType)
|
||||
toast.success(`已忘记 ${addressLabel(r.host, r.port)}`)
|
||||
pendingForget.value = null
|
||||
} catch (e) {
|
||||
toast.error(`操作失败:${String(e)}`)
|
||||
} finally {
|
||||
forgetBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 跳板机链(ProxyJump)=====
|
||||
|
||||
/**
|
||||
@@ -280,129 +344,175 @@ function moveHop(i: number, dir: -1 | 1) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 头部 -->
|
||||
<div class="shrink-0 flex items-center gap-2 px-4 h-12 border-b border-border">
|
||||
<Globe class="size-4 text-muted-foreground" />
|
||||
<h3 class="text-sm font-medium">SSH 主机</h3>
|
||||
<span class="text-xs text-muted-foreground">({{ store.hosts.length }})</span>
|
||||
<div class="flex-1" />
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importHosts">
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<PageToolbar :icon="Globe" title="SSH 主机" :meta="`共 ${store.hosts.length} 台`">
|
||||
<KnownHostsMenu />
|
||||
<Button variant="outline" size="sm" @click="importHosts">
|
||||
<Upload class="size-3.5" />导入备份
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="exportHosts">
|
||||
<Button variant="outline" size="sm" @click="exportHosts">
|
||||
<Download class="size-3.5" />导出
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importConfig">
|
||||
<Button variant="outline" size="sm" @click="importConfig">
|
||||
<Download class="size-3.5" />导入 ~/.ssh/config
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 gap-1.5 text-xs" @click="openNew">
|
||||
<Button size="sm" @click="openNew">
|
||||
<Plus class="size-3.5" />新建主机
|
||||
</Button>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
<div v-if="store.hosts.length === 0" class="py-16 text-center">
|
||||
<Globe class="size-8 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p class="text-sm text-muted-foreground mb-1">还没有配置 SSH 主机</p>
|
||||
<p class="text-xs text-muted-foreground/70 mb-4">
|
||||
可以手工新建,也可以直接从 <code class="px-1 rounded bg-muted">~/.ssh/config</code> 导入
|
||||
<!-- 风险提示:有主机指纹变更时始终可见(信任详情 popover 是同一条信息的展开) -->
|
||||
<div
|
||||
v-if="changedHostCount > 0"
|
||||
class="shrink-0 mb-3 flex items-start gap-2 p-2.5 rounded-md
|
||||
border border-warning/40 bg-warning-soft"
|
||||
>
|
||||
<AlertTriangle class="size-3.5 shrink-0 mt-0.5 text-warning" />
|
||||
<div class="text-[11px] text-warning-text">
|
||||
<p class="font-medium">{{ changedHostCount }} 台主机曾发生指纹变更</p>
|
||||
<p class="mt-0.5 opacity-90">
|
||||
若不是您本人重装或更换了服务器密钥,请联系管理员核实。点主机行的信任徽标可查看详情。
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Button size="sm" class="gap-1.5" @click="openNew">
|
||||
<Plus class="size-3.5" />新建主机
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="gap-1.5" @click="importConfig">
|
||||
<Download class="size-3.5" />导入配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="v in store.hosts"
|
||||
:key="v.config.id"
|
||||
class="group flex items-center gap-3 px-3 py-2 rounded-md border border-border
|
||||
hover:border-primary/40 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 size-6 rounded flex items-center justify-center transition-colors"
|
||||
:class="
|
||||
v.config.favorited
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground/30 hover:text-amber-500'
|
||||
"
|
||||
:title="v.config.favorited ? '取消收藏' : '收藏'"
|
||||
@click="toggleFavorite(v)"
|
||||
>
|
||||
<Star class="size-3.5" :class="{ 'fill-current': v.config.favorited }" />
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate">
|
||||
{{ v.config.name || v.config.host }}
|
||||
</span>
|
||||
<span v-if="v.config.group" class="text-[10px] px-1.5 py-0.5 rounded bg-muted shrink-0">
|
||||
{{ v.config.group }}
|
||||
</span>
|
||||
<!-- 不可连接时给出明确原因,而不是让用户点下去才发现失败 -->
|
||||
<span
|
||||
v-if="!v.ready"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400 shrink-0"
|
||||
:title="v.issue ?? ''"
|
||||
>
|
||||
{{ v.issue || '配置不完整' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{{ v.config.username }}@{{ v.config.host
|
||||
}}<span v-if="v.config.port !== 22">:{{ v.config.port }}</span>
|
||||
<span class="mx-1.5">·</span>
|
||||
<span v-if="v.config.authMethod === 'key' && v.config.keyId">
|
||||
<KeyRound class="inline size-2.5 -mt-0.5" />
|
||||
{{ store.keys.find(k => k.meta.id === v.config.keyId)?.meta.name ?? '密钥' }}
|
||||
</span>
|
||||
<span v-else-if="v.config.authMethod === 'password'">
|
||||
<!-- 不回显掩码:密码不是 API key,保留前 3 后 4 也是在泄露真实内容;
|
||||
是否已保存由 hasPassword 表达,明文/掩码一律不出现在列表 -->
|
||||
{{ v.hasPassword ? '密码已保存' : '密码未保存' }}
|
||||
</span>
|
||||
<span v-else>{{ v.config.authMethod }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
:disabled="!v.ready"
|
||||
:title="v.ready ? '连接' : (v.issue ?? '')"
|
||||
@click="emit('connect', v.config.id)"
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-7" title="编辑" @click="openEdit(v.config.id)">
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
@click="remove(v)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
列表:卡片承载(与 proxy 订阅列表同口径),行间距 8px。
|
||||
|
||||
`-mx-3` + 内层 `px-3 pb-3`:滚动视口会裁掉超出自身盒子的内容,卡片阴影
|
||||
(shadow-sm)贴着视口边缘就会被切一半 —— 表现为「终端模块的卡片比别的
|
||||
页面扁」。负外边距把视口向两侧各外扩 12px、内层等量内缩,卡片位置与宽度
|
||||
不变却留出了阴影余量。**内边距必须在内层**:根元素上的 pb 不属于滚动内容,
|
||||
滚到底时最后一张卡片的底部阴影仍会被裁(同一份说明见 TerminalSettingsPanel)。
|
||||
-->
|
||||
<ScrollArea class="flex-1 min-h-0 -mx-3">
|
||||
<div class="px-3 pb-3">
|
||||
<Card v-if="store.hosts.length === 0">
|
||||
<CardContent>
|
||||
<StateBlock
|
||||
variant="empty"
|
||||
title="还没有配置 SSH 主机"
|
||||
description="可以手工新建,也可以直接从 ~/.ssh/config 导入"
|
||||
:min-height="220"
|
||||
>
|
||||
<template #action>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Button size="sm" class="gap-1.5" @click="openNew">
|
||||
<Plus class="size-3.5" />新建主机
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="gap-1.5" @click="importConfig">
|
||||
<Download class="size-3.5" />导入配置
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</StateBlock>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-else>
|
||||
<CardContent class="space-y-2">
|
||||
<div
|
||||
v-for="v in store.hosts"
|
||||
:key="v.config.id"
|
||||
class="group flex items-center gap-3 rounded-md border border-border p-3 transition-colors hover:bg-hover"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 size-7 rounded flex items-center justify-center transition-colors"
|
||||
:class="
|
||||
v.config.favorited
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground/30 hover:text-warning'
|
||||
"
|
||||
:title="v.config.favorited ? '取消收藏' : '收藏'"
|
||||
@click="toggleFavorite(v)"
|
||||
>
|
||||
<Star class="size-3.5" :class="{ 'fill-current': v.config.favorited }" />
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate">
|
||||
{{ v.config.name || v.config.host }}
|
||||
</span>
|
||||
<span v-if="v.config.group" class="shrink-0 text-[11px] text-muted-foreground">
|
||||
{{ v.config.group }}
|
||||
</span>
|
||||
<!-- 不可连接时给出明确原因,而不是让用户点下去才发现失败 -->
|
||||
<StatusBadge
|
||||
v-if="!v.ready"
|
||||
variant="warning"
|
||||
size="sm"
|
||||
:title="v.issue ?? ''"
|
||||
>
|
||||
{{ v.issue || '配置不完整' }}
|
||||
</StatusBadge>
|
||||
|
||||
<!--
|
||||
主机密钥信任:徽标即「查看详情」的入口(有记录时才可点),
|
||||
详情与「忘记」都在 HostTrustBadge 里,行内只传记录集合。
|
||||
-->
|
||||
<HostTrustBadge
|
||||
:label="v.config.name || v.config.host"
|
||||
:address="addressLabel(v.config.host, v.config.port)"
|
||||
:records="recordsOf(v)"
|
||||
@forget="pendingForget = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{{ v.config.username }}@{{ v.config.host
|
||||
}}<span v-if="v.config.port !== 22">:{{ v.config.port }}</span>
|
||||
<span class="mx-1.5">·</span>
|
||||
<span v-if="v.config.authMethod === 'key' && v.config.keyId">
|
||||
<KeyRound class="inline size-2.5 -mt-0.5" />
|
||||
{{ store.keys.find(k => k.meta.id === v.config.keyId)?.meta.name ?? '密钥' }}
|
||||
</span>
|
||||
<span v-else-if="v.config.authMethod === 'password'">
|
||||
<!-- 不回显掩码:密码不是 API key,保留前 3 后 4 也是在泄露真实内容;
|
||||
是否已保存由 hasPassword 表达,明文/掩码一律不出现在列表 -->
|
||||
{{ v.hasPassword ? '密码已保存' : '密码未保存' }}
|
||||
</span>
|
||||
<span v-else>{{ v.config.authMethod }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="!v.ready"
|
||||
:title="v.ready ? '连接' : (v.issue ?? '')"
|
||||
@click="emit('connect', v.config.id)"
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="编辑" @click="openEdit(v.config.id)">
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
@click="remove(v)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!--
|
||||
没有对应主机配置的指纹记录(配置已删 / 从未保存过配置)。
|
||||
放在主机列表之后的**同一滚动区**里:它属于「同一份 known_hosts 的另一半」,
|
||||
独立成一屏会让用户在两个位置之间来回找。
|
||||
-->
|
||||
<OrphanKnownHosts @forget="pendingForget = $event" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<!-- ===== 编辑对话框 ===== -->
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent class="max-w-xl max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
|
||||
<DialogDescription class="text-xs">
|
||||
@@ -410,7 +520,14 @@ function moveHop(i: number, dir: -1 | 1) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="editing" class="space-y-4">
|
||||
<!--
|
||||
表单很长(基本信息 + 认证 + 可展开的「高级选项」),必须滚动。
|
||||
用 ScrollArea 而不是给 DialogContent 加 overflow-y-auto:后者会带上
|
||||
浏览器原生滚动条(系统样式、与主题不一致),项目其余长对话框
|
||||
(monitor / downloader)都用 ScrollArea + max-h 的写法。
|
||||
-->
|
||||
<ScrollArea class="max-h-[60vh] pr-4">
|
||||
<div v-if="editing" class="space-y-4">
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
@@ -476,7 +593,7 @@ function moveHop(i: number, dir: -1 | 1) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="errors.keyId" class="text-[11px] text-destructive">{{ errors.keyId }}</p>
|
||||
<p v-if="availableKeys.length === 0" class="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
<p v-if="availableKeys.length === 0" class="text-[11px] text-warning">
|
||||
还没有可用密钥,请先到「密钥管理」生成或导入
|
||||
</p>
|
||||
</div>
|
||||
@@ -637,9 +754,21 @@ function moveHop(i: number, dir: -1 | 1) {
|
||||
</div>
|
||||
<Switch v-model="editing.favorited" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Label class="text-xs">注入 shell 集成</Label>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
让远端上报命令与目录,命令历史与 cwd 跟踪才可用(不落盘,只影响本次会话)。
|
||||
与「登录后执行」里 exec 换 shell 的配置冲突时可关掉
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="editing.injectShellIntegration" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" @click="dialogOpen = false">取消</Button>
|
||||
@@ -667,5 +796,38 @@ function moveHop(i: number, dir: -1 | 1) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!--
|
||||
===== 忘记主机密钥(指纹记录) =====
|
||||
对话框放在页级而不是信任 popover 内部:popover 会因失焦关闭,
|
||||
而确认框是「用户明确同意降低校验强度」的关键一步,不能被关在中间。
|
||||
-->
|
||||
<Dialog :open="pendingForget !== null" @update:open="v => !v && (pendingForget = null)">
|
||||
<DialogContent class="max-w-sm" :show-close-button="false">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="text-base">忘记该主机</DialogTitle>
|
||||
<DialogDescription class="text-xs">
|
||||
将删除
|
||||
<span class="font-mono">
|
||||
{{ pendingForget ? addressLabel(pendingForget.host, pendingForget.port) : '' }}
|
||||
</span>
|
||||
的
|
||||
<span class="font-mono">{{ pendingForget?.keyType }}</span>
|
||||
指纹记录。下次连接时将重新提示核对。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex items-start gap-2 p-2 rounded bg-muted text-[11px] text-muted-foreground">
|
||||
<AlertTriangle class="size-3.5 shrink-0 mt-0.5" />
|
||||
<span>仅在确认该主机密钥确实已更换时执行。若出于「连不上就删掉」的动机,请先核实原因。</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" @click="pendingForget = null">取消</Button>
|
||||
<Button variant="destructive" size="sm" :disabled="forgetBusy" @click="submitForget">
|
||||
<Loader2 v-if="forgetBusy" class="size-3.5 mr-1.5 animate-spin" />
|
||||
忘记
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user