draupnir4all-web

git clone git://archive.git.mtrnord.blog/MTRNord/draupnir4all-web.git
Log | Files | Refs | README | LICENSE

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:
Mnext.config.ts | 1+
Msrc/app/api/session/route.ts | 15+++++++++++----
Msrc/app/dashboard/analytics/page.tsx | 7++++---
Msrc/app/dashboard/bans/page.tsx | 9+++++----
Msrc/app/dashboard/layout.tsx | 21+++------------------
Msrc/app/dashboard/overview/page.tsx | 26++++++++++++++++++--------
Msrc/app/dashboard/reports/page.tsx | 7++++---
Asrc/app/dashboard/settings/bot/page.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/app/dashboard/settings/members/page.tsx | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/dashboard/settings/page.tsx | 208++++---------------------------------------------------------------------------
Asrc/app/dashboard/settings/protectedRooms/page.tsx | 37+++++++++++++++++++++++++++++++++++++
Asrc/app/dashboard/settings/settingsLayout.tsx | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/layout.tsx | 9++++++---
Asrc/components/dashboard/bot-selector.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/dashboard/dashboard-header.tsx | 291++++++-------------------------------------------------------------------------
Asrc/components/dashboard/layoutWrapper.tsx | 36++++++++++++++++++++++++++++++++++++
Asrc/components/dashboard/logout.tsx | 26++++++++++++++++++++++++++
Asrc/components/dashboard/notifications.tsx | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/contexts/session-context.tsx | 62++++++++++++++++++++++++++++++++++++++++----------------------
Asrc/lib/api.ts | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/auth.ts | 1+
Msrc/lib/clientSideAuth.ts | 3+++
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',