feat(ux): Tier 3 security-adjacent UX (presentational only)
- secrets: revealed value gets a copy-to-clipboard with feedback, auto re-hides after 30s, re-hides on window blur, and the timer is cleared on unmount (no change to revealSecret or RLS) - permissions matrix: a view-only notice when the viewer is not admin, and a per-checkbox aria-label so the grid is usable non-visually - OpsAclEditor: pills are now aria-pressed toggles with a visible check when selected and a success toast on save No server action, RLS, or permission logic changed.
This commit is contained in:
@@ -60,6 +60,14 @@ export function PermissionsPanel({ isAdmin, customRoles, rolePerms }: Props) {
|
|||||||
<span className="font-sans text-xs text-muted-foreground">holds all permissions (implicit, locked)</span>
|
<span className="font-sans text-xs text-muted-foreground">holds all permissions (implicit, locked)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<div className="mb-4 rounded border border-border bg-muted/40 px-3 py-2" role="status">
|
||||||
|
<p className="font-sans text-xs text-muted-foreground">
|
||||||
|
View only. Only an admin can change permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -118,6 +126,7 @@ function PermGroup({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
|
aria-label={`${p.label} for ${s.label}`}
|
||||||
checked={grants[s.key]?.has(p.code) ?? false}
|
checked={grants[s.key]?.has(p.code) ?? false}
|
||||||
onChange={e => onToggle(s.key, p.code, e.target.checked)}
|
onChange={e => onToggle(s.key, p.code, e.target.checked)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, ChevronDown, Users2, FileText } from 'lucide-react'
|
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, ChevronDown, Users2, FileText, Copy, Check } from 'lucide-react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
|
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
|
||||||
@@ -359,17 +359,50 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
|||||||
const [value, setValue] = useState<string | null>(null)
|
const [value, setValue] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [showAcl, setShowAcl] = useState(false)
|
const [showAcl, setShowAcl] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null }
|
||||||
|
setRevealed(false)
|
||||||
|
setValue(null)
|
||||||
|
setCopied(false)
|
||||||
|
}
|
||||||
|
|
||||||
async function reveal() {
|
async function reveal() {
|
||||||
if (revealed) { setRevealed(false); setValue(null); return }
|
if (revealed) { hide(); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await revealSecret(secret.id)
|
const result = await revealSecret(secret.id)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.error) { toast.error(result.error); return }
|
if (result.error) { toast.error(result.error); return }
|
||||||
setValue(result.value ?? '')
|
setValue(result.value ?? '')
|
||||||
setRevealed(true)
|
setRevealed(true)
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||||
|
hideTimer.current = setTimeout(hide, 30000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (!value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy to clipboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-hide a revealed secret when the window loses focus (screen share /
|
||||||
|
// shoulder surfing), and drop the plaintext + timer on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
const onBlur = () => hide()
|
||||||
|
window.addEventListener('blur', onBlur)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', onBlur)
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!(await confirm({ title: 'Delete secret', description: `"${secret.title}" cannot be undone.`, confirmText: 'Delete', destructive: true }))) return
|
if (!(await confirm({ title: 'Delete secret', description: `"${secret.title}" cannot be undone.`, confirmText: 'Delete', destructive: true }))) return
|
||||||
const result = await deleteSecret(secret.id)
|
const result = await deleteSecret(secret.id)
|
||||||
@@ -388,7 +421,18 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
|||||||
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
|
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
|
||||||
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
|
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
|
||||||
{revealed && value && (
|
{revealed && value && (
|
||||||
<p className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded mt-1 break-all">{value}</p>
|
<div className="flex items-start gap-1.5 mt-1">
|
||||||
|
<p className="flex-1 font-mono text-xs text-foreground bg-muted px-2 py-1 rounded break-all">{value}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copy}
|
||||||
|
aria-label="Copy secret to clipboard"
|
||||||
|
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition flex-shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
import { setOpsAcl } from '@/lib/actions'
|
import { setOpsAcl } from '@/lib/actions'
|
||||||
|
|
||||||
export interface AclRoleOption { value: string; label: string }
|
export interface AclRoleOption { value: string; label: string }
|
||||||
@@ -37,6 +38,8 @@ export function OpsAclEditor({ entityType, entityId, options, initial }: Props)
|
|||||||
else back.add(value)
|
else back.add(value)
|
||||||
return back
|
return back
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
toast.success('Access updated')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +58,13 @@ export function OpsAclEditor({ entityType, entityId, options, initial }: Props)
|
|||||||
<button
|
<button
|
||||||
key={o.value}
|
key={o.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
onClick={() => toggle(o.value, !on)}
|
onClick={() => toggle(o.value, !on)}
|
||||||
className={`flex items-center font-mono text-[10px] px-3 py-2 min-h-[44px] rounded border transition ${
|
className={`flex items-center gap-1 font-mono text-[10px] px-3 py-2 min-h-[44px] rounded border transition ${
|
||||||
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
|
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{on && <Check className="w-3 h-3" />}
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user