|
|
|
@@ -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)}
|
|
|
|
|