feat: add SMTP email support and fix profile/permissions bugs

This commit is contained in:
root
2026-06-30 19:29:08 +00:00
parent 4ded582dc4
commit f4437c5e1d
26 changed files with 643 additions and 129 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
const newChannel = {
id: result.id,
serverId: result.server_id,
server_id: result.server_id,
name: result.name,
type: result.type,
category: result.category || null,
+10 -10
View File
@@ -3,6 +3,7 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
import { useWebSocketStore } from '../stores/ws.ts';
import { useServerStore } from '../stores/server.ts';
import { useLayoutStore } from '../stores/layout.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx';
@@ -29,7 +30,6 @@ export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const user = useAuthStore((state) => state.user);
const fetchMe = useAuthStore((state) => state.fetchMe);
const logout = useAuthStore((state) => state.logout);
const updateProfile = useAuthStore((state) => state.updateProfile);
const wsConnect = useWebSocketStore((s) => s.connect);
@@ -38,14 +38,14 @@ export function Layout() {
const navigate = useNavigate();
const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [dmMode, setDmMode] = useState(false);
const isDM = useLayoutStore((s) => s.isDM);
const setDM = useLayoutStore((s) => s.setDM);
const [showServerSettings, setShowServerSettings] = useState(false);
const activeServerId = useServerStore((s) => s.activeServerId);
useEffect(() => {
fetchMe();
wsConnect();
return () => { wsDisconnect(); };
}, [fetchMe, wsConnect, wsDisconnect]);
}, [wsConnect, wsDisconnect]);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
@@ -120,9 +120,9 @@ export function Layout() {
<div className="flex-1 flex min-h-0">
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
<button
onClick={() => setDmMode(false)}
onClick={() => setDM(false)}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
!dmMode
!isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
}`}
@@ -132,11 +132,11 @@ export function Layout() {
</button>
<button
onClick={() => {
setDmMode(true);
setDM(true);
setActiveServer(null);
}}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
dmMode
isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
}`}
@@ -146,7 +146,7 @@ export function Layout() {
</button>
</div>
<ServerBar />
{dmMode ? <ConversationList /> : <ChannelList />}
{isDM ? <ConversationList /> : <ChannelList />}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<VoicePanel />
@@ -155,7 +155,7 @@ export function Layout() {
</div>
</div>
</div>
{!dmMode && <MemberList />}
{!isDM && <MemberList />}
</div>
{showServerSettings && activeServerId && (
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
+15 -3
View File
@@ -6,6 +6,8 @@ import { usePresenceStore } from "../stores/presence.ts";
import type { UserStatus } from "../stores/auth.ts";
import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts";
import { useLayoutStore } from "../stores/layout.ts";
import { useNavigate } from "react-router-dom";
function statusIcon(status: UserStatus): string {
switch (status) {
@@ -53,7 +55,10 @@ function MemberRow({ member }: { member: Member }) {
const currentUser = useAuthStore((s) => s.user);
const userPerms = useServerStore((s) => s.userPermissions);
const activeServerId = useServerStore((s) => s.activeServerId);
const setActiveServer = useServerStore((s) => s.setActiveServer);
const createConversation = useConversationStore((s) => s.createConversation);
const setDM = useLayoutStore((s) => s.setDM);
const navigate = useNavigate();
const canKick = userPerms?.kick_members ?? false;
const canBan = userPerms?.ban_members ?? false;
@@ -79,9 +84,16 @@ function MemberRow({ member }: { member: Member }) {
canKick={canKick}
canBan={canBan}
canMute={canMute}
onMessage={() => {
createConversation([member.id]);
setMenuOpen(false);
onMessage={async () => {
try {
const conv = await createConversation([member.id]);
setMenuOpen(false);
setActiveServer(null);
setDM(true);
navigate(`/dm/${conv.id}`);
} catch (err) {
console.error("Failed to start DM:", err);
}
}}
onKick={(_reason) => {
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
+13 -1
View File
@@ -15,7 +15,19 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
const activeServerId = useServerStore((s) => s.activeServerId);
const membersByServer = useMemberStore((s) => s.membersByServer);
const currentUserId = useAuthStore((s) => s.user?.id);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const members = useMemo(() => {
const map = new Map<string, any>();
if (activeServerId && membersByServer[activeServerId]) {
membersByServer[activeServerId].forEach((m) => map.set(m.id, m));
} else {
// Global search if no active server
Object.values(membersByServer).forEach((list) => {
list.forEach((m) => map.set(m.id, m));
});
}
return Array.from(map.values());
}, [activeServerId, membersByServer]);
const filtered = useMemo(() => {
const q = query.toLowerCase();
+32 -12
View File
@@ -322,20 +322,40 @@ export function UserSettings() {
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
NOTIFICATIONS:
</label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={isSubscribed ? unsubscribe : subscribe}
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{isSubscribed ? '● subscribed' : '○ not subscribed'}
</span>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<button
type="button"
onClick={async () => {
if (Notification.permission !== 'granted') {
await Notification.requestPermission();
// Force a re-render to update the permission status
setDisplayName(displayName + ' '); setTimeout(() => setDisplayName(displayName), 0);
}
}}
className={`terminal-button text-xs ${Notification.permission === 'granted' ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{Notification.permission === 'granted' ? '[DESKTOP GRANTED]' : '[ENABLE DESKTOP]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{Notification.permission === 'granted' ? '● allowed' : '○ not allowed'}
</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={isSubscribed ? unsubscribe : subscribe}
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{isSubscribed ? '● subscribed' : '○ not subscribed'}
</span>
</div>
</div>
<p className="text-gb-fg-f text-xs font-mono mt-2">
Receive push notifications for @mentions and DMs when the app is closed.
Enable Desktop to get notifications while the app is running in the background. Enable Push to get notifications when the app is fully closed.
</p>
</div>
)}