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 (
<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">
Configure your Backspace instance. These settings affect all users.
</div>
@@ -74,6 +74,7 @@ export function StoragePanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary">Storage</h2>
<div className="text-xs text-txt-tertiary">
Monitor disk usage and clean up orphaned files left behind by deleted content or replaced avatars/banners.
</div>
@@ -162,6 +162,7 @@ export function StreamingPanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary">Streaming</h2>
<div className="text-xs text-txt-tertiary">
These limits apply to all users on this instance. Users can pick values within these bounds.
</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('');
setTempPassword(null);
try {
const result = await api.admin.resetUserPassword(user.id);
setTempPassword({ userId: user.id, password: result.temporaryPassword });
const result = await api.admin.resetUserPassword(resetConfirmUser.id);
setTempPassword({ userId: resetConfirmUser.id, password: result.temporaryPassword });
setResetConfirmUser(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password');
setResetConfirmUser(null);
} finally {
setResetLoading(false);
}
};
@@ -111,6 +124,7 @@ export function UsersPanel() {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-txt-primary">Users</h2>
<div className="text-xs text-txt-tertiary">
View and manage user accounts on this instance.
</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>
)}
{/* 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 && !data && (
<div className="text-sm text-txt-tertiary py-4">Loading users...</div>
@@ -187,102 +170,135 @@ export function UsersPanel() {
const isFederated = !!user.homeInstance;
const isDeleted = user.isDeleted;
const hasTempPassword = tempPassword?.userId === user.id;
return (
<div key={user.id} className="flex items-center gap-3 rounded-lg bg-white/[0.02] p-3.5">
{/* Avatar */}
<div className={isDeleted ? 'opacity-50' : ''}>
<Avatar
src={user.avatar ? api.uploads.url(user.avatar) : null}
name={user.displayName || user.username}
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 key={user.id}>
<div className={`flex items-center gap-3 rounded-lg bg-white/[0.02] p-3.5 ${hasTempPassword ? 'rounded-b-none' : ''}`}>
{/* Avatar */}
<div className={isDeleted ? 'opacity-50' : ''}>
<Avatar
src={user.avatar ? api.uploads.url(user.avatar) : null}
name={user.displayName || user.username}
size={32}
avatarColor={user.avatarColor as any}
/>
</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 */}
{!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
{/* 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 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 */}
{!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 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>
}`}
>
<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" />
</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 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="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>
</button>
{/* Delete user */}
<button
onClick={() => handleDeleteUser(user)}
disabled={isSelf}
title={isSelf ? 'Use account settings to delete your own account' : 'Delete user'}
className={`p-1.5 rounded transition-colors ${
isSelf
? 'text-txt-tertiary/30 cursor-not-allowed'
: '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}>
<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>
</button>
</div>
)}
</div>
{/* Delete user */}
<button
onClick={() => handleDeleteUser(user)}
disabled={isSelf}
title={isSelf ? 'Use account settings to delete your own account' : 'Delete user'}
className={`p-1.5 rounded transition-colors ${
isSelf
? 'text-txt-tertiary/30 cursor-not-allowed'
: '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}>
<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>
</button>
{/* Inline temp password display */}
{hasTempPassword && (
<div className="p-3 bg-status-online/10 border border-status-online/30 border-t-0 rounded-b-lg">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-txt-secondary">Temporary password:</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-[10px] text-txt-tertiary mt-1">
Shown once. User has been disconnected and must log in again.
</div>
</div>
)}
</div>
@@ -315,6 +331,16 @@ export function UsersPanel() {
)}
{/* 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
isOpen={confirmAction?.type === 'demote'}
onClose={() => setConfirmAction(null)}