commit 92fa6a2f317c7aecfb1aa8114b2a9f7c45cf64cf
parent c30b4d6923e325557b4d629a60824be191d248a5
Author: MTRNord <mtrnord1@gmail.com>
Date: Thu, 1 May 2025 00:38:42 +0200
Use SSR where possible and prepare bot selector based on API
Signed-off-by: MTRNord <mtrnord1@gmail.com>
Diffstat:
22 files changed, 708 insertions(+), 535 deletions(-)
diff --git a/next.config.ts b/next.config.ts
@@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
useCache: true,
+ dynamicIO: true,
},
output: 'standalone'
};
diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts
@@ -1,8 +1,9 @@
+import { User } from "@/lib/auth";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
- const { openidToken } = await req.json();
+ const { openidToken, homeserverUrl, matrixId } = await req.json();
if (!openidToken) {
return NextResponse.json({ error: "OpenID token is required" }, { status: 400 });
@@ -13,9 +14,13 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid OpenID token" }, { status: 401 });
}
- const user = {
+ const user: User = {
id: "user_" + Math.random().toString(36).substring(2, 9),
+ displayName: matrixId,
+ matrixId: matrixId,
+ token: openidToken,
isAdmin: false,
+ homeserverUrl: homeserverUrl,
};
const response = NextResponse.json(user);
@@ -33,9 +38,11 @@ export async function GET() {
return NextResponse.json(JSON.parse(session));
}
-export async function DELETE() {
- const response = NextResponse.json({}, { status: 204 });
+export async function DELETE(req: Request) {
+ const url = new URL(req.url);
+ const response = NextResponse.redirect(url.origin);
response.cookies.set("session", "", { httpOnly: true, path: "/", maxAge: 0 });
+
return response;
}
diff --git a/src/app/dashboard/analytics/page.tsx b/src/app/dashboard/analytics/page.tsx
@@ -16,6 +16,7 @@ import { PlotlyChart } from "@/components/analytics/plotly-chart";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import InfoCardWithTrend from "@/components/analytics/info-card-with-trend";
+import LayoutWrapper from "@/components/dashboard/layoutWrapper";
// Mock data for charts
const roomActivityData: RoomActivityData[] = [
@@ -61,7 +62,7 @@ const monthlyActivityData: MonthlyActivityData[] = [
export default function AnalyticsDashboard() {
const searchParams = useSearchParams()
- const teamIdParam = searchParams.get("team")
+ const teamIdParam = searchParams.get("team") || undefined
const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0])
@@ -90,7 +91,7 @@ export default function AnalyticsDashboard() {
const reportChangePercent = 12
return (
- <>
+ <LayoutWrapper activeTab="analytics" teamIdParam={teamIdParam}>
<TabNavigation selectedTeam={selectedTeam} currentTab="analytics" />
<div className="space-y-4">
<div className="flex items-center justify-end">
@@ -285,6 +286,6 @@ export default function AnalyticsDashboard() {
</CardContent>
</Card>
</div>
- </>
+ </LayoutWrapper>
)
}
diff --git a/src/app/dashboard/bans/page.tsx b/src/app/dashboard/bans/page.tsx
@@ -10,11 +10,12 @@ import { EBanTypes, mockPolicyLists, mockTeams, } from "../mockData";
import AddBan from "../../../components/modals/add-ban";
import { useSearchParams } from "next/navigation";
import TabNavigation from "../../../components/dashboard/tab-navigation";
+import LayoutWrapper from "@/components/dashboard/layoutWrapper";
export default function Bans() {
const searchParams = useSearchParams()
- const teamIdParam = searchParams.get("team")
+ const teamIdParam = searchParams.get("team") || undefined
const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0])
const [searchTerm, setSearchTerm] = useState<string>("");
@@ -45,7 +46,7 @@ export default function Bans() {
});
return (
- <>
+ <LayoutWrapper activeTab="bans" teamIdParam={teamIdParam}>
<TabNavigation selectedTeam={selectedTeam} currentTab="bans" />
<Card className="border-gray-800 bg-gray-950">
@@ -156,6 +157,6 @@ export default function Bans() {
</div>
</CardContent>
</Card>
- </>
- );
+ </LayoutWrapper>
+ )
}
\ No newline at end of file
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
@@ -1,7 +1,5 @@
import type { ReactNode } from "react"
import { Suspense } from "react"
-import { DashboardHeader } from "../../components/dashboard/dashboard-header"
-import { mockTeams } from "./mockData"
export default async function DashboardLayout({
children,
@@ -9,21 +7,8 @@ export default async function DashboardLayout({
children: ReactNode
}) {
return (
- <div className="flex min-h-screen flex-col bg-black text-white">
- <Suspense
- fallback={
- <div className="flex h-16 items-center justify-between border-b border-gray-800 bg-black/80 backdrop-blur-sm">
- Loading...
- </div>
- }
- >
- <DashboardHeader teams={mockTeams} />
- </Suspense>
- <main className="flex-1 container px-4 py-8 md:px-6 md:py-12">
- <Suspense fallback={<div className="flex h-screen w-full items-center justify-center">Loading...</div>}>
- {children}
- </Suspense>
- </main>
- </div>
+ <Suspense fallback={<div className="flex h-screen w-full items-center justify-center">Loading...</div>}>
+ {children}
+ </Suspense>
)
}
diff --git a/src/app/dashboard/overview/page.tsx b/src/app/dashboard/overview/page.tsx
@@ -1,23 +1,33 @@
-"use client"
import { AlertTriangle, Ban, CheckCircle } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import AddProtectedRoom from "../../../components/modals/add-protected-room"
import ProtectedRomsList from "../../../components/dashboard/protected-rooms-list"
import { mockPolicyLists, mockReports, mockTeams } from "../mockData"
import TabNavigation from "../../../components/dashboard/tab-navigation";
-import { useSearchParams } from "next/navigation"
-
-export default function OverviewPage() {
- const searchParams = useSearchParams()
- const teamIdParam = searchParams.get("team");
+import LayoutWrapper from "@/components/dashboard/layoutWrapper"
+import { listBots } from "@/lib/api"
+import { cookies } from "next/headers"
+import { User } from "@/lib/auth"
+export default async function OverviewPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const teamIdParam = (await searchParams).team as string | undefined;
+ const cookieStore = await cookies()
+ const session: User = JSON.parse(cookieStore.get("session")?.value || "{}")
+ if (!session.token) {
+ return <div className="flex h-screen w-full items-center justify-center">Loading...</div>
+ }
+ const listData = await listBots(session.token);
const selectedTeam = mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0];
const reports = mockReports.filter((report) => report.teamId === selectedTeam.id);
const policyLists = mockPolicyLists.filter((list) => list.teamId === selectedTeam.id);
return (
- <>
+ <LayoutWrapper listData={listData} activeTab="overview" teamIdParam={teamIdParam}>
<TabNavigation selectedTeam={selectedTeam} currentTab="overview" />
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -125,6 +135,6 @@ export default function OverviewPage() {
</Card>
</div>
</div>
- </>
+ </LayoutWrapper>
)
}
\ No newline at end of file
diff --git a/src/app/dashboard/reports/page.tsx b/src/app/dashboard/reports/page.tsx
@@ -20,10 +20,11 @@ import {
import { mockReports, mockTeams } from "../mockData";
import { useSearchParams } from "next/navigation"
import TabNavigation from "../../../components/dashboard/tab-navigation"
+import LayoutWrapper from "@/components/dashboard/layoutWrapper"
export default function ReportsPage() {
const searchParams = useSearchParams()
- const teamIdParam = searchParams.get("team")
+ const teamIdParam = searchParams.get("team") || undefined
const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0])
// Update selected team when URL param changes
@@ -47,7 +48,7 @@ export default function ReportsPage() {
: reports.filter((r) => r.type === filter)
return (
- <>
+ <LayoutWrapper activeTab="reports" teamIdParam={teamIdParam}>
<TabNavigation selectedTeam={selectedTeam} currentTab="reports" />
<Card className="border-gray-800 bg-gray-950">
<CardHeader className="pb-2">
@@ -272,6 +273,6 @@ export default function ReportsPage() {
</div>
</CardContent>
</Card>
- </>
+ </LayoutWrapper>
)
}
diff --git a/src/app/dashboard/settings/bot/page.tsx b/src/app/dashboard/settings/bot/page.tsx
@@ -0,0 +1,42 @@
+import { listBots } from "@/lib/api";
+import { User } from "@/lib/auth";
+import { cookies } from "next/headers";
+import { mockTeams } from "../../mockData";
+import SettingsLayout from "../settingsLayout";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Trash2 } from "lucide-react";
+
+export default async function BotSettingsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const teamIdParam = (await searchParams).team as string | undefined;
+ const cookieStore = await cookies()
+ const session: User = JSON.parse(cookieStore.get("session")?.value || "{}")
+ if (!session.token) {
+ return <div className="flex h-screen w-full items-center justify-center">Loading...</div>
+ }
+ const listData = await listBots(session.token);
+ const selectedTeam = mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0];
+
+ return (
+ <SettingsLayout currentTab={"settings"} listData={listData} selectedTeam={selectedTeam} teamIdParam={teamIdParam}>
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="bot-name">Bot Name</Label>
+ <Input id="bot-name" defaultValue={selectedTeam.name} className="bg-gray-900 border-gray-800" />
+ </div>
+ <div className="pt-4 flex justify-between">
+ <Button variant="outline" className="border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300">
+ <Trash2 className="mr-2 h-4 w-4" />
+ Delete Bot
+ </Button>
+ <Button className="bg-purple-600 text-white hover:bg-purple-700">Save Changes</Button>
+ </div>
+ </div>
+ </SettingsLayout>
+ )
+}
+\ No newline at end of file
diff --git a/src/app/dashboard/settings/members/page.tsx b/src/app/dashboard/settings/members/page.tsx
@@ -0,0 +1,138 @@
+import { listBots } from "@/lib/api";
+import { User } from "@/lib/auth";
+import { cookies } from "next/headers";
+import SettingsLayout from "../settingsLayout";
+import { mockTeams } from "../../mockData";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { LogOut, Settings, UserPlus } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+
+export default async function MembersSettingsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const teamIdParam = (await searchParams).team as string | undefined;
+ const cookieStore = await cookies()
+ const session: User = JSON.parse(cookieStore.get("session")?.value || "{}")
+ if (!session.token) {
+ return <div className="flex h-screen w-full items-center justify-center">Loading...</div>
+ }
+ const listData = await listBots(session.token);
+ const selectedTeam = mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0];
+
+ return (
+ <SettingsLayout currentTab={"members"} listData={listData} selectedTeam={selectedTeam} teamIdParam={teamIdParam}>
+ <div className="flex items-center justify-between">
+ <h3 className="text-sm font-medium text-gray-400">Team Members</h3>
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700">
+ <UserPlus className="mr-2 h-4 w-4" />
+ Invite Member
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="bg-gray-950 border-gray-800">
+ <DialogHeader>
+ <DialogTitle>Invite Team Member</DialogTitle>
+ <DialogDescription className="text-gray-400">
+ Invite a Matrix user to join your moderation team
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="invite-matrix-id">Matrix ID</Label>
+ <Input
+ id="invite-matrix-id"
+ placeholder="@user:matrix.org"
+ className="bg-gray-900 border-gray-800"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="invite-role">Role</Label>
+ <select
+ id="invite-role"
+ className="w-full rounded-md border border-gray-800 bg-gray-900 px-3 py-2 text-sm"
+ >
+ <option value="moderator">Moderator</option>
+ <option value="viewer">Viewer (Read-only)</option>
+ </select>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300"
+ >
+ Cancel
+ </Button>
+ <Button className="bg-purple-600 text-white hover:bg-purple-700">
+ Send Invitation
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <ScrollArea className="h-[300px] rounded-md border border-gray-800">
+ <div className="p-4 space-y-3">
+ {selectedTeam.members.map((member) => (
+ <div key={member.id} className="flex items-center justify-between p-3 rounded-md bg-gray-900">
+ <div className="flex items-center gap-3">
+ <Avatar className="h-10 w-10 border border-gray-800">
+ <AvatarImage src={member.avatar || ""} />
+ <AvatarFallback className="bg-gray-800 text-gray-400">
+ {member.name.substring(1, 3).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <p className="font-medium">{member.name}</p>
+ <div className="flex items-center gap-2">
+ <Badge
+ className={
+ member.role === "owner"
+ ? "bg-purple-900/50 text-purple-300"
+ : "bg-blue-900/50 text-blue-300"
+ }
+ >
+ {member.role}
+ </Badge>
+ <span className="text-xs text-gray-500">
+ Joined {new Date(member.joined).toLocaleDateString()}
+ </span>
+ </div>
+ </div>
+ </div>
+ {member.role !== "owner" && (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
+ <span className="sr-only">Open menu</span>
+ <Settings className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="bg-gray-900 border-gray-800">
+ <DropdownMenuItem className="text-yellow-400 focus:text-yellow-400 focus:bg-yellow-900/20">
+ <Settings className="mr-2 h-4 w-4" />
+ Change Role
+ </DropdownMenuItem>
+ <DropdownMenuItem className="text-red-400 focus:text-red-400 focus:bg-red-900/20">
+ <LogOut className="mr-2 h-4 w-4" />
+ Remove from Team
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </SettingsLayout>
+ )
+}
+\ No newline at end of file
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx
@@ -1,203 +1,15 @@
-"use client";
+import { redirect } from "next/navigation"
-import { useState } from "react"
-import { UserPlus, Settings, Trash2, LogOut } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Badge } from "@/components/ui/badge"
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { useSearchParams } from "next/navigation"
-import { mockTeams } from "../mockData"
-import AddProtectedRoom from "../../../components/modals/add-protected-room"
-import ProtectedRomsList from "../../../components/dashboard/protected-rooms-list"
-import TabNavigation from "../../../components/dashboard/tab-navigation"
+export default async function TeamManagement({
+ searchParams,
+}: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const teamId = (await searchParams).team as string | undefined
+ // Redirect to overview with team parameter
+ const redirectUrl = teamId ? `/dashboard/settings/members?team=${teamId}` : "/dashboard/settings/members"
-export default function TeamManagement() {
- const searchParams = useSearchParams()
- const teamIdParam = searchParams.get("team")
- const [selectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0])
-
-
- // TODO: Make this actual pages so we can use more ssr
- const [activeTab, setActiveTab] = useState("members")
-
- return (
- <>
- <TabNavigation selectedTeam={selectedTeam} currentTab="settings" />
- <Card className="border-gray-800 bg-gray-950">
- <CardHeader>
- <div className="flex items-center justify-between">
- <div>
- <CardTitle>Team Management</CardTitle>
- <CardDescription className="text-gray-400">Manage your bots and team members</CardDescription>
- </div>
- </div>
- </CardHeader>
- <CardContent>
- <Tabs defaultValue="members" value={activeTab} onValueChange={setActiveTab} className="space-y-4">
- <TabsList className="bg-gray-900">
- <TabsTrigger value="members">Team Members</TabsTrigger>
- <TabsTrigger value="rooms">Protected Rooms</TabsTrigger>
- <TabsTrigger value="settings">Bot Settings</TabsTrigger>
- </TabsList>
-
- <TabsContent value="members" className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-sm font-medium text-gray-400">Team Members</h3>
- <Dialog>
- <DialogTrigger asChild>
- <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700">
- <UserPlus className="mr-2 h-4 w-4" />
- Invite Member
- </Button>
- </DialogTrigger>
- <DialogContent className="bg-gray-950 border-gray-800">
- <DialogHeader>
- <DialogTitle>Invite Team Member</DialogTitle>
- <DialogDescription className="text-gray-400">
- Invite a Matrix user to join your moderation team
- </DialogDescription>
- </DialogHeader>
- <div className="space-y-4 py-4">
- <div className="space-y-2">
- <Label htmlFor="invite-matrix-id">Matrix ID</Label>
- <Input
- id="invite-matrix-id"
- placeholder="@user:matrix.org"
- className="bg-gray-900 border-gray-800"
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="invite-role">Role</Label>
- <select
- id="invite-role"
- className="w-full rounded-md border border-gray-800 bg-gray-900 px-3 py-2 text-sm"
- >
- <option value="moderator">Moderator</option>
- <option value="viewer">Viewer (Read-only)</option>
- </select>
- </div>
- </div>
- <DialogFooter>
- <Button
- variant="outline"
- className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300"
- >
- Cancel
- </Button>
- <Button className="bg-purple-600 text-white hover:bg-purple-700">
- Send Invitation
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </div>
-
- <ScrollArea className="h-[300px] rounded-md border border-gray-800">
- <div className="p-4 space-y-3">
- {selectedTeam.members.map((member) => (
- <div key={member.id} className="flex items-center justify-between p-3 rounded-md bg-gray-900">
- <div className="flex items-center gap-3">
- <Avatar className="h-10 w-10 border border-gray-800">
- <AvatarImage src={member.avatar || ""} />
- <AvatarFallback className="bg-gray-800 text-gray-400">
- {member.name.substring(1, 3).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <div>
- <p className="font-medium">{member.name}</p>
- <div className="flex items-center gap-2">
- <Badge
- className={
- member.role === "owner"
- ? "bg-purple-900/50 text-purple-300"
- : "bg-blue-900/50 text-blue-300"
- }
- >
- {member.role}
- </Badge>
- <span className="text-xs text-gray-500">
- Joined {new Date(member.joined).toLocaleDateString()}
- </span>
- </div>
- </div>
- </div>
- {member.role !== "owner" && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
- <span className="sr-only">Open menu</span>
- <Settings className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="bg-gray-900 border-gray-800">
- <DropdownMenuItem className="text-yellow-400 focus:text-yellow-400 focus:bg-yellow-900/20">
- <Settings className="mr-2 h-4 w-4" />
- Change Role
- </DropdownMenuItem>
- <DropdownMenuItem className="text-red-400 focus:text-red-400 focus:bg-red-900/20">
- <LogOut className="mr-2 h-4 w-4" />
- Remove from Team
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
- ))}
- </div>
- </ScrollArea>
- </TabsContent>
-
- <TabsContent value="rooms" className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-sm font-medium text-gray-400">Protected Rooms</h3>
- <AddProtectedRoom filled />
- </div>
-
- <ScrollArea className="h-[300px] rounded-md border border-gray-800">
- <ProtectedRomsList team={selectedTeam} />
- </ScrollArea>
- </TabsContent>
-
- <TabsContent value="settings" className="space-y-4">
- <div className="space-y-4">
- <div className="space-y-2">
- <Label htmlFor="bot-name">Bot Name</Label>
- <Input id="bot-name" defaultValue={selectedTeam.name} className="bg-gray-900 border-gray-800" />
- </div>
- <div className="pt-4 flex justify-between">
- <Button variant="outline" className="border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300">
- <Trash2 className="mr-2 h-4 w-4" />
- Delete Bot
- </Button>
- <Button className="bg-purple-600 text-white hover:bg-purple-700">Save Changes</Button>
- </div>
- </div>
- </TabsContent>
- </Tabs>
- </CardContent>
- </Card>
- </>
- )
+ redirect(redirectUrl)
}
diff --git a/src/app/dashboard/settings/protectedRooms/page.tsx b/src/app/dashboard/settings/protectedRooms/page.tsx
@@ -0,0 +1,36 @@
+import { listBots } from "@/lib/api";
+import { User } from "@/lib/auth";
+import { cookies } from "next/headers";
+import { mockTeams } from "../../mockData";
+import SettingsLayout from "../settingsLayout";
+import AddProtectedRoom from "@/components/modals/add-protected-room";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import ProtectedRomsList from "@/components/dashboard/protected-rooms-list";
+
+export default async function ProtectedRoomsSettingsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const teamIdParam = (await searchParams).team as string | undefined;
+ const cookieStore = await cookies()
+ const session: User = JSON.parse(cookieStore.get("session")?.value || "{}")
+ if (!session.token) {
+ return <div className="flex h-screen w-full items-center justify-center">Loading...</div>
+ }
+ const listData = await listBots(session.token);
+ const selectedTeam = mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0];
+
+ return (
+ <SettingsLayout currentTab={"rooms"} listData={listData} selectedTeam={selectedTeam} teamIdParam={teamIdParam}>
+ <div className="flex items-center justify-between">
+ <h3 className="text-sm font-medium text-gray-400">Protected Rooms</h3>
+ <AddProtectedRoom filled />
+ </div>
+
+ <ScrollArea className="h-[300px] rounded-md border border-gray-800">
+ <ProtectedRomsList team={selectedTeam} />
+ </ScrollArea>
+ </SettingsLayout>
+ )
+}
+\ No newline at end of file
diff --git a/src/app/dashboard/settings/settingsLayout.tsx b/src/app/dashboard/settings/settingsLayout.tsx
@@ -0,0 +1,54 @@
+import LayoutWrapper from "@/components/dashboard/layoutWrapper"
+import TabNavigation from "@/components/dashboard/tab-navigation"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ListResponse } from "@/lib/api";
+import { Team } from "../mockData";
+import Link from "next/link";
+
+export default function SettingsLayout({
+ children,
+ currentTab,
+ listData,
+ teamIdParam,
+ selectedTeam,
+}: {
+ children: React.ReactNode;
+ currentTab: string;
+ listData?: ListResponse;
+ teamIdParam?: string;
+ selectedTeam: Team;
+}) {
+ return (
+ <LayoutWrapper listData={listData} activeTab="settings" teamIdParam={teamIdParam}>
+ <TabNavigation selectedTeam={selectedTeam} currentTab="settings" />
+ <Card className="border-gray-800 bg-gray-950">
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>Team Management</CardTitle>
+ <CardDescription className="text-gray-400">Manage your bots and team members</CardDescription>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-2">
+ <Tabs defaultValue={currentTab} className="space-y-4">
+ <TabsList className="bg-gray-900">
+ <TabsTrigger value="members" asChild>
+ <Link href={`/dashboard/settings/members?team=${selectedTeam.id}`}>Team Members</Link>
+ </TabsTrigger>
+ <TabsTrigger value="rooms" asChild>
+ <Link href={`/dashboard/settings/protectedRooms?team=${selectedTeam.id}`}>Protected Rooms</Link>
+ </TabsTrigger>
+ <TabsTrigger value="settings" asChild>
+ <Link href={`/dashboard/settings/bot?team=${selectedTeam.id}`}>Bot Settings</Link>
+ </TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {children}
+ </CardContent>
+ </Card>
+ </LayoutWrapper>
+ )
+}
+\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import "@/app/globals.css"
import { Inter } from "next/font/google"
import { ThemeProvider } from "@/components/theme-provider"
import { SessionProvider } from "@/contexts/session-context"
+import { Suspense } from "react"
const inter = Inter({ subsets: ["latin"] })
@@ -22,9 +23,11 @@ export default function RootLayout({
<html lang="en" className="dark" style={{ colorScheme: "dark" }}>
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
- <SessionProvider>
- {children}
- </SessionProvider>
+ <Suspense>
+ <SessionProvider>
+ {children}
+ </SessionProvider>
+ </Suspense>
</ThemeProvider>
</body>
</html>
diff --git a/src/components/dashboard/bot-selector.tsx b/src/components/dashboard/bot-selector.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { ChevronDown, Crown } from "lucide-react";
+import { Button } from "../ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
+import CreateBotModal from "../modals/create-bot";
+import Link from "next/link";
+import { ListResponse } from "@/lib/api";
+
+export default function BotSelector({
+ selectedTeam,
+ listData
+}: {
+ selectedTeam?: string;
+ listData: ListResponse | undefined;
+}) {
+ if (!listData) {
+ return <div className="flex h-full w-full items-center justify-center">Loading...</div>
+ }
+
+ const bots = listData.bots;
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" className="hidden md:flex border-purple-500 text-purple-400 hover:bg-purple-950">
+ <Crown className="mr-2 h-4 w-4" />
+ {bots.find((bot) => bot.id === selectedTeam)?.displayName || "Select a Bot"}
+ <ChevronDown className="ml-2 h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-56 bg-gray-900 border-gray-800">
+ <DropdownMenuLabel>Your Bots</DropdownMenuLabel>
+ <DropdownMenuSeparator className="bg-gray-800" />
+ {bots.map((bot) => (
+ <Link key={bot.id} href={{
+ query: { team: bot.id },
+ }}>
+ <DropdownMenuItem
+ className={selectedTeam === bot.id ? "bg-gray-800" : ""}
+ >
+ {bot.displayName}
+ </DropdownMenuItem>
+ </Link>
+ ))}
+ <DropdownMenuSeparator className="bg-gray-800" />
+ <CreateBotModal />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+\ No newline at end of file
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
@@ -1,135 +1,26 @@
-"use client"
-
-import { useState, useEffect } from "react"
+import { Suspense } from "react"
import Link from "next/link"
-import { usePathname, useRouter, useSearchParams } from "next/navigation"
-import { Shield, Bell, Menu, Ban, LogOut, Crown, ChevronDown, Plus, X } from "lucide-react"
-
+import { Shield, Menu } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Separator } from "@/components/ui/separator"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { useSession } from "@/contexts/session-context"
import AddBan from "../modals/add-ban"
import KickUserModal from "../modals/kick-user"
-import CreateBotModal from "../modals/create-bot"
-import { mockPolicyLists, Team } from "../../app/dashboard/mockData"
+import { mockPolicyLists } from "../../app/dashboard/mockData"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
-
-interface Notification {
- id: string
- type: string
- title: string
- message: string
- timestamp: string
- read: boolean
- teamId?: string
-}
+import BotSelector from "./bot-selector"
+import Logout from "./logout"
+import Notifications from "./notifications"
+import { ListResponse } from "@/lib/api"
interface DashboardHeaderProps {
- teams: Team[]
+ activeTab: "overview" | "reports" | "bans" | "analytics" | "settings"
+ teamIdParam?: string
+ listData: ListResponse | undefined
}
-export function DashboardHeader({ teams }: DashboardHeaderProps) {
- const { logout } = useSession()
- const pathname = usePathname()
- const router = useRouter()
- const searchParams = useSearchParams()
-
- // Get team ID from query params or use default
- const teamIdParam = searchParams.get("team")
- const [selectedTeam, setSelectedTeam] = useState<Team>(teams.find((t) => t.id === teamIdParam) || teams[0])
-
- const policyLists = mockPolicyLists.filter((list) => list.teamId === selectedTeam.id)
-
- // Determine active tab from the URL
- const getActiveTabFromPath = (path: string) => {
- if (path.includes("/dashboard/reports")) return "reports"
- if (path.includes("/dashboard/bans")) return "bans"
- if (path.includes("/dashboard/analytics")) return "analytics"
- if (path.includes("/dashboard/settings")) return "settings"
- return "overview"
- }
-
- const activeTab = getActiveTabFromPath(pathname)
-
- // Update selected team when URL param changes
- useEffect(() => {
- const team = teams.find((t) => t.id === teamIdParam)
- if (team) {
- setSelectedTeam(team)
- }
- }, [teamIdParam, teams])
-
- const [notifications, setNotifications] = useState<Notification[]>([
- {
- id: "notif1",
- type: "report",
- title: "New report",
- message: "A new message has been reported in #general",
- timestamp: "2025-04-21T15:30:00Z",
- read: false,
- teamId: "team1",
- },
- {
- id: "notif2",
- type: "ban",
- title: "User banned",
- message: "@spammer:matrix.org has been banned by @mod1:matrix.org",
- timestamp: "2025-04-21T14:45:00Z",
- read: false,
- teamId: "team1",
- },
- {
- id: "notif3",
- type: "invite",
- title: "Team invitation",
- message: "You've been invited to join the Support Team Bot",
- timestamp: "2025-04-21T12:15:00Z",
- read: true,
- teamId: "team2",
- },
- ])
-
- const teamNotifications = notifications.filter((n) => n.teamId === selectedTeam.id || !n.teamId)
- const unreadCount = teamNotifications.filter((n) => !n.read).length
-
- const markNotificationAsRead = (id: string) => {
- setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))
- }
-
- const clearAllNotifications = () => {
- setNotifications(notifications.map((n) => ({ ...n, read: true })))
- }
-
- const deleteNotification = (id: string) => {
- setNotifications(notifications.filter((n) => n.id !== id))
- }
-
- const handleLogout = async () => {
- await logout()
- }
-
- const handleTeamChange = (teamId: string) => {
- const team = teams.find((t) => t.id === teamId)
- if (team) {
- setSelectedTeam(team)
-
- // Update URL with team parameter
- const params = new URLSearchParams(searchParams)
- params.set("team", teamId)
- router.push(`${pathname}?${params.toString()}`)
- }
- }
+export function DashboardHeader({ activeTab, teamIdParam, listData }: DashboardHeaderProps) {
+ const policyLists = mockPolicyLists.filter((list) => list.teamId === teamIdParam)
return (
<header className="sticky top-0 z-10 border-b border-gray-800 bg-black/80 backdrop-blur-sm">
@@ -141,132 +32,20 @@ export function DashboardHeader({ teams }: DashboardHeaderProps) {
</Link>
{/* Global Team Selector */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="outline" className="hidden md:flex border-purple-500 text-purple-400 hover:bg-purple-950">
- <Crown className="mr-2 h-4 w-4" />
- {selectedTeam.name}
- <ChevronDown className="ml-2 h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-56 bg-gray-900 border-gray-800">
- <DropdownMenuLabel>Your Bots</DropdownMenuLabel>
- <DropdownMenuSeparator className="bg-gray-800" />
- {teams.map((team) => (
- <DropdownMenuItem
- key={team.id}
- className={selectedTeam.id === team.id ? "bg-gray-800" : ""}
- onClick={() => handleTeamChange(team.id)}
- >
- {team.name}
- </DropdownMenuItem>
- ))}
- <DropdownMenuSeparator className="bg-gray-800" />
- <CreateBotModal />
- </DropdownMenuContent>
- </DropdownMenu>
+ <Suspense>
+ <BotSelector listData={listData} selectedTeam={teamIdParam} />
+ </Suspense>
</div>
<div className="flex items-center gap-4">
- {/* Notifications */}
- <Popover>
- <PopoverTrigger asChild>
- <Button variant="ghost" size="icon" className="relative text-gray-400 hover:text-white">
- <Bell className="h-5 w-5" />
- {unreadCount > 0 && (
- <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium text-white">
- {unreadCount}
- </span>
- )}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-80 p-0 bg-gray-900 border-gray-800">
- <div className="flex items-center justify-between border-b border-gray-800 p-3">
- <h3 className="font-medium">Notifications</h3>
- <div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="sm"
- className="h-7 px-2 text-xs text-gray-400 hover:text-white"
- onClick={clearAllNotifications}
- >
- Clear all
- </Button>
- </div>
- </div>
- <ScrollArea className="h-[300px]">
- {teamNotifications.length > 0 ? (
- <div className="space-y-1 p-1">
- {teamNotifications.map((notification) => (
- <div
- key={notification.id}
- className={`flex items-start p-3 rounded-md ${notification.read ? "bg-gray-900" : "bg-gray-800"}`}
- onClick={() => markNotificationAsRead(notification.id)}
- >
- <div
- className={`rounded-full p-1.5 mr-3 ${notification.type === "report"
- ? "bg-yellow-500/20"
- : notification.type === "ban"
- ? "bg-red-500/20"
- : "bg-blue-500/20"
- }`}
- >
- {notification.type === "report" ? (
- <Bell className="h-3.5 w-3.5 text-yellow-500" />
- ) : notification.type === "ban" ? (
- <Ban className="h-3.5 w-3.5 text-red-500" />
- ) : (
- <Plus className="h-3.5 w-3.5 text-blue-500" />
- )}
- </div>
- <div className="flex-1 min-w-0">
- <div className="flex items-center justify-between">
- <p className="text-sm font-medium">{notification.title}</p>
- <Button
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0 text-gray-500 hover:text-gray-300"
- onClick={(e) => {
- e.stopPropagation()
- deleteNotification(notification.id)
- }}
- >
- <X className="h-3 w-3" />
- <span className="sr-only">Dismiss</span>
- </Button>
- </div>
- <p className="text-xs text-gray-400 line-clamp-2">{notification.message}</p>
- <p className="text-xs text-gray-500 mt-1">
- {new Date(notification.timestamp).toLocaleString()}
- </p>
- </div>
- </div>
- ))}
- </div>
- ) : (
- <div className="flex flex-col items-center justify-center h-full p-6 text-center">
- <Bell className="h-8 w-8 text-gray-600 mb-2" />
- <p className="text-gray-400">No notifications</p>
- </div>
- )}
- </ScrollArea>
- </PopoverContent>
- </Popover>
+ <Notifications teamIdParam={teamIdParam} />
{/* Quick Action Buttons */}
<AddBan header policyLists={policyLists} />
<KickUserModal />
- <Button
- variant="outline"
- size="sm"
- className="hidden md:flex border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300"
- onClick={handleLogout}
- >
- <LogOut className="mr-2 h-4 w-4" />
- Log Out
- </Button>
+ <Logout />
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
@@ -285,31 +64,7 @@ export function DashboardHeader({ teams }: DashboardHeaderProps) {
</div>
</div>
<div className="p-4">
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="w-full border-purple-500 text-purple-400 hover:bg-purple-950"
- >
- <Crown className="mr-2 h-4 w-4" />
- {selectedTeam.name}
- <ChevronDown className="ml-2 h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="bg-gray-900 border-gray-800">
- <DropdownMenuLabel>Your Bots</DropdownMenuLabel>
- <DropdownMenuSeparator className="bg-gray-800" />
- {teams.map((team) => (
- <DropdownMenuItem
- key={team.id}
- className={selectedTeam.id === team.id ? "bg-gray-800" : ""}
- onClick={() => handleTeamChange(team.id)}
- >
- {team.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
+ <BotSelector listData={listData} selectedTeam={teamIdParam} />
</div>
<nav className="flex flex-col p-4 gap-2">
<Link href="/dashboard/overview">
@@ -361,15 +116,7 @@ export function DashboardHeader({ teams }: DashboardHeaderProps) {
<KickUserModal />
</nav>
<div className="mt-auto border-t border-gray-800 p-4">
- <Button
- variant="outline"
- size="sm"
- className="w-full border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300"
- onClick={handleLogout}
- >
- <LogOut className="mr-2 h-4 w-4" />
- Log Out
- </Button>
+ <Logout />
</div>
</div>
</SheetContent>
diff --git a/src/components/dashboard/layoutWrapper.tsx b/src/components/dashboard/layoutWrapper.tsx
@@ -0,0 +1,35 @@
+import { Suspense } from "react";
+import { DashboardHeader } from "./dashboard-header";
+import { mockTeams } from "@/app/dashboard/mockData";
+import { ListResponse } from "@/lib/api";
+
+interface LayoutWrapperProps {
+ children: React.ReactNode;
+ activeTab: "overview" | "reports" | "bans" | "analytics" | "settings";
+ teamIdParam?: string;
+ listData: ListResponse | undefined;
+}
+
+export default function LayoutWrapper({ children, activeTab, teamIdParam, listData }: LayoutWrapperProps) {
+ // Get first team id if there is no teamIdParam
+ const teamId = teamIdParam || mockTeams[0]?.id;
+
+ return (
+ <div className="flex min-h-screen flex-col bg-black text-white">
+ <Suspense
+ fallback={
+ <div className="flex h-16 items-center justify-between border-b border-gray-800 bg-black/80 backdrop-blur-sm">
+ Loading...
+ </div>
+ }
+ >
+ <DashboardHeader listData={listData} activeTab={activeTab} teamIdParam={teamId} />
+ </Suspense>
+ <main className="flex-1 container px-4 py-8 md:px-6 md:py-12">
+ <Suspense fallback={<div className="flex h-screen w-full items-center justify-center">Loading...</div>}>
+ {children}
+ </Suspense>
+ </main>
+ </div>
+ )
+}
+\ No newline at end of file
diff --git a/src/components/dashboard/logout.tsx b/src/components/dashboard/logout.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { LogOut } from "lucide-react";
+import { Button } from "../ui/button";
+import { useSession } from "@/contexts/session-context";
+
+export default function Logout() {
+ const { logout } = useSession()
+
+ const handleLogout = async () => {
+ await logout()
+ }
+
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ className="hidden md:flex border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300"
+ onClick={handleLogout}
+ >
+ <LogOut className="mr-2 h-4 w-4" />
+ Log Out
+ </Button>
+ )
+}
+\ No newline at end of file
diff --git a/src/components/dashboard/notifications.tsx b/src/components/dashboard/notifications.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { Ban, Bell, Plus, X } from "lucide-react";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import { ScrollArea } from "../ui/scroll-area";
+import { Button } from "../ui/button";
+import { useState } from "react";
+
+interface Notification {
+ id: string
+ type: string
+ title: string
+ message: string
+ timestamp: string
+ read: boolean
+ teamId?: string
+}
+
+export default function Notifications({ teamIdParam }: { teamIdParam?: string }) {
+ const [notifications, setNotifications] = useState<Notification[]>([
+ {
+ id: "notif1",
+ type: "report",
+ title: "New report",
+ message: "A new message has been reported in #general",
+ timestamp: "2025-04-21T15:30:00Z",
+ read: false,
+ teamId: "team1",
+ },
+ {
+ id: "notif2",
+ type: "ban",
+ title: "User banned",
+ message: "@spammer:matrix.org has been banned by @mod1:matrix.org",
+ timestamp: "2025-04-21T14:45:00Z",
+ read: false,
+ teamId: "team1",
+ },
+ {
+ id: "notif3",
+ type: "invite",
+ title: "Team invitation",
+ message: "You've been invited to join the Support Team Bot",
+ timestamp: "2025-04-21T12:15:00Z",
+ read: true,
+ teamId: "team2",
+ },
+ ])
+
+ const teamNotifications = notifications.filter((n) => n.teamId === teamIdParam || !n.teamId)
+ const unreadCount = teamNotifications.filter((n) => !n.read).length
+
+ const markNotificationAsRead = (id: string) => {
+ setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))
+ }
+
+ const clearAllNotifications = () => {
+ setNotifications(notifications.map((n) => ({ ...n, read: true })))
+ }
+
+ const deleteNotification = (id: string) => {
+ setNotifications(notifications.filter((n) => n.id !== id))
+ }
+
+ return (
+ <>
+ {/* Notifications */}
+ < Popover >
+ <PopoverTrigger asChild>
+ <Button variant="ghost" size="icon" className="relative text-gray-400 hover:text-white">
+ <Bell className="h-5 w-5" />
+ {unreadCount > 0 && (
+ <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium text-white">
+ {unreadCount}
+ </span>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-80 p-0 bg-gray-900 border-gray-800">
+ <div className="flex items-center justify-between border-b border-gray-800 p-3">
+ <h3 className="font-medium">Notifications</h3>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 px-2 text-xs text-gray-400 hover:text-white"
+ onClick={clearAllNotifications}
+ >
+ Clear all
+ </Button>
+ </div>
+ </div>
+ <ScrollArea className="h-[300px]">
+ {teamNotifications.length > 0 ? (
+ <div className="space-y-1 p-1">
+ {teamNotifications.map((notification) => (
+ <div
+ key={notification.id}
+ className={`flex items-start p-3 rounded-md ${notification.read ? "bg-gray-900" : "bg-gray-800"}`}
+ onClick={() => markNotificationAsRead(notification.id)}
+ >
+ <div
+ className={`rounded-full p-1.5 mr-3 ${notification.type === "report"
+ ? "bg-yellow-500/20"
+ : notification.type === "ban"
+ ? "bg-red-500/20"
+ : "bg-blue-500/20"
+ }`}
+ >
+ {notification.type === "report" ? (
+ <Bell className="h-3.5 w-3.5 text-yellow-500" />
+ ) : notification.type === "ban" ? (
+ <Ban className="h-3.5 w-3.5 text-red-500" />
+ ) : (
+ <Plus className="h-3.5 w-3.5 text-blue-500" />
+ )}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center justify-between">
+ <p className="text-sm font-medium">{notification.title}</p>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 text-gray-500 hover:text-gray-300"
+ onClick={(e) => {
+ e.stopPropagation()
+ deleteNotification(notification.id)
+ }}
+ >
+ <X className="h-3 w-3" />
+ <span className="sr-only">Dismiss</span>
+ </Button>
+ </div>
+ <p className="text-xs text-gray-400 line-clamp-2">{notification.message}</p>
+ <p className="text-xs text-gray-500 mt-1">
+ {new Date(notification.timestamp).toLocaleString()}
+ </p>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="flex flex-col items-center justify-center h-full p-6 text-center">
+ <Bell className="h-8 w-8 text-gray-600 mb-2" />
+ <p className="text-gray-400">No notifications</p>
+ </div>
+ )}
+ </ScrollArea>
+ </PopoverContent>
+ </Popover >
+ </>
+ )
+}
+\ No newline at end of file
diff --git a/src/contexts/session-context.tsx b/src/contexts/session-context.tsx
@@ -27,25 +27,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const [accessToken, setAccessToken] = useState<string | undefined>();
const [openidExpiration, setOpenIDExpiration] = useState<number | undefined>();
- // Restore session from cookies on initial load
- useEffect(() => {
- const fetchSession = async () => {
- try {
- const response = await fetch("/api/session");
- if (response.ok) {
- const userData = await response.json();
- setUser(userData);
- }
- } catch (error) {
- console.error("Failed to restore session:", error);
- } finally {
- setIsLoading(false);
- }
- };
-
- fetchSession();
- }, []);
-
const refreshOpenIDToken = useCallback(async () => {
if (!homeserverUrl || !accessToken || !user) {
throw new Error("Missing required information to refresh OpenID token.");
@@ -58,7 +39,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const response = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ openidToken }),
+ body: JSON.stringify({ openidToken, homeserverUrl, matrixId: user.matrixId }),
});
if (!response.ok) {
@@ -73,6 +54,43 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}
}, [homeserverUrl, accessToken, user]);
+ // Restore session from cookies on initial load
+ useEffect(() => {
+ const fetchSession = async () => {
+ try {
+ if (openidExpiration) {
+ const now = Date.now();
+ const refreshTime = openidExpiration - 60 * 1000; // Refresh 1 minute before expiration
+
+ if (refreshTime > now) {
+ const timeout = setTimeout(() => {
+ refreshOpenIDToken().catch((err) => {
+ console.error("Failed to refresh OpenID token:", err);
+ });
+ }, refreshTime - now);
+
+ return () => clearTimeout(timeout);
+ }
+ }
+ const response = await fetch("/api/session");
+ if (response.ok) {
+ const userData: User = await response.json();
+ setUser(userData);
+ setAccessToken(userData.token);
+ setHomeserverUrl(userData.homeserverUrl);
+ }
+ } catch (error) {
+ console.error("Failed to restore session:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchSession();
+ }, [openidExpiration, refreshOpenIDToken]);
+
+
+
// Automatically refresh OpenID token before it expires
useEffect(() => {
if (openidExpiration) {
@@ -155,7 +173,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const response = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ openidToken }),
+ body: JSON.stringify({ openidToken, homeserverUrl, matrixId }),
});
if (!response.ok) {
@@ -196,7 +214,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const response = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ openidToken }),
+ body: JSON.stringify({ openidToken, homeserverUrl, matrixId }),
});
if (!response.ok) {
diff --git a/src/lib/api.ts b/src/lib/api.ts
@@ -0,0 +1,41 @@
+'use server'
+
+export interface ListResponse {
+ bots: {
+ id: string,
+ managementRoom: string,
+ ownerID: string,
+ displayName: string,
+ }[]
+}
+
+export async function listBots(token: string): Promise<ListResponse | undefined> {
+ const url = new URL("/api/1/appservice/list", process.env.NEXT_PUBLIC_D4ALL_INSTANCE_ADDRESS);
+ const res = await fetch(url, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Accept": "application/json",
+ },
+ });
+ if (!res.ok) {
+ const resp: {
+ error?: string;
+ errcode?: string;
+ } = await res.json();
+ if (resp.error) {
+ console.error("Failed to fetch teams:", res.status, resp.errcode, resp.error);
+ } else {
+ console.error("Failed to fetch teams:", res.status, res.statusText);
+ }
+ // TODO: Handle error
+ return undefined;
+ }
+ const data = await res.json();
+ if (!data.bots) {
+ console.error("Invalid response:", data);
+ // Handle error
+ return undefined;
+ }
+ return data as ListResponse;
+}
+\ No newline at end of file
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
@@ -7,6 +7,7 @@ export type User = {
avatarUrl?: string
isAdmin: boolean
token: string
+ homeserverUrl: string
}
export function createSession(user: User) {
diff --git a/src/lib/clientSideAuth.ts b/src/lib/clientSideAuth.ts
@@ -33,6 +33,9 @@ interface OpenIDTokenResponse {
}
export async function generateOpenIDToken(homeserverUrl: string, username: string, accessToken: string): Promise<OpenIDTokenResponse> {
+ if (!accessToken) {
+ throw new Error('Access token is required');
+ }
const url = new URL("/_matrix/client/v3/user/" + encodeURIComponent(username) + "/openid/request_token", homeserverUrl);
const response = await fetch(url, {
method: 'POST',