fix: add missing Instance sub-panel titles and overhaul reset password UX

- Add <h2> title headings to General, Streaming, Storage, Users panels
- Reset password now shows confirmation dialog before executing
- Temp password displays inline below the user row (not at top of list)
- Temp password has copy button and dismiss action
This commit is contained in:
Jannis Braun
2026-03-22 04:04:46 +01:00
parent 437873c823
commit 4f143bb289
4 changed files with 152 additions and 123 deletions
@@ -65,6 +65,7 @@ export function GeneralPanel() {
return ( return (
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}> <form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
<h2 className="text-lg font-semibold text-txt-primary">General</h2>
<div className="text-xs text-txt-tertiary"> <div className="text-xs text-txt-tertiary">
Configure your Backspace instance. These settings affect all users. Configure your Backspace instance. These settings affect all users.
</div> </div>
@@ -74,6 +74,7 @@ export function StoragePanel() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary">Storage</h2>
<div className="text-xs text-txt-tertiary"> <div className="text-xs text-txt-tertiary">
Monitor disk usage and clean up orphaned files left behind by deleted content or replaced avatars/banners. Monitor disk usage and clean up orphaned files left behind by deleted content or replaced avatars/banners.
</div> </div>
@@ -162,6 +162,7 @@ export function StreamingPanel() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary">Streaming</h2>
<div className="text-xs text-txt-tertiary"> <div className="text-xs text-txt-tertiary">
These limits apply to all users on this instance. Users can pick values within these bounds. These limits apply to all users on this instance. Users can pick values within these bounds.
</div> </div>
@@ -67,14 +67,27 @@ export function UsersPanel() {
} }
}; };
const handleResetPassword = async (user: AdminUser) => { const [resetConfirmUser, setResetConfirmUser] = useState<AdminUser | null>(null);
const [resetLoading, setResetLoading] = useState(false);
const handleResetPassword = (user: AdminUser) => {
setResetConfirmUser(user);
};
const handleResetConfirm = async () => {
if (!resetConfirmUser) return;
setResetLoading(true);
setError(''); setError('');
setTempPassword(null); setTempPassword(null);
try { try {
const result = await api.admin.resetUserPassword(user.id); const result = await api.admin.resetUserPassword(resetConfirmUser.id);
setTempPassword({ userId: user.id, password: result.temporaryPassword }); setTempPassword({ userId: resetConfirmUser.id, password: result.temporaryPassword });
setResetConfirmUser(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password'); setError(err instanceof Error ? err.message : 'Failed to reset password');
setResetConfirmUser(null);
} finally {
setResetLoading(false);
} }
}; };
@@ -111,6 +124,7 @@ export function UsersPanel() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold text-txt-primary">Users</h2>
<div className="text-xs text-txt-tertiary"> <div className="text-xs text-txt-tertiary">
View and manage user accounts on this instance. View and manage user accounts on this instance.
</div> </div>
@@ -140,37 +154,6 @@ export function UsersPanel() {
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div> <div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)} )}
{/* Temp password banner */}
{tempPassword && (
<div className="p-3 bg-status-online/10 border border-status-online/30 rounded-lg">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-txt-secondary">
Temporary password for <span className="font-medium text-txt-primary">{data?.users.find(u => u.id === tempPassword.userId)?.username ?? 'user'}</span>:
</div>
<button
onClick={() => setTempPassword(null)}
className="text-txt-tertiary hover:text-txt-secondary text-xs"
>
Dismiss
</button>
</div>
<div className="mt-1.5 flex items-center gap-2">
<code className="px-2 py-1 bg-black/30 rounded text-sm font-mono text-status-online select-all">
{tempPassword.password}
</code>
<button
onClick={() => navigator.clipboard.writeText(tempPassword.password)}
className="px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-xs rounded transition-colors"
>
Copy
</button>
</div>
<div className="text-xs text-txt-tertiary mt-1.5">
This password is shown once. The user has been disconnected and must log in again.
</div>
</div>
)}
{/* Loading */} {/* Loading */}
{loading && !data && ( {loading && !data && (
<div className="text-sm text-txt-tertiary py-4">Loading users...</div> <div className="text-sm text-txt-tertiary py-4">Loading users...</div>
@@ -187,102 +170,135 @@ export function UsersPanel() {
const isFederated = !!user.homeInstance; const isFederated = !!user.homeInstance;
const isDeleted = user.isDeleted; const isDeleted = user.isDeleted;
const hasTempPassword = tempPassword?.userId === user.id;
return ( return (
<div key={user.id} className="flex items-center gap-3 rounded-lg bg-white/[0.02] p-3.5"> <div key={user.id}>
{/* Avatar */} <div className={`flex items-center gap-3 rounded-lg bg-white/[0.02] p-3.5 ${hasTempPassword ? 'rounded-b-none' : ''}`}>
<div className={isDeleted ? 'opacity-50' : ''}> {/* Avatar */}
<Avatar <div className={isDeleted ? 'opacity-50' : ''}>
src={user.avatar ? api.uploads.url(user.avatar) : null} <Avatar
name={user.displayName || user.username} src={user.avatar ? api.uploads.url(user.avatar) : null}
size={32} name={user.displayName || user.username}
avatarColor={user.avatarColor as any} size={32}
/> avatarColor={user.avatarColor as any}
</div> />
{/* Info */}
<div className={`flex-1 min-w-0 ${isDeleted ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2">
<span className={`text-sm font-medium text-txt-primary truncate ${isDeleted ? 'line-through' : ''}`}>
{user.username}
</span>
{user.displayName && !isDeleted && (
<span className="text-xs text-txt-tertiary truncate">{user.displayName}</span>
)}
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5">
{user.isAdmin && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-amber/20 text-accent-amber">
Admin
</span>
)}
{isFederated && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-sky/20 text-accent-sky truncate max-w-[120px]">
{user.homeInstance}
</span>
)}
{isDeleted && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-rose/20 text-accent-rose">
Deleted
</span>
)}
<span className="text-[10px] text-txt-tertiary">
{formatDate(user.createdAt)}
</span>
</div>
</div>
{/* Actions */} {/* Info */}
{!isDeleted && ( <div className={`flex-1 min-w-0 ${isDeleted ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-2">
{/* Toggle admin */} <span className={`text-sm font-medium text-txt-primary truncate ${isDeleted ? 'line-through' : ''}`}>
<button {user.username}
onClick={() => handleToggleAdmin(user)} </span>
disabled={isFederated && !user.isAdmin} {user.displayName && !isDeleted && (
title={user.isAdmin ? 'Demote from admin' : isFederated ? 'Federated users cannot be admin' : 'Promote to admin'} <span className="text-xs text-txt-tertiary truncate">{user.displayName}</span>
className={`p-1.5 rounded transition-colors ${ )}
user.isAdmin </div>
? 'text-accent-amber hover:bg-accent-amber/10' <div className="flex items-center gap-1.5 mt-0.5">
: isFederated {user.isAdmin && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-amber/20 text-accent-amber">
Admin
</span>
)}
{isFederated && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-sky/20 text-accent-sky truncate max-w-[120px]">
{user.homeInstance}
</span>
)}
{isDeleted && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-rose/20 text-accent-rose">
Deleted
</span>
)}
<span className="text-[10px] text-txt-tertiary">
{formatDate(user.createdAt)}
</span>
</div>
</div>
{/* Actions */}
{!isDeleted && (
<div className="flex items-center gap-1 shrink-0">
{/* Toggle admin */}
<button
onClick={() => handleToggleAdmin(user)}
disabled={isFederated && !user.isAdmin}
title={user.isAdmin ? 'Demote from admin' : isFederated ? 'Federated users cannot be admin' : 'Promote to admin'}
className={`p-1.5 rounded transition-colors ${
user.isAdmin
? 'text-accent-amber hover:bg-accent-amber/10'
: isFederated
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</button>
{/* Reset password */}
<button
onClick={() => handleResetPassword(user)}
disabled={isFederated}
title={isFederated ? 'Federated users authenticate via home instance' : 'Reset password'}
className={`p-1.5 rounded transition-colors ${
isFederated
? 'text-txt-tertiary/30 cursor-not-allowed' ? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]'
}`} }`}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg> </svg>
</button> </button>
{/* Reset password */} {/* Delete user */}
<button <button
onClick={() => handleResetPassword(user)} onClick={() => handleDeleteUser(user)}
disabled={isFederated} disabled={isSelf}
title={isFederated ? 'Federated users authenticate via home instance' : 'Reset password'} title={isSelf ? 'Use account settings to delete your own account' : 'Delete user'}
className={`p-1.5 rounded transition-colors ${ className={`p-1.5 rounded transition-colors ${
isFederated isSelf
? 'text-txt-tertiary/30 cursor-not-allowed' ? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]' : 'text-txt-tertiary hover:text-accent-rose hover:bg-accent-rose/10'
}`} }`}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg> </svg>
</button> </button>
</div>
)}
</div>
{/* Delete user */} {/* Inline temp password display */}
<button {hasTempPassword && (
onClick={() => handleDeleteUser(user)} <div className="p-3 bg-status-online/10 border border-status-online/30 border-t-0 rounded-b-lg">
disabled={isSelf} <div className="flex items-center justify-between gap-2">
title={isSelf ? 'Use account settings to delete your own account' : 'Delete user'} <div className="text-xs text-txt-secondary">Temporary password:</div>
className={`p-1.5 rounded transition-colors ${ <button
isSelf onClick={() => setTempPassword(null)}
? 'text-txt-tertiary/30 cursor-not-allowed' className="text-txt-tertiary hover:text-txt-secondary text-xs"
: 'text-txt-tertiary hover:text-accent-rose hover:bg-accent-rose/10' >
}`} Dismiss
> </button>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> </div>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> <div className="mt-1.5 flex items-center gap-2">
</svg> <code className="px-2 py-1 bg-black/30 rounded text-sm font-mono text-status-online select-all">
</button> {tempPassword.password}
</code>
<button
onClick={() => navigator.clipboard.writeText(tempPassword.password)}
className="px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-xs rounded transition-colors"
>
Copy
</button>
</div>
<div className="text-[10px] text-txt-tertiary mt-1">
Shown once. User has been disconnected and must log in again.
</div>
</div> </div>
)} )}
</div> </div>
@@ -315,6 +331,16 @@ export function UsersPanel() {
)} )}
{/* Confirm dialogs */} {/* Confirm dialogs */}
<ConfirmDialog
isOpen={!!resetConfirmUser}
onClose={() => setResetConfirmUser(null)}
onConfirm={handleResetConfirm}
title="Reset Password"
description={<>Reset the password for <strong>{resetConfirmUser?.username}</strong>? They will be disconnected immediately and must log in with the new temporary password.</>}
confirmLabel="Reset Password"
variant="warning"
loading={resetLoading}
/>
<ConfirmDialog <ConfirmDialog
isOpen={confirmAction?.type === 'demote'} isOpen={confirmAction?.type === 'demote'}
onClose={() => setConfirmAction(null)} onClose={() => setConfirmAction(null)}