draupnir4all-web

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

commit 393e6adfac6f4129ddd1643c9843f188bf3eed82
parent dfb79283f199386e71afbf311a2ca83d648b7448
Author: MTRNord <mtrnord1@gmail.com>
Date:   Tue, 29 Apr 2025 13:00:20 +0200

Implement the actual login code

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

Diffstat:
Asrc/app/api/session/route.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/dashboard/analytics/page.tsx | 12+-----------
Msrc/app/dashboard/bans/page.tsx | 12+-----------
Msrc/app/dashboard/overview/page.tsx | 11+----------
Msrc/app/dashboard/reports/page.tsx | 12+-----------
Msrc/app/dashboard/settings/page.tsx | 12++----------
Msrc/app/login/page.tsx | 21++++++++++++++-------
Msrc/app/register/page.tsx | 148++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/contexts/session-context.tsx | 373+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/lib/auth.ts | 1+
Asrc/lib/clientSideAuth.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/middleware.ts | 18++++++++++++++++++
12 files changed, 499 insertions(+), 241 deletions(-)

diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts @@ -0,0 +1,46 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + const { openidToken } = await req.json(); + + if (!openidToken) { + return NextResponse.json({ error: "OpenID token is required" }, { status: 400 }); + } + + const isValid = await validateOpenIDToken(openidToken); + if (!isValid) { + return NextResponse.json({ error: "Invalid OpenID token" }, { status: 401 }); + } + + const user = { + id: "user_" + Math.random().toString(36).substring(2, 9), + isAdmin: false, + }; + + const response = NextResponse.json(user); + response.cookies.set("session", JSON.stringify(user), { httpOnly: true, path: "/" }); + return response; +} + +export async function GET() { + const session = (await cookies()).get("session")?.value; + + if (!session) { + return NextResponse.json({ error: "No active session" }, { status: 401 }); + } + + return NextResponse.json(JSON.parse(session)); +} + +export async function DELETE() { + const response = NextResponse.json({}, { status: 204 }); + response.cookies.set("session", "", { httpOnly: true, path: "/", maxAge: 0 }); + return response; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function validateOpenIDToken(openidToken: string): Promise<boolean> { + // TODO: Implement OpenID token validation logic with the backend + return true; +} +\ No newline at end of file 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 { redirect, useSearchParams } from "next/navigation" +import { 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,7 +16,6 @@ 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[] = [ @@ -65,15 +64,6 @@ export default function AnalyticsDashboard() { const teamIdParam = searchParams.get("team") const [selectedTeam, setSelectedTeam] = useState(mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0]) - const { user } = useSession() - - - useEffect(() => { - // Check if the user is logged in - if (!user) { - redirect("/login") - } - }, [user]) // Update selected team when URL param changes useEffect(() => { diff --git a/src/app/dashboard/bans/page.tsx b/src/app/dashboard/bans/page.tsx @@ -8,9 +8,8 @@ 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 { redirect, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import TabNavigation from "../../../components/dashboard/tab-navigation"; -import { useSession } from "@/contexts/session-context"; export default function Bans() { @@ -18,15 +17,6 @@ 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() - - - useEffect(() => { - // Check if the user is logged in - if (!user) { - redirect("/login") - } - }, [user]) // Update selected team when URL param changes useEffect(() => { diff --git a/src/app/dashboard/overview/page.tsx b/src/app/dashboard/overview/page.tsx @@ -5,21 +5,12 @@ 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 { useSession } from "@/contexts/session-context" -import { redirect, useSearchParams } from "next/navigation" -import { useEffect } from "react" +import { useSearchParams } from "next/navigation" export default function OverviewPage() { const searchParams = useSearchParams() - const { user } = useSession() const teamIdParam = searchParams.get("team"); - useEffect(() => { - // Check if the user is logged in - if (!user) { - redirect("/login") - } - }, [user]) const selectedTeam = mockTeams.find((t) => t.id === teamIdParam) || mockTeams[0]; const reports = mockReports.filter((report) => report.teamId === selectedTeam.id); diff --git a/src/app/dashboard/reports/page.tsx b/src/app/dashboard/reports/page.tsx @@ -18,23 +18,13 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { mockReports, mockTeams } from "../mockData"; -import { redirect, useSearchParams } from "next/navigation" +import { 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() - - - useEffect(() => { - // Check if the user is logged in - if (!user) { - redirect("/login") - } - }, [user]) // Update selected team when URL param changes useEffect(() => { diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react" +import { useState } from "react" import { UserPlus, Settings, Trash2, LogOut } from "lucide-react" import { Button } from "@/components/ui/button" @@ -26,26 +26,18 @@ import { } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { redirect, useSearchParams } from "next/navigation" +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" -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() - useEffect(() => { - // Check if the user is logged in - if (!user) { - redirect("/login") - } - }, [user]) // TODO: Make this actual pages so we can use more ssr const [activeTab, setActiveTab] = useState("members") diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx @@ -54,12 +54,13 @@ export default function LoginPage() { setErrorMessage("") try { - await login(matrixId) + await login(matrixId, password) } catch (err) { console.error("Login failed:", err) setErrorMessage("Login failed. Please check your credentials.") + setStep("error") } - }, [login, matrixId]) + }, [login, matrixId, password]) return ( <div className="flex min-h-screen flex-col bg-black text-white"> @@ -162,7 +163,7 @@ export default function LoginPage() { setMatrixId("") setPassword("") }} - disabled={isLoading} + disabled={isLoading || loginLoading} > Cancel </Button> @@ -174,7 +175,7 @@ export default function LoginPage() { </form> )} - {(isLoading || loginLoading) && ( + {((isLoading || loginLoading) && discoveryStatus !== "loading") && ( <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> @@ -182,6 +183,12 @@ export default function LoginPage() { </div> )} + {((isLoading || loginLoading) && discoveryStatus === "loading") && ( + <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">Discovering the Homeserver URL...</p> + </div> + )} {step === "error" && ( <div className="rounded-md bg-red-900/20 border border-red-800 p-4 text-center"> <p className="text-red-300 font-medium">Login Failed</p> @@ -189,7 +196,7 @@ export default function LoginPage() { <Button variant="outline" className="mt-4 border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" - onClick={() => setStep("initial")} + onClick={() => { setStep("initial"); setMatrixId(""); setPassword(""); }} > Try Again </Button> @@ -202,7 +209,7 @@ export default function LoginPage() { <Button className="w-full bg-purple-600 text-white hover:bg-purple-700" onClick={startMatrixAuth} - disabled={!matrixId} + disabled={!matrixId || isLoading || loginLoading} > Continue <ArrowRight className="ml-2 h-4 w-4" /> @@ -218,6 +225,6 @@ export default function LoginPage() { </CardFooter> </Card> </main> - </div> + </div > ) } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx @@ -1,39 +1,64 @@ "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 RegisterPage() { - const { register, isLoading } = useSession() + const { isLoading, register, discoveryStatus, homeserverUrl, discoverHomeserver, user } = useSession(); + const [loginLoading, setLoginLoading] = useState(isLoading) const [matrixId, setMatrixId] = useState("") const [step, setStep] = useState<"initial" | "login" | "error">("initial") + const [password, setPassword] = useState("") 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) => { - try { - // Use the register function from session context - await register(matrixId) - } catch (error) { - console.error("Registration failed", error) - setErrorMessage(error instanceof Error ? error.message : "Registration failed") + 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 { + await register(matrixId, password) + } catch (err) { + console.error("Login failed:", err) + setErrorMessage("Login failed. Please check your credentials.") } - } + }, [register, matrixId, password]) return ( <div className="flex min-h-screen flex-col bg-black text-white"> @@ -71,10 +96,86 @@ export default function RegisterPage() { )} {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) && discoveryStatus !== "loading") && ( <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">Registering your account...</p> @@ -82,6 +183,13 @@ export default function RegisterPage() { </div> )} + {((isLoading || loginLoading) && discoveryStatus === "loading") && ( + <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">Discovering the Homeserver URL...</p> + </div> + )} + {step === "error" && ( <div className="rounded-md bg-red-900/20 border border-red-800 p-4 text-center"> <p className="text-red-300 font-medium">Registration Failed</p> diff --git a/src/contexts/session-context.tsx b/src/contexts/session-context.tsx @@ -1,196 +1,246 @@ -"use client" +"use client"; -import { createContext, useContext, useState, type ReactNode } from "react" -import { useRouter } from "next/navigation" -import { createSession, deleteSession, getSessionUser, User } from "@/lib/auth" +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { generateOpenIDToken, login as realLogin, logout as realLogout } from "@/lib/clientSideAuth"; +import { User } from "@/lib/auth"; type SessionContextType = { - 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 - } -} + user?: User; + isLoading: boolean; + isAuthenticated: boolean; + discoveryStatus: "idle" | "loading" | "success" | "error"; + homeserverUrl?: string; + login: (matrixId: string, password: string) => Promise<void>; + register: (matrixId: string, password: string) => Promise<void>; + logout: () => Promise<void>; + discoverHomeserver: (matrixId: string) => Promise<void>; + fetchWithTokenRefresh: (url: string, options?: RequestInit) => Promise<Response>; +}; -const SessionContext = createContext<SessionContextType | undefined>(undefined) +const SessionContext = createContext<SessionContextType | undefined>(undefined); export function SessionProvider({ children }: { children: ReactNode }) { - 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() + const [user, setUser] = useState<User | undefined>(); + const [isLoading, setIsLoading] = useState(false); + const [homeserverUrl, setHomeserverUrl] = useState<string | undefined>(); + const [discoveryStatus, setDiscoveryStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [accessToken, setAccessToken] = useState<string | undefined>(); + const [openidExpiration, setOpenIDExpiration] = useState<number | undefined>(); + const router = useRouter(); + + // 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 discoverHomeserver = async (matrixId: string) => { - if (matrixId === "") { - return + const refreshOpenIDToken = useCallback(async () => { + if (!homeserverUrl || !accessToken || !user) { + throw new Error("Missing required information to refresh OpenID token."); } - setDiscoveryStatus("loading") + + try { + const openidToken = await generateOpenIDToken(homeserverUrl, user.matrixId, accessToken); + + // Update the session cookie with the new OpenID token + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ openidToken }), + }); + + if (!response.ok) { + throw new Error("Failed to update session with new OpenID token."); + } + + const userData = await response.json(); + setUser(userData); + } catch (error) { + console.error("Failed to refresh OpenID token:", error); + throw error; + } + }, [homeserverUrl, accessToken, user]); + + // Automatically refresh OpenID token before it expires + useEffect(() => { + 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); + } + } + }, [openidExpiration, refreshOpenIDToken]); + + const discoverHomeserver = async (matrixId: string) => { + setDiscoveryStatus("loading"); if (!matrixId.includes(":")) { - setDiscoveryStatus("error") - throw new Error("Invalid Matrix ID format. Please include a domain (e.g., @user:domain).") + 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() + + const serverName = matrixId.split(":").pop(); + try { - // Try to fetch well-known data - const wellKnownUrl = `https://${serverName}/.well-known/matrix/client` + const url = new URL("/.well-known/matrix/client", `https://${serverName}`); + const response = await fetch(url); + if (response.ok) { + // Check if the response is empty + if (!response.body) { + setHomeserverUrl(`https://${serverName}`); + setDiscoveryStatus("success"); + return; + } - 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) + const data = await response.json(); + if (data["m.homeserver"]?.base_url) { + // Check if the base_url is a valid URL by checking /_matrix/client/versions on the endpoint + const url = new URL("/_matrix/client/versions", data["m.homeserver"].base_url) + const versionsResponse = await fetch(url); + console.log("Versions response:", versionsResponse.status); + if (versionsResponse.ok) { + setHomeserverUrl(data["m.homeserver"].base_url); + setDiscoveryStatus("success"); return; + } else { + setDiscoveryStatus("error"); + throw new Error("Homeserver did not respond successfully to versions request."); } } - } catch (e) { - console.warn("Well-known discovery failed, falling back to direct server", e) } + setHomeserverUrl(`https://${serverName}`); + setDiscoveryStatus("success"); + } catch (error: unknown) { + console.warn("Failed to discover homeserver:", error); + setHomeserverUrl(`https://${serverName}`); + setDiscoveryStatus("success"); + } + }; - // 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, password: string) => { + setIsLoading(true); + if (!homeserverUrl) { + throw new Error("Homeserver URL is not set. Please discover your homeserver first."); } - } - const login = async (matrixId: string) => { - setIsLoading(true) 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) - - // 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, - }) - router.replace("/dashboard") - return; - } catch (err) { - console.error("Matrix login failed:", err) - throw err instanceof Error ? err.message : "Authentication failed" + // Step 1: Login to Matrix and get access token + const accessToken = await realLogin(homeserverUrl, matrixId, password); + setAccessToken(accessToken); + + // Step 2: Generate OpenID token + const { access_token: openidToken, expires_in } = await generateOpenIDToken(homeserverUrl, matrixId, accessToken); + setOpenIDExpiration(expires_in); + + // Step 3: Send OpenID token to the server for validation and session creation + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ openidToken }), + }); + + if (!response.ok) { + throw new Error("Failed to create session on the server."); + } + + const userData = await response.json(); + setUser(userData); + router.replace("/dashboard"); + } catch (error) { + console.error("Login failed:", error); + throw error; } finally { - setIsLoading(false) + setIsLoading(false); + } + }; + + const register = async (matrixId: string, password: string) => { + setIsLoading(true); + if (!homeserverUrl) { + throw new Error("Homeserver URL is not set. Please discover your homeserver first."); } - } - const register = async (matrixId: string) => { - setIsLoading(true) try { - const response = await fetch("/api/auth/register", { + + // Step 1: Login to Matrix and get access token + const accessToken = await realLogin(homeserverUrl, matrixId, password); + setAccessToken(accessToken); + + // Step 2: Generate OpenID token + const { access_token: openidToken, expires_in } = await generateOpenIDToken(homeserverUrl, matrixId, accessToken); + setOpenIDExpiration(expires_in); + + // Step 3: Send OpenID token to the server for validation and session creation + const response = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ matrixId }), - }) + body: JSON.stringify({ openidToken }), + }); if (!response.ok) { - throw new Error("Registration failed") + throw new Error("Failed to create session on the server."); } - - const data = await response.json() - setUser(data.user) - router.replace("/dashboard") + const userData = await response.json(); + setUser(userData); + router.replace("/dashboard"); } catch (error) { - console.error("Registration failed:", error) - throw error + console.error("Login failed:", error); + throw error; } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; const logout = async () => { try { - setUser(undefined) - deleteSession() - router.push("/") + await fetch("/api/session", { method: "DELETE" }); + + if (homeserverUrl && accessToken) { + await realLogout(homeserverUrl, accessToken); + } + + setUser(undefined); + setAccessToken(undefined); + router.push("/"); } catch (error) { - console.error("Logout failed:", error) + console.error("Logout failed:", error); } - } + }; + + const fetchWithTokenRefresh = async (url: string, options: RequestInit = {}) => { + const response = await fetch(url, options); + + if (response.status === 401) { + // Token expired, refresh it + await refreshOpenIDToken(); + + // Retry the request + return fetch(url, options); + } + + return response; + }; return ( <SessionContext.Provider @@ -200,22 +250,22 @@ export function SessionProvider({ children }: { children: ReactNode }) { homeserverUrl, discoveryStatus, isAuthenticated: !!user, - token, login, register, logout, discoverHomeserver, + fetchWithTokenRefresh, }} > {children} </SessionContext.Provider> - ) + ); } export function useSession() { - const context = useContext(SessionContext) + const context = useContext(SessionContext); if (context === undefined) { - throw new Error("useSession must be used within a SessionProvider") + throw new Error("useSession must be used within a SessionProvider"); } - return context -} + return context; +} +\ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts @@ -6,6 +6,7 @@ export type User = { displayName: string avatarUrl?: string isAdmin: boolean + token: string } export function createSession(user: User) { diff --git a/src/lib/clientSideAuth.ts b/src/lib/clientSideAuth.ts @@ -0,0 +1,72 @@ +export async function login(homeserverUrl: string, username: string, password: string): Promise<string> { + console.log(`Authenticating with ${homeserverUrl}`); + + const url = new URL("/_matrix/client/v3/login", homeserverUrl); + const loginResponse = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: username, + }, + initial_device_display_name: "Draupnir4All Web", + password, + }), + }); + + const loginData = await loginResponse.json(); + if (!loginData.access_token) { + throw new Error(loginData.error || 'Login failed'); + } + + return loginData.access_token; +} + +interface OpenIDTokenResponse { + access_token: string; + expires_in: number; + matrix_server_name: string; + token_type: string; + error?: string; +} + +export async function generateOpenIDToken(homeserverUrl: string, username: string, accessToken: string): Promise<OpenIDTokenResponse> { + const url = new URL("/_matrix/client/v3/user/" + encodeURIComponent(username) + "/openid/request_token", homeserverUrl); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'm.login.openid', + }), + }); + + const data = await response.json(); + if (!data.access_token) { + throw new Error(data.error || 'Failed to generate OpenID token'); + } + + const dataAsResponse = data as OpenIDTokenResponse; + + return dataAsResponse; +} + +export async function logout(homeserverUrl: string, accessToken: string): Promise<void> { + console.log(`Logging out with ${homeserverUrl}`); + + const url = new URL("/_matrix/client/v3/logout", homeserverUrl); + try { + await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}` + }, + }); + } catch (error: unknown) { + console.warn("Failed to logout:", error); + } +} +\ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(req: NextRequest) { + const session = req.cookies.get("session"); + + // Redirect to login if session is missing on protected routes + if (!session && req.nextUrl.pathname.startsWith("/dashboard")) { + return NextResponse.redirect(new URL("/login", req.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*"], // Add other protected routes here +}; +\ No newline at end of file