1.1.3
This commit is contained in:
@@ -9,7 +9,17 @@
|
||||
* still hold the old Matrix-based code and are not imported.
|
||||
*/
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Gear, Plus, ShieldStar, Smiley, Trash, UploadSimple, X } from '@phosphor-icons/react';
|
||||
import {
|
||||
ClockCounterClockwise,
|
||||
Gear,
|
||||
Plus,
|
||||
Prohibit,
|
||||
ShieldStar,
|
||||
Smiley,
|
||||
Trash,
|
||||
UploadSimple,
|
||||
X,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
@@ -20,7 +30,7 @@ import { MobileServerSettings } from './MobileServerSettings';
|
||||
import { useRolesView } from './RolesView';
|
||||
import userStyles from './UserSettingsModal.module.css';
|
||||
|
||||
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis';
|
||||
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit';
|
||||
|
||||
interface ServerSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,6 +42,8 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
|
||||
{ id: 'overview', label: 'Overview', icon: Gear },
|
||||
{ id: 'roles', label: 'Roles', icon: ShieldStar },
|
||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
||||
{ id: 'bans', label: 'Bans', icon: Prohibit },
|
||||
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
|
||||
];
|
||||
|
||||
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
|
||||
@@ -150,6 +162,8 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
|
||||
{activeTab === 'overview' && <OverviewTab />}
|
||||
{inRolesView && rolesView.content}
|
||||
{activeTab === 'emojis' && <CustomEmojisTab />}
|
||||
{activeTab === 'bans' && <BansTab />}
|
||||
{activeTab === 'audit' && <AuditLogTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,6 +356,7 @@ const PERMISSION_KEYS = [
|
||||
'move_members',
|
||||
'mute_members',
|
||||
'manage_nicknames',
|
||||
'ban_members',
|
||||
] as const;
|
||||
|
||||
type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
@@ -892,3 +907,362 @@ const dangerBtnStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
/* Bans */
|
||||
/* ------------------------------------------------------------------- */
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const s = Math.max(0, Math.floor(diff / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function BansTab() {
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const bans = useQuery(api.bans.list, myUserId ? { actorId: myUserId } : 'skip');
|
||||
const allUsers = useQuery(api.auth.getPublicKeys, {}) ?? [];
|
||||
const banMutation = useMutation(api.bans.ban);
|
||||
const unbanMutation = useMutation(api.bans.unban);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickedUserId, setPickedUserId] = useState<string>('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const bannedIdSet = useMemo(
|
||||
() => new Set((bans ?? []).map((b: any) => b.userId)),
|
||||
[bans],
|
||||
);
|
||||
|
||||
const bannableUsers = useMemo(
|
||||
() =>
|
||||
(allUsers as any[]).filter(
|
||||
(u) => u.id !== myUserId && !bannedIdSet.has(u.id),
|
||||
),
|
||||
[allUsers, bannedIdSet, myUserId],
|
||||
);
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!myUserId || !pickedUserId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await banMutation({
|
||||
actorId: myUserId,
|
||||
userId: pickedUserId as Id<'userProfiles'>,
|
||||
reason: reason.trim() || undefined,
|
||||
});
|
||||
setPickerOpen(false);
|
||||
setPickedUserId('');
|
||||
setReason('');
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to ban user.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async (userId: string) => {
|
||||
if (!myUserId) return;
|
||||
try {
|
||||
await unbanMutation({
|
||||
actorId: myUserId,
|
||||
userId: userId as Id<'userProfiles'>,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to unban user.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={userStyles.profileHeader}>
|
||||
<h2 className={userStyles.profileSubheading}>Bans</h2>
|
||||
<p className={userStyles.profileDescription}>
|
||||
Banned users can't log in or send messages. Unbanning restores access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
marginBottom: 12,
|
||||
borderRadius: 6,
|
||||
background: 'rgba(248, 113, 113, 0.12)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerOpen ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
}}
|
||||
>
|
||||
<Label>User</Label>
|
||||
<select
|
||||
value={pickedUserId}
|
||||
onChange={(e) => setPickedUserId(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">Select a user…</option>
|
||||
{bannableUsers.map((u: any) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName || u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ height: 12 }} />
|
||||
<Label>Reason (optional)</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={200}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBan}
|
||||
disabled={busy || !pickedUserId}
|
||||
style={dangerBtnStyle}
|
||||
>
|
||||
{busy ? 'Banning…' : 'Ban user'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPickerOpen(false);
|
||||
setPickedUserId('');
|
||||
setReason('');
|
||||
setError(null);
|
||||
}}
|
||||
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
style={{ ...primaryBtnStyle, marginBottom: 16, display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<Plus size={16} weight="bold" /> Ban a user
|
||||
</button>
|
||||
)}
|
||||
|
||||
{bans === undefined ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading bans…</div>
|
||||
) : bans.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>No one is banned.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{bans.map((b: any) => (
|
||||
<div
|
||||
key={b._id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-tertiary)',
|
||||
}}
|
||||
>
|
||||
{b.user?.avatarUrl ? (
|
||||
<img
|
||||
src={b.user.avatarUrl}
|
||||
alt=""
|
||||
style={{ width: 40, height: 40, borderRadius: '50%' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{(b.user?.displayName || b.user?.username || '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
|
||||
{b.user?.displayName || b.user?.username || 'Unknown user'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{b.reason ? `“${b.reason}” · ` : ''}
|
||||
banned by {b.actor?.displayName || b.actor?.username || 'unknown'} · {formatRelative(b.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnban(b.userId)}
|
||||
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
/* Audit Log */
|
||||
/* ------------------------------------------------------------------- */
|
||||
|
||||
const AUDIT_LABELS: Record<string, string> = {
|
||||
'channel.create': 'created channel',
|
||||
'channel.delete': 'deleted channel',
|
||||
'channel.rename': 'renamed channel',
|
||||
'channel.update_topic': 'updated channel topic',
|
||||
'role.create': 'created role',
|
||||
'role.delete': 'deleted role',
|
||||
'role.update': 'updated role',
|
||||
'role.assign': 'assigned role',
|
||||
'role.unassign': 'removed role',
|
||||
'server.settings_update': 'updated server settings',
|
||||
'ban.add': 'banned',
|
||||
'ban.remove': 'unbanned',
|
||||
};
|
||||
|
||||
export function AuditLogTab() {
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const entries = useQuery(
|
||||
api.audit.list,
|
||||
myUserId ? { actorId: myUserId, limit: 200 } : 'skip',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={userStyles.profileHeader}>
|
||||
<h2 className={userStyles.profileSubheading}>Audit Log</h2>
|
||||
<p className={userStyles.profileDescription}>
|
||||
Recent admin actions, newest first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{entries === undefined ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading…</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
Nothing logged yet.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{entries.map((e: any) => {
|
||||
const label = AUDIT_LABELS[e.action] ?? e.action;
|
||||
const actorName = e.actor?.displayName || e.actor?.username || 'Someone';
|
||||
return (
|
||||
<div
|
||||
key={e._id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--background-secondary)',
|
||||
}}
|
||||
>
|
||||
{e.actor?.avatarUrl ? (
|
||||
<img
|
||||
src={e.actor.avatarUrl}
|
||||
alt=""
|
||||
style={{ width: 28, height: 28, borderRadius: '50%' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{actorName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<strong>{actorName}</strong> {label}
|
||||
{e.targetName ? <> <strong>{e.targetName}</strong></> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={new Date(e.createdAt).toLocaleString()}
|
||||
>
|
||||
{formatRelative(e.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user