draupnir4all-web

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

commit 8e17c300a656a82a01cff2470210834516d8c96f
parent 6fe6a9ae55a6791f597edff226594439979a96cc
Author: MTRNord <mtrnord1@gmail.com>
Date:   Mon, 28 Apr 2025 20:48:24 +0200

Prepare login being fully client side. However this will require future changes for the SSR sadly.

Signed-off-by: MTRNord <mtrnord1@gmail.com>

Diffstat:
Dsrc/app/api/auth/login/route.ts | 32--------------------------------
Dsrc/app/api/auth/logout/route.ts | 16----------------
Dsrc/app/api/auth/register/route.ts | 32--------------------------------
Dsrc/app/api/auth/session/route.ts | 17-----------------
Dsrc/app/api/verify-matrix-token/route.ts | 68--------------------------------------------------------------------
Msrc/app/dashboard/analytics/page.tsx | 7++++++-
Asrc/app/dashboard/bans/loading.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/dashboard/bans/page.tsx | 7++++++-
Msrc/app/dashboard/overview/page.tsx | 35++++++++++++++---------------------
Msrc/app/dashboard/page.tsx | 17++++++++++-------
Asrc/app/dashboard/reports/loading.tsx | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/dashboard/reports/page.tsx | 7++++++-
Msrc/app/dashboard/settings/page.tsx | 7++++++-
Msrc/app/layout.tsx | 6+++++-
Msrc/app/login/page.tsx | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/app/page.tsx | 2+-
Dsrc/components/matrix-login-form.tsx | 240-------------------------------------------------------------------------------
Msrc/contexts/session-context.tsx | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/lib/auth.ts | 95+++++++++++++++----------------------------------------------------------------
Dsrc/middleware.ts | 34----------------------------------
20 files changed, 484 insertions(+), 613 deletions(-)

diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts @@ -1,32 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server" -import { createSession } from "@/lib/auth" - -export async function POST(request: NextRequest) { - try { - const { matrixId } = await request.json() - - if (!matrixId) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) - } - - // In a real app, you would verify the user's credentials with Matrix - // For demo purposes, we'll create a mock user - const user = { - id: "user_" + Math.random().toString(36).substring(2, 9), - matrixId, - displayName: matrixId.split(":")[0].substring(1), - isAdmin: matrixId.includes("admin"), - } - - // Create a session token - await createSession(user) - - // Create a response - const response = NextResponse.json({ success: true, user }) - - return response - } catch (error) { - console.error("Login failed:", error) - return NextResponse.json({ error: "Authentication failed" }, { status: 500 }) - } -} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server" -import { deleteSession } from "@/lib/auth" - -export async function POST() { - try { - // Create a response - const response = NextResponse.json({ success: true }) - - // Clear the session cookie - await deleteSession() - return response - } catch (error) { - console.error("Logout failed:", error) - return NextResponse.json({ error: "Logout failed" }, { status: 500 }) - } -} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts @@ -1,32 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server" -import { createSession } from "@/lib/auth" - -export async function POST(request: NextRequest) { - try { - const { matrixId } = await request.json() - - if (!matrixId) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) - } - - // In a real app, you would register the user with Matrix - // For demo purposes, we'll create a mock user - const user = { - id: "user_" + Math.random().toString(36).substring(2, 9), - matrixId, - displayName: matrixId.split(":")[0].substring(1), - isAdmin: false, - } - - // Create a session token - await createSession(user) - - // Create a response - const response = NextResponse.json({ success: true, user }) - - return response - } catch (error) { - console.error("Registration failed:", error) - return NextResponse.json({ error: "Registration failed" }, { status: 500 }) - } -} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server" -import { getSessionUser } from "@/lib/auth" - -export async function GET() { - try { - const user = await getSessionUser() - - if (!user) { - return NextResponse.json({ authenticated: false }, { status: 401 }) - } - - return NextResponse.json({ authenticated: true, user }) - } catch (error) { - console.error("Session check failed:", error) - return NextResponse.json({ authenticated: false }, { status: 500 }) - } -} diff --git a/src/app/api/verify-matrix-token/route.ts b/src/app/api/verify-matrix-token/route.ts @@ -1,68 +0,0 @@ -import { NextResponse } from "next/server" - -interface WellKnownResponse { - "m.homeserver"?: { - base_url: string - } -} - -export async function POST(request: Request) { - try { - const { matrixId, token } = await request.json() - - if (!matrixId || !token) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) - } - - if (!matrixId.includes(":")) { - return NextResponse.json({ error: "Invalid Matrix ID format" }, { status: 400 }) - } - - // Extract server name from Matrix ID - const serverName = matrixId.split(":").pop() - - // Discover homeserver URL - let homeserverUrl = `https://${serverName}` - - try { - // Try to fetch well-known data - const wellKnownUrl = `https://${serverName}/.well-known/matrix/client` - const response = await fetch(wellKnownUrl) - - if (response.ok) { - const data = (await response.json()) as WellKnownResponse - if (data["m.homeserver"]?.base_url) { - // TODO: Fix me - // eslint-disable-next-line @typescript-eslint/no-unused-vars - homeserverUrl = data["m.homeserver"].base_url - } - } - } catch (e) { - console.warn("Well-known discovery failed, falling back to direct server", e) - } - - // In a real implementation, you would: - // 1. Make a request to the Matrix server to verify the token - // const response = await fetch( - // `${homeserverUrl}/_matrix/federation/v1/openid/userinfo?access_token=${token}`, - // { method: "GET" } - // ) - // const data = await response.json() - - // 2. Verify that the user ID in the response matches the provided matrixId - // if (data.sub !== matrixId) { - // return NextResponse.json( - // { error: "Token verification failed" }, - // { status: 401 } - // ) - // } - - // 3. Create a user account or session in your system - - // For demo purposes, we'll just return success - return NextResponse.json({ success: true }) - } catch (error) { - console.error("Error verifying Matrix token:", error) - return NextResponse.json({ error: "Internal server error" }, { status: 500 }) - } -} diff --git a/src/app/dashboard/analytics/page.tsx b/src/app/dashboard/analytics/page.tsx @@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Heatmap } from "@/components/analytics/heatmap" -import { useSearchParams } from "next/navigation" +import { redirect, useSearchParams } from "next/navigation" import { mockTeams } from "../mockData" import TabNavigation from "../../../components/dashboard/tab-navigation"; import { BannedServerData, generateHeatmapData, getBannedServersConfig, getMonthlyActivityConfig, getReportTypesConfig, getRoomActivityConfig, MonthlyActivityData, ReportTypeData, RoomActivityData } from "@/components/analytics/chart-configs"; @@ -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 { useSession } from "@/contexts/session-context"; // Mock data for charts const roomActivityData: RoomActivityData[] = [ @@ -64,6 +65,10 @@ export default function AnalyticsDashboard() { const teamIdParam = searchParams.get("team") const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0]) + const { user } = useSession() + if (!user) { + redirect("/login") + } // Update selected team when URL param changes useEffect(() => { diff --git a/src/app/dashboard/bans/loading.tsx b/src/app/dashboard/bans/loading.tsx @@ -0,0 +1,60 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent, CardHeader } from "@/components/ui/card" + +export default function Loading() { + return ( + <div> + {/* Tab Navigation Skeleton */} + <div className="flex items-center justify-between mb-8"> + <Skeleton className="h-9 w-64" /> + <div className="hidden md:block"> + <Skeleton className="h-10 w-[400px]" /> + </div> + </div> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <div className="flex items-center justify-between"> + <Skeleton className="h-6 w-32 mb-1" /> + <Skeleton className="h-9 w-24" /> {/* Add Ban button */} + </div> + <Skeleton className="h-4 w-64" /> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + {/* Policy Lists Column */} + <div className="space-y-4"> + <Skeleton className="h-5 w-32" /> + <div className="space-y-2"> + {Array(3) + .fill(0) + .map((_, i) => ( + <div key={i} className="p-3 rounded-md border border-gray-800 space-y-2"> + <div className="flex items-center justify-between mb-1"> + <Skeleton className="h-5 w-32" /> + <Skeleton className="h-4 w-4" /> {/* Edit icon */} + </div> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-16" /> + </div> + ))} + </div> + </div> + + {/* Ban Entries Columns (spans 2 columns) */} + <div className="md:col-span-2 space-y-4"> + <div className="flex items-center justify-between"> + <Skeleton className="h-5 w-32" /> + <div className="flex items-center gap-2"> + <Skeleton className="h-8 w-[200px]" /> {/* Search input */} + <Skeleton className="h-8 w-[120px]" /> {/* Filter dropdown */} + </div> + </div> + <Skeleton className="h-[400px] w-full rounded-md border border-gray-800" /> {/* Ban entries list */} + </div> + </div> + </CardContent> + </Card> + </div> + ) +} diff --git a/src/app/dashboard/bans/page.tsx b/src/app/dashboard/bans/page.tsx @@ -8,8 +8,9 @@ import { ChangeEvent, useEffect, useState } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { EBanTypes, mockPolicyLists, mockTeams, } from "../mockData"; import AddBan from "../../../components/modals/add-ban"; -import { useSearchParams } from "next/navigation"; +import { redirect, useSearchParams } from "next/navigation"; import TabNavigation from "../../../components/dashboard/tab-navigation"; +import { useSession } from "@/contexts/session-context"; export default function Bans() { @@ -17,6 +18,10 @@ export default function Bans() { const teamIdParam = searchParams.get("team") const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0]) const [searchTerm, setSearchTerm] = useState<string>(""); + const { user } = useSession() + if (!user) { + redirect("/login") + } // Update selected team when URL param changes useEffect(() => { diff --git a/src/app/dashboard/overview/page.tsx b/src/app/dashboard/overview/page.tsx @@ -1,36 +1,26 @@ +"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, PolicyList, Report, Team } from "../mockData" +import { mockPolicyLists, mockReports, mockTeams } from "../mockData" import TabNavigation from "../../../components/dashboard/tab-navigation"; +import { useSession } from "@/contexts/session-context" +import { redirect, useSearchParams } from "next/navigation" -interface OverviewPageProps { - selectedTeam: Team; - reports: Report[]; - policyLists: PolicyList[]; -} -async function fetchData(teamIdParam?: string): Promise<OverviewPageProps> { - "use cache"; - // TODO: Check if we really can cache the real data here +export default function OverviewPage() { + const searchParams = useSearchParams() + const { user } = useSession() + if (!user) { + redirect("/login") + } + const teamIdParam = searchParams.get("team"); 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 { - selectedTeam, - reports, - policyLists, - }; -} - -export default async function OverviewPage({ searchParams }: { searchParams: Promise<{ team?: string }> }) { - const { team } = await searchParams; - const { selectedTeam, reports, policyLists } = await fetchData(team); - return ( <> <TabNavigation selectedTeam={selectedTeam} currentTab="overview" /> @@ -77,6 +67,9 @@ export default async function OverviewPage({ searchParams }: { searchParams: Pro <div className="h-3 w-3 rounded-full bg-green-500"></div> <div className="text-sm font-medium">Online</div> </div> + if (!user) { + redirect("/login") + } <p className="text-xs text-gray-400">Last restart: 7d ago</p> </CardContent> </Card> diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx @@ -1,11 +1,14 @@ -import { redirect } from "next/navigation" +"use client" +import { useSession } from "@/contexts/session-context" +import { redirect, useSearchParams } from "next/navigation" -export default async function DashboardPage({ - searchParams, -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> -}) { - const teamId = (await searchParams).team as string | undefined +export default function DashboardPage() { + const { user } = useSession() + const searchParams = useSearchParams() + const teamId = searchParams.get("team") + if (!user) { + redirect("/login") + } // Redirect to overview with team parameter const redirectUrl = teamId ? `/dashboard/overview?team=${teamId}` : "/dashboard/overview" diff --git a/src/app/dashboard/reports/loading.tsx b/src/app/dashboard/reports/loading.tsx @@ -0,0 +1,96 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent, CardHeader } from "@/components/ui/card" + +export default function Loading() { + return ( + <div> + <div className="flex items-center justify-between mb-8"> + <Skeleton className="h-9 w-64" /> + <div className="hidden md:block"> + <Skeleton className="h-10 w-[400px]" /> + </div> + </div> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <div className="flex items-center justify-between"> + <Skeleton className="h-6 w-40" /> + <Skeleton className="h-8 w-32" /> + </div> + <Skeleton className="h-4 w-64 mt-1" /> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-4"> + <Skeleton className="h-5 w-32 mb-2" /> + + {Array(3) + .fill(0) + .map((_, i) => ( + <div key={i} className="p-3 rounded-md border border-gray-800 space-y-2"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + <Skeleton className="h-4 w-4" /> + <Skeleton className="h-5 w-32" /> + </div> + <Skeleton className="h-5 w-16" /> + </div> + <Skeleton className="h-4 w-48" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-32" /> + </div> + ))} + </div> + + <div className="space-y-4"> + <Skeleton className="h-5 w-32 mb-2" /> + + <div className="space-y-4"> + <div className="p-4 rounded-md bg-gray-900 border border-gray-800"> + <div className="flex items-center justify-between mb-3"> + <Skeleton className="h-5 w-40" /> + <Skeleton className="h-5 w-24" /> + </div> + + <div className="space-y-2"> + {Array(4) + .fill(0) + .map((_, i) => ( + <div key={i} className="flex justify-between"> + <Skeleton className="h-4 w-24" /> + <Skeleton className="h-4 w-32" /> + </div> + ))} + </div> + </div> + + <div className="p-4 rounded-md bg-gray-900 border border-gray-800"> + <Skeleton className="h-5 w-40 mb-3" /> + + <div className="space-y-3"> + {Array(3) + .fill(0) + .map((_, i) => ( + <div key={i} className="flex justify-between"> + <Skeleton className="h-4 w-24" /> + <Skeleton className="h-4 w-32" /> + </div> + ))} + + <Skeleton className="h-[100px] w-full mt-4" /> + </div> + </div> + + <div className="flex gap-2 mt-4"> + <Skeleton className="h-9 w-28" /> + <Skeleton className="h-9 w-28" /> + <Skeleton className="h-9 w-36" /> + </div> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + ) +} diff --git a/src/app/dashboard/reports/page.tsx b/src/app/dashboard/reports/page.tsx @@ -18,13 +18,18 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { mockReports, mockTeams } from "../mockData"; -import { useSearchParams } from "next/navigation" +import { redirect, useSearchParams } from "next/navigation" import TabNavigation from "../../../components/dashboard/tab-navigation" +import { useSession } from "@/contexts/session-context" export default function ReportsPage() { const searchParams = useSearchParams() const teamIdParam = searchParams.get("team") const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0]) + const { user } = useSession() + if (!user) { + redirect("/login") + } // Update selected team when URL param changes useEffect(() => { diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx @@ -26,17 +26,22 @@ import { } 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 { redirect, 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" +import { useSession } from "@/contexts/session-context"; export default function TeamManagement() { const searchParams = useSearchParams() const teamIdParam = searchParams.get("team") const [selectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0]) + const { user } = useSession() + if (!user) { + redirect("/login") + } // TODO: Make this actual pages so we can use more ssr const [activeTab, setActiveTab] = useState("members") diff --git a/src/app/layout.tsx b/src/app/layout.tsx @@ -16,11 +16,15 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { + + return ( <html lang="en" className="dark" style={{ colorScheme: "dark" }}> <body className={inter.className}> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> - <SessionProvider>{children}</SessionProvider> + <SessionProvider> + {children} + </SessionProvider> </ThemeProvider> </body> </html> diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx @@ -1,39 +1,65 @@ "use client" -import { useState } from "react" +import { useCallback, useEffect, useState } from "react" import Link from "next/link" -import { Shield, ArrowRight, Loader2 } from "lucide-react" +import { Shield, ArrowRight, Loader2, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { MatrixLoginForm } from "@/components/matrix-login-form" import { useSession } from "@/contexts/session-context" +import { redirect } from "next/navigation" export default function LoginPage() { - const { login, isLoading } = useSession() const [matrixId, setMatrixId] = useState("") const [step, setStep] = useState<"initial" | "login" | "error">("initial") + const [password, setPassword] = useState("") + const { isLoading, login, discoveryStatus, homeserverUrl, discoverHomeserver, user } = useSession(); + const [loginLoading, setLoginLoading] = useState(isLoading) const [errorMessage, setErrorMessage] = useState("") - const startMatrixAuth = () => { - if (!matrixId) return - setStep("login") + if (user) { + redirect("/dashboard") } - // TODO: Fix me - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const handleLoginSuccess = async (token: string) => { + useEffect(() => { + console.log("Discovery status changed:", discoveryStatus) + if (discoveryStatus === "loading") { + setLoginLoading(true) + } else if (discoveryStatus === "error") { + setErrorMessage("Failed to discover homeserver. Please check your Matrix ID format.") + setLoginLoading(false) + } else if (discoveryStatus === "success") { + setErrorMessage("") + setLoginLoading(false) + } + }, [discoveryStatus]) + + const startMatrixAuth = useCallback(() => { + if (!matrixId) return + setLoginLoading(true) + discoverHomeserver(matrixId).then(() => { + setStep("login") + }).catch((err) => { + console.error("Error discovering homeserver:", err) + setErrorMessage("Please check your Matrix ID format. It should be like @username:matrix.org") + setStep("error") + setLoginLoading(false) + }); + }, [matrixId, discoverHomeserver]); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault() + setErrorMessage("") + try { - // Use the login function from session context await login(matrixId) - } catch (error) { - console.error("Login failed", error) - setErrorMessage(error instanceof Error ? error.message : "Authentication failed") - setStep("error") + } catch (err) { + console.error("Login failed:", err) + setErrorMessage("Login failed. Please check your credentials.") } - } + }, [login, matrixId]) return ( <div className="flex min-h-screen flex-col bg-black text-white"> @@ -69,10 +95,86 @@ export default function LoginPage() { )} {step === "login" && ( - <MatrixLoginForm matrixId={matrixId} onSuccess={handleLoginSuccess} onCancel={() => setStep("initial")} /> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label htmlFor="matrix-username">Matrix ID</Label> + <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-gray-400" onClick={() => setStep("initial")}> + <X className="h-4 w-4" /> + </Button> + </div> + <div className="flex items-center gap-2 rounded-md bg-gray-900 px-3 py-2 text-sm"> + <span>{matrixId}</span> + </div> + + {discoveryStatus === "loading" && ( + <div className="flex items-center gap-2 text-xs text-gray-400"> + <Loader2 className="h-3 w-3 animate-spin" /> + Discovering homeserver... + </div> + )} + + {discoveryStatus === "success" && homeserverUrl && ( + <p className="text-xs text-gray-400">Authenticating with {homeserverUrl.replace(/^https?:\/\//, "")}</p> + )} + + {discoveryStatus === "error" && ( + <p className="text-xs text-red-400">Failed to discover homeserver. Please check your Matrix ID and ensure it&apos;s format is &quot;@localpart:example.com&quot;.</p> + )} + </div> + + {discoveryStatus === "success" && ( + <div className="space-y-2"> + <Label htmlFor="matrix-password">Password</Label> + <Input + id="matrix-password" + type="password" + placeholder="Enter your Matrix password" + className="bg-gray-900 border-gray-800" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + /> + </div> + )} + + <div className="flex gap-2"> + <Button + type="submit" + className="flex-1 bg-purple-600 text-white hover:bg-purple-700" + disabled={isLoading || !password || discoveryStatus !== "success"} + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Authenticating... + </> + ) : ( + "Log in" + )} + </Button> + <Button + type="button" + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + onClick={() => { + setStep("initial") + setMatrixId("") + setPassword("") + }} + disabled={isLoading} + > + Cancel + </Button> + </div> + + <p className="text-xs text-gray-400 text-center"> + Your credentials are sent directly to your Matrix homeserver. Draupnir4All only receives a verification token. + </p> + </form> )} - {isLoading && ( + {(isLoading || loginLoading) && ( <div className="flex flex-col items-center justify-center py-6 space-y-4"> <Loader2 className="h-8 w-8 animate-spin text-purple-400" /> <p className="text-gray-300">Logging in...</p> diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; export const experimental_ppr = true -const d4all_support_url = process.env.D4ALL_SUPPORT_URL || "#" +const d4all_support_url = process.env.NEXT_PUBLIC_D4ALL_SUPPORT_URL || "#" export default function Home() { return ( diff --git a/src/components/matrix-login-form.tsx b/src/components/matrix-login-form.tsx @@ -1,240 +0,0 @@ -"use client" - -import type React from "react" - -import { useState, useEffect } from "react" -import { Loader2, X, AlertCircle } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Alert, AlertDescription } from "@/components/ui/alert" - -interface MatrixLoginFormProps { - matrixId: string - onSuccess: (token: string) => void - onCancel: () => void -} - -interface WellKnownResponse { - "m.homeserver"?: { - base_url: string - } -} - -export function MatrixLoginForm({ matrixId, onSuccess, onCancel }: MatrixLoginFormProps) { - const [password, setPassword] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState("") - const [homeserverUrl, setHomeserverUrl] = useState<string | null>(null) - const [discoveryStatus, setDiscoveryStatus] = useState<"idle" | "loading" | "success" | "error">("idle") - - // Discover homeserver URL - useEffect(() => { - async function discoverHomeserver() { - if (!matrixId.includes(":")) { - setError("Invalid Matrix ID format. Please include a domain (e.g., @user:domain).") - return null - } - // Extract username and server from Matrix ID - const serverName = matrixId.split(":").pop() - if (!serverName) - - setDiscoveryStatus("loading") - try { - // Try to fetch well-known data - const wellKnownUrl = `https://${serverName}/.well-known/matrix/client` - - try { - const response = await fetch(wellKnownUrl) - if (response.ok) { - const data = (await response.json()) as WellKnownResponse - if (data["m.homeserver"]?.base_url) { - setHomeserverUrl(data["m.homeserver"].base_url) - setDiscoveryStatus("success") - return - } - } - } catch (e) { - console.warn("Well-known discovery failed, falling back to direct server", e) - } - - // Fallback to direct server - setHomeserverUrl(`https://${serverName}`) - setDiscoveryStatus("success") - } catch (err) { - console.error("Homeserver discovery failed:", err) - setError("Could not discover Matrix homeserver. Please check your Matrix ID.") - setDiscoveryStatus("error") - } - } - - discoverHomeserver() - }, [matrixId]) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!homeserverUrl) { - setError("Homeserver URL not available. Please try again.") - return - } - - setIsLoading(true) - setError("") - - try { - // Step 1: Authenticate with the Matrix homeserver - console.log(`Authenticating with ${homeserverUrl}`) - - // In a real implementation: - // const loginResponse = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify({ - // type: 'm.login.password', - // identifier: { - // type: 'm.id.user', - // user: username - // }, - // password - // }) - // }) - - // const loginData = await loginResponse.json() - // if (!loginData.access_token) { - // throw new Error(loginData.error || 'Login failed') - // } - - // Simulate login delay - await new Promise((resolve) => setTimeout(resolve, 1000)) - - // Simulate access token - // TODO: Fix me - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const mockAccessToken = "syt_" + Math.random().toString(36).substring(2, 15) - - // Step 2: Request an OpenID token - console.log(`Requesting OpenID token for ${matrixId}`) - - // In a real implementation: - // const openIdResponse = await fetch( - // `${homeserverUrl}/_matrix/client/v3/user/${encodeURIComponent(matrixId)}/openid/request_token`, - // { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json', - // 'Authorization': `Bearer ${loginData.access_token}` - // }, - // body: JSON.stringify({}) - // } - // ) - - // const openIdData = await openIdResponse.json() - // if (!openIdData.access_token) { - // throw new Error(openIdData.error || 'Failed to get OpenID token') - // } - - // Simulate OpenID token request delay - await new Promise((resolve) => setTimeout(resolve, 500)) - - // Simulate OpenID token - const mockOpenIdToken = - "ey" + - Math.random().toString(36).substring(2, 15) + - "." + - Math.random().toString(36).substring(2, 15) + - "." + - Math.random().toString(36).substring(2, 15) - - // Pass the token to the parent component - onSuccess(mockOpenIdToken) - } catch (err) { - console.error("Matrix login failed:", err) - setError(err instanceof Error ? err.message : "Authentication failed") - setIsLoading(false) - } - } - - return ( - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <Label htmlFor="matrix-username">Matrix ID</Label> - <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-gray-400" onClick={onCancel}> - <X className="h-4 w-4" /> - </Button> - </div> - <div className="flex items-center gap-2 rounded-md bg-gray-900 px-3 py-2 text-sm"> - <span>{matrixId}</span> - </div> - - {discoveryStatus === "loading" && ( - <div className="flex items-center gap-2 text-xs text-gray-400"> - <Loader2 className="h-3 w-3 animate-spin" /> - Discovering homeserver... - </div> - )} - - {discoveryStatus === "success" && homeserverUrl && ( - <p className="text-xs text-gray-400">Authenticating with {homeserverUrl.replace(/^https?:\/\//, "")}</p> - )} - - {discoveryStatus === "error" && ( - <p className="text-xs text-red-400">Failed to discover homeserver. Please check your Matrix ID.</p> - )} - </div> - - {discoveryStatus === "success" && ( - <div className="space-y-2"> - <Label htmlFor="matrix-password">Password</Label> - <Input - id="matrix-password" - type="password" - placeholder="Enter your Matrix password" - className="bg-gray-900 border-gray-800" - value={password} - onChange={(e) => setPassword(e.target.value)} - required - /> - </div> - )} - - {error && ( - <Alert variant="destructive" className="bg-red-900/20 border-red-800"> - <AlertCircle className="h-4 w-4 text-red-400" /> - <AlertDescription className="text-sm text-red-300">{error}</AlertDescription> - </Alert> - )} - - <div className="flex gap-2"> - <Button - type="submit" - className="flex-1 bg-purple-600 text-white hover:bg-purple-700" - disabled={isLoading || !password || discoveryStatus !== "success"} - > - {isLoading ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - Authenticating... - </> - ) : ( - "Log in" - )} - </Button> - <Button - type="button" - variant="outline" - className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" - onClick={onCancel} - disabled={isLoading} - > - Cancel - </Button> - </div> - - <p className="text-xs text-gray-400 text-center"> - Your credentials are sent directly to your Matrix homeserver. Draupnir4All only receives a verification token. - </p> - </form> - ) -} diff --git a/src/contexts/session-context.tsx b/src/contexts/session-context.tsx @@ -1,71 +1,158 @@ "use client" -import { createContext, useContext, useEffect, useState, type ReactNode } from "react" +import { createContext, useContext, useState, type ReactNode } from "react" import { useRouter } from "next/navigation" - -type User = { - id: string - matrixId: string - displayName: string - avatarUrl?: string - isAdmin: boolean -} +import { createSession, deleteSession, getSessionUser, User } from "@/lib/auth" type SessionContextType = { - user: User | null + user?: User isLoading: boolean isAuthenticated: boolean + discoveryStatus: "idle" | "loading" | "success" | "error" + homeserverUrl?: string + token?: string login: (matrixId: string) => Promise<void> register: (matrixId: string) => Promise<void> logout: () => Promise<void> + discoverHomeserver: (matrixId: string) => Promise<void> +} + +interface WellKnownResponse { + "m.homeserver"?: { + base_url: string + } } const SessionContext = createContext<SessionContextType | undefined>(undefined) export function SessionProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState<User | null>(null) - const [isLoading, setIsLoading] = useState(true) + const [user, setUser] = useState<User | undefined>(getSessionUser()) + const [isLoading, setIsLoading] = useState(false) + const [homeserverUrl, setHomeserverUrl] = useState<string | undefined>() + const [discoveryStatus, setDiscoveryStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [token, setToken] = useState<string | undefined>() const router = useRouter() - // Check for existing session on mount - useEffect(() => { - const checkSession = async () => { - try { - const response = await fetch("/api/auth/session") - const data = await response.json() - if (data.authenticated && data.user) { - setUser(data.user) + const discoverHomeserver = async (matrixId: string) => { + if (matrixId === "") { + return + } + setDiscoveryStatus("loading") + if (!matrixId.includes(":")) { + setDiscoveryStatus("error") + throw new Error("Invalid Matrix ID format. Please include a domain (e.g., @user:domain).") + } + // Extract username and server from Matrix ID + const serverName = matrixId.split(":").pop() + try { + // Try to fetch well-known data + const wellKnownUrl = `https://${serverName}/.well-known/matrix/client` + + try { + const response = await fetch(wellKnownUrl) + if (response.ok) { + const data = (await response.json()) as WellKnownResponse + if (data["m.homeserver"]?.base_url) { + setDiscoveryStatus("success") + setHomeserverUrl(data["m.homeserver"].base_url) + return; + } } - } catch (error) { - console.error("Failed to check session:", error) - } finally { - setIsLoading(false) + } catch (e) { + console.warn("Well-known discovery failed, falling back to direct server", e) } - } - checkSession() - }, []) + // Fallback to direct server + setDiscoveryStatus("success") + setHomeserverUrl(`https://${serverName}`) + } catch (err) { + setDiscoveryStatus("error") + setHomeserverUrl(undefined) + console.error("Homeserver discovery failed:", err) + throw new Error("Could not discover Matrix homeserver. Please check your Matrix ID.") + } + } const login = async (matrixId: string) => { setIsLoading(true) try { - const response = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ matrixId }), + // Step 1: Authenticate with the Matrix homeserver + console.log(`Authenticating with ${homeserverUrl}`) + + // In a real implementation: + // const loginResponse = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ + // type: 'm.login.password', + // identifier: { + // type: 'm.id.user', + // user: username + // }, + // password + // }) + // }) + + // const loginData = await loginResponse.json() + // if (!loginData.access_token) { + // throw new Error(loginData.error || 'Login failed') + // } + + // Simulate login delay + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Simulate access token + // TODO: Fix me + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const mockAccessToken = "syt_" + Math.random().toString(36).substring(2, 15) + + // Step 2: Request an OpenID token + console.log(`Requesting OpenID token for ${matrixId}`) + + // In a real implementation: + // const openIdResponse = await fetch( + // `${homeserverUrl}/_matrix/client/v3/user/${encodeURIComponent(matrixId)}/openid/request_token`, + // { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // 'Authorization': `Bearer ${loginData.access_token}` + // }, + // body: JSON.stringify({}) + // } + // ) + + // const openIdData = await openIdResponse.json() + // if (!openIdData.access_token) { + // throw new Error(openIdData.error || 'Failed to get OpenID token') + // } + + // Simulate OpenID token request delay + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Simulate OpenID token + const mockOpenIdToken = + "ey" + + Math.random().toString(36).substring(2, 15) + + "." + + Math.random().toString(36).substring(2, 15) + + "." + + Math.random().toString(36).substring(2, 15) + + // Save the OpenID token to the session + setToken(mockOpenIdToken) + createSession({ + matrixId, + displayName: matrixId.split(":")[0].substring(1), + id: "user_" + Math.random().toString(36).substring(2, 9), + isAdmin: false, }) - - if (!response.ok) { - throw new Error("Authentication failed") - } - - const data = await response.json() - setUser(data.user) - router.push("/dashboard") - } catch (error) { - console.error("Login failed:", error) - throw error + router.replace("/dashboard") + return; + } catch (err) { + console.error("Matrix login failed:", err) + throw err instanceof Error ? err.message : "Authentication failed" } finally { setIsLoading(false) } @@ -86,7 +173,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { const data = await response.json() setUser(data.user) - router.push("/dashboard") + router.replace("/dashboard") } catch (error) { console.error("Registration failed:", error) throw error @@ -97,8 +184,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { const logout = async () => { try { - await fetch("/api/auth/logout", { method: "POST" }) - setUser(null) + setUser(undefined) + deleteSession() router.push("/") } catch (error) { console.error("Logout failed:", error) @@ -110,10 +197,14 @@ export function SessionProvider({ children }: { children: ReactNode }) { value={{ user, isLoading, + homeserverUrl, + discoveryStatus, isAuthenticated: !!user, + token, login, register, logout, + discoverHomeserver, }} > {children} diff --git a/src/lib/auth.ts b/src/lib/auth.ts @@ -1,8 +1,4 @@ -import { cookies } from "next/headers" -import { jwtVerify, SignJWT } from "jose" - -const secretKey = process.env.SESSION_SECRET -const encodedKey = new TextEncoder().encode(secretKey) +'use client' export type User = { id: string @@ -12,88 +8,33 @@ export type User = { isAdmin: boolean } -export async function encrypt(user: User) { - return new SignJWT({ user }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime('7d') - .sign(encodedKey) -} - -export async function decrypt(session: string | undefined = '') { - try { - const { payload } = await jwtVerify(session, encodedKey, { - algorithms: ['HS256'], - }) - return payload - } catch (error: unknown) { - console.log('Failed to verify session', error) +export function createSession(user: User) { + const localStorage = globalThis.localStorage; + if (localStorage) { + localStorage.setItem('session', JSON.stringify(user)) } } -export async function createSession(user: User) { - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - const session = await encrypt(user) - const cookieStore = await cookies() - - cookieStore.set('session', session, { - httpOnly: true, - secure: true, - expires: expiresAt, - sameSite: 'lax', - path: '/', - }) -} - -export async function updateSession() { - const session = (await cookies()).get('session')?.value - const payload = await decrypt(session) - - if (!session || !payload) { - return null +export function deleteSession() { + const localStorage = globalThis.localStorage; + if (localStorage) { + localStorage.removeItem('session') } - - const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - - const cookieStore = await cookies() - cookieStore.set('session', session, { - httpOnly: true, - secure: true, - expires: expires, - sameSite: 'lax', - path: '/', - }) -} - -export async function deleteSession() { - const cookieStore = await cookies() - cookieStore.delete('session') } -export async function getSessionUser(): Promise<User | null> { +export function getSessionUser(): User | undefined { try { - // Get the session cookie - const sessionCookie = (await cookies()).get('session') - - if (!sessionCookie?.value) { - return null + // Get the session from local storage + const sessionCookie = globalThis.localStorage?.getItem('session') + if (!sessionCookie) { + return } - // Verify the JWT token - try { - const { payload } = await jwtVerify(sessionCookie.value, encodedKey) - - if (!payload.user) { - return null - } - - return payload.user as User - } catch (jwtError) { - console.error("JWT verification failed:", jwtError) - return null - } + return JSON.parse(sessionCookie) as User } catch (error) { + globalThis.localStorage?.removeItem('session') + console.error("Failed to verify session:", error) - return null + return } } \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -import { getSessionUser } from "./lib/auth" - -export async function middleware(request: NextRequest) { - // Get the pathname of the request - const path = request.nextUrl.pathname - - // Define public paths that don't require authentication - const isPublicPath = path === "/" || path === "/login" || path === "/register" - - // Check if the user is authenticated - const user = await getSessionUser() - const isAuthenticated = !!user - - // If the path is dashboard and the user is not authenticated, redirect to login - if (path.startsWith("/dashboard") && !isAuthenticated) { - const url = new URL("/login", request.url) - return NextResponse.redirect(url) - } - - // If the user is authenticated and trying to access login/register, redirect to dashboard - if (isAuthenticated && isPublicPath) { - const url = new URL("/dashboard", request.url) - return NextResponse.redirect(url) - } - - return NextResponse.next() -} - -// Configure the middleware to run only on specific paths -export const config = { - matcher: ["/", "/login", "/register", "/dashboard/:path*"], -}