This commit is contained in:
Bryan1029384756
2026-04-18 15:41:51 -05:00
parent 938df217f4
commit 593eaba82e
47 changed files with 3539 additions and 212 deletions

View File

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