diff --git a/app/components/AuthWrapper.tsx b/app/components/AuthWrapper.tsx index 70dc0ca..d54ed9d 100644 --- a/app/components/AuthWrapper.tsx +++ b/app/components/AuthWrapper.tsx @@ -1,155 +1,21 @@ "use client"; -import { useEffect, useState, Suspense } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; -import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import LoadingSpinner from "./shared/LoadingSpinner"; +import { useEffect, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; +import FullPageLoader from "./shared/FullPageLoader"; -interface AuthWrapperProps { - children: React.ReactNode; -} - -// Check if there's any indication of a session in storage -function hasSessionInStorage(): boolean { - if (typeof window === "undefined") return false; - - try { - const keys = Object.keys(localStorage); - // Look for keys that might indicate a session exists - return keys.some(key => - key.includes("solidClientAuthn") || - key.includes("solid-auth") || - key.includes("oidc") || - key.includes("session") - ); - } catch { - return false; - } -} - -function AuthWrapperContent({ children }: AuthWrapperProps) { - const { session } = useSolidAuth(); - const searchParams = useSearchParams(); +export default function AuthWrapper({ children }: { children: ReactNode }) { + const { status } = useSolidAuth(); const router = useRouter(); - const pathname = usePathname(); - const [isCheckingSession, setIsCheckingSession] = useState(true); - const [hasSessionIndicator, setHasSessionIndicator] = useState(() => hasSessionInStorage()); - const [wasLoggedIn, setWasLoggedIn] = useState(false); - - // Check if we're in the middle of an OAuth callback - const isOAuthCallback = searchParams.has("code") || searchParams.has("state"); - const isLoginPage = pathname === "/login"; - - - const hasSessionData = !!(session.webId || session.sessionId || session.clientAppId); useEffect(() => { - if (session.isLoggedIn) { - setWasLoggedIn(true); - } - - // If we're in an OAuth callback, keep checking until session is established - // Don't redirect until session is confirmed - if (isOAuthCallback) { - setIsCheckingSession(true); - - if (session.isLoggedIn) { - setIsCheckingSession(false); - // Redirect to home after successful OAuth (remove OAuth params from URL) - const redirectTimer = setTimeout(() => { - if (typeof window !== "undefined") { - window.location.href = "/"; - } - }, 200); - return () => clearTimeout(redirectTimer); - } else { - // Session not yet established, keep waiting - // Set a timeout to prevent infinite waiting (max 10 seconds) - const maxWaitTimer = setTimeout(() => { - setIsCheckingSession(false); - }, 10000); - return () => clearTimeout(maxWaitTimer); - } - } - - - if (session.isLoggedIn) { - setIsCheckingSession(false); - if (isLoginPage) { - router.replace("/"); - } - return; - } - - - if (wasLoggedIn && !session.isLoggedIn) { - setIsCheckingSession(false); - if (!isLoginPage) { - router.replace("/login"); - } - return; - } + if (status === "anonymous") router.replace("/login"); + }, [status, router]); - // If we have session data (webId, sessionId, etc.) but isLoggedIn is false, - // the session is likely being restored - wait longer - // Otherwise, if no session data and no storage indicator, show login quickly - const shouldWaitForRestore = hasSessionData || hasSessionIndicator; - const checkTimer = setTimeout(() => { - setIsCheckingSession(false); + // Fail closed: children mount only once a session is confirmed, so nothing + // downstream can fetch before there is anything to authenticate with. + if (status !== "authenticated") return ; - if (!session.isLoggedIn && !isLoginPage && !isOAuthCallback) { - router.replace("/login"); - } - }, shouldWaitForRestore ? 2000 : 200); - - return () => clearTimeout(checkTimer); - }, [session.isLoggedIn, session.webId, session.sessionId, isOAuthCallback, hasSessionIndicator, hasSessionData, wasLoggedIn, isLoginPage, router]); - - - if (isCheckingSession || isOAuthCallback) { - return ( -
- -
- ); - } - - // If OAuth callback is on login page, we're still processing - show loading - // This handles the case where OAuth redirects back to /login - if (isOAuthCallback && isLoginPage) { - return ( -
- -
- ); - } - - // If not authenticated and not on login page, redirect will happen in useEffect - // If authenticated and on login page, redirect will happen in useEffect - // For now, just show children (or nothing if redirecting) - if (!session.isLoggedIn && !isLoginPage) { - return null; // Redirecting to login - } - - if (session.isLoggedIn && isLoginPage) { - return null; // Redirecting to home - } - - // User is authenticated and on correct page, show the app return <>{children}; } - - -export default function AuthWrapper({ children }: AuthWrapperProps) { - return ( - - - - } - > - {children} - - ); -} diff --git a/app/components/DeleteConfirmDialog.tsx b/app/components/DeleteConfirmDialog.tsx index fa1ed95..d3e8bcc 100644 --- a/app/components/DeleteConfirmDialog.tsx +++ b/app/components/DeleteConfirmDialog.tsx @@ -55,7 +55,7 @@ export default function DeleteConfirmDialog({

Are you sure you want to delete{" "} - "{file.name}"? + “{file.name}”?

{file.type === "folder" && (

diff --git a/app/components/FileItemMenu.tsx b/app/components/FileItemMenu.tsx index 0986ab1..440b29b 100644 --- a/app/components/FileItemMenu.tsx +++ b/app/components/FileItemMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef } from "react"; import Button from "./shared/Button"; import { EllipsisVerticalIcon, @@ -15,6 +15,20 @@ import { import { useClickOutside } from "../lib/hooks"; import { FileItemData } from "./FileItem"; +/** Roughly how tall the menu renders; used to decide which way it opens. */ +const ESTIMATED_MENU_HEIGHT = 250; + +/** Opens upward only when the menu would not fit below but would above. */ +function placementFor(button: HTMLElement | null): "top" | "bottom" { + if (!button) return "bottom"; + + const { top, bottom } = button.getBoundingClientRect(); + const spaceBelow = window.innerHeight - bottom; + return spaceBelow < ESTIMATED_MENU_HEIGHT && top > ESTIMATED_MENU_HEIGHT + ? "top" + : "bottom"; +} + interface FileItemMenuProps { file: FileItemData; onRename?: (file: FileItemData) => void; @@ -49,23 +63,6 @@ export default function FileItemMenu({ refs: [menuRef, menuButtonRef], }); - useEffect(() => { - if (showMenu && menuButtonRef.current) { - const buttonRect = menuButtonRef.current.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const spaceBelow = viewportHeight - buttonRect.bottom; - const spaceAbove = buttonRect.top; - const estimatedMenuHeight = 250; // Approximate height of the menu - - // If there's not enough space below but enough space above, show menu above - if (spaceBelow < estimatedMenuHeight && spaceAbove > estimatedMenuHeight) { - setMenuPosition("top"); - } else { - setMenuPosition("bottom"); - } - } - }, [showMenu]); - const handleAction = (action: ((file: FileItemData) => void) | undefined) => { if (action) { action(file); @@ -143,6 +140,7 @@ export default function FileItemMenu({ aria-expanded={showMenu} onClick={(e) => { e.stopPropagation(); + if (!showMenu) setMenuPosition(placementFor(menuButtonRef.current)); setShowMenu(!showMenu); }} className={position === "top-right" ? "bg-white/90 hover:bg-white shadow-sm" : ""} diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 74362ef..26d6750 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -7,7 +7,6 @@ import EmptyState from "./shared/EmptyState"; interface FileListProps { files: FileItemData[]; - currentPath: string; onFileSelect: (file: FileItemData) => void; onFileDoubleClick: (file: FileItemData) => void; onFileRename?: (file: FileItemData) => void; @@ -25,7 +24,6 @@ const VIEW_STORAGE_KEY = "solid-file-manager-view"; export default function FileList({ files, - currentPath, onFileSelect, onFileDoubleClick, onFileRename, diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 04f6f82..3f87ef9 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -1,5 +1,7 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useState, useEffect, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { useSearchParams, useRouter } from "next/navigation"; @@ -14,7 +16,6 @@ import { EyeIcon, ShareIcon, } from "@heroicons/react/24/outline"; -import AuthWrapper from "./AuthWrapper"; import Header from "./Header"; import Sidebar from "./Sidebar"; import Breadcrumb from "./Breadcrumb"; @@ -30,11 +31,11 @@ import FileUploadHandler from "./FileUploadHandler"; import ContextMenu, { ContextMenuAction } from "./ContextMenu"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; +import FullPageLoader from "./shared/FullPageLoader"; import ErrorDisplay from "./shared/ErrorDisplay"; import { useSolidStorages, useBrowseStorage } from "../lib/hooks"; import { buildBreadcrumbItems, - getAuthenticatedSession, copyFileResource, copyFolderResource, downloadFile, @@ -43,7 +44,6 @@ import { deleteFolderResource, uploadFilesToContainer, uploadFolderFilesToContainer, - FolderUploadFile, processDragDropItems, hasFiles as hasFilesInDrag, isUnsupportedFolderDrag, @@ -172,10 +172,14 @@ export default function FileManager() { setSelectedStorageId(matchingStorage.id); setCurrentPath(urlParam === matchingStorage.url ? "/" : urlParam); + // Sync the address bar without navigating. if (typeof window !== "undefined") { const params = new URLSearchParams(); params.set("url", safeEncodeUrl(urlParam)); - router.replace(`/?${params.toString()}`, { scroll: false }); + const search = `?${params.toString()}`; + if (window.location.search !== search) { + window.history.replaceState(null, "", `/${search}`); + } } setIsInitialized(true); @@ -186,7 +190,7 @@ export default function FileManager() { } setIsInitialized(true); - }, [searchParams, storages, isLoadingStorages, isInitialized, router]); + }, [searchParams, storages, isLoadingStorages, isInitialized]); useEffect(() => { return () => { @@ -325,7 +329,7 @@ export default function FileManager() { const toastId = toast.loading(`Copying "${file.name}"...`); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (file.type === "folder") { await copyFolderResource(file, fetchFn); } else { @@ -372,7 +376,7 @@ export default function FileManager() { ); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (fileToDelete.type === "folder") { await deleteFolderResource(fileToDelete.url, fetchFn); @@ -415,7 +419,7 @@ export default function FileManager() { ); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (file.type === "folder") { await downloadFolderAsZip(file.url, file.name, fetchFn); @@ -509,8 +513,9 @@ export default function FileManager() { let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); return; } @@ -775,205 +780,188 @@ export default function FileManager() { }; if (isLoadingStorages) { - return ( - -

- -
- - ); + return ; } const isBrowsing = selectedStorageId && isLoadingFiles; if (storagesError) { return ( - - window.location.reload()} - /> - + window.location.reload()} + /> ); } if (browseError && selectedStorageId) { return ( - - { - setCurrentPath("/"); - }} - /> - + { + setCurrentPath("/"); + }} + /> ); } if (storages.length === 0) { return ( - -
-
-

No Storages Found

-

- Unable to discover any Solid storage roots from your WebID profile. -

-
+
+
+

No Storages Found

+

+ Unable to discover any Solid storage roots from your WebID profile. +

- +
); } return ( - -
-
setSidebarOpen(true)} - sidebarOpen={sidebarOpen} +
+
setSidebarOpen(true)} + /> +
+ setSidebarOpen(false)} + activeTab="my-storages" + onNewFolderClick={() => setShowNewFolderDialog(true)} + onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} + onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} /> -
- setSidebarOpen(false)} - activeTab="my-storages" - currentContainerUrl={containerUrlToBrowse} - onNewFolderClick={() => setShowNewFolderDialog(true)} - onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} - onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} - /> -
-
- +
+
+ +
+ {isBrowsing ? ( +
+
- {isBrowsing ? ( -
- -
- ) : ( -
- -
- )} -
-
- - setShowNewFolderDialog(false)} - currentContainerUrl={containerUrlToBrowse} - onFolderCreated={handleFolderCreated} - /> - { - setShowRenameDialog(false); - setFileToRename(null); - }} - file={fileToRename} - onRenamed={handleRenamed} - /> - { - setShowPreviewModal(false); - setFileToPreview(null); - }} - file={fileToPreview} - /> - { - setShowMoveDialog(false); - setFileToMove(null); - }} - file={fileToMove} - availableFolders={availableFolders} - currentLocationUrl={getCurrentLocationUrl()} - onMoved={handleMoved} - /> - { - setShowDeleteDialog(false); - setFileToDelete(null); - }} - file={fileToDelete} - onConfirm={handleDeleteConfirm} - isDeleting={isDeleting} - /> - { - setShowShareDialog(false); - setFileToShare(null); - }} - file={fileToShare} - onShare={handleShareConfirm} - /> - setShowShareSuccessModal(false)} - resourceUrl={sharedResourceUrl} - resourceName={sharedResourceName} - onOpenInApp={(url) => { - updateUrl(url, true); - }} - /> - {isDragActive && ( -
-
-

Drop files or folders to upload

-

- They will be uploaded to the current folder -

+ ) : ( +
+
+ )} +
+
+ + setShowNewFolderDialog(false)} + onFolderCreated={handleFolderCreated} + /> + { + setShowRenameDialog(false); + setFileToRename(null); + }} + file={fileToRename} + onRenamed={handleRenamed} + /> + { + setShowPreviewModal(false); + setFileToPreview(null); + }} + file={fileToPreview} + /> + { + setShowMoveDialog(false); + setFileToMove(null); + }} + file={fileToMove} + availableFolders={availableFolders} + currentLocationUrl={getCurrentLocationUrl()} + onMoved={handleMoved} + /> + { + setShowDeleteDialog(false); + setFileToDelete(null); + }} + file={fileToDelete} + onConfirm={handleDeleteConfirm} + isDeleting={isDeleting} + /> + { + setShowShareDialog(false); + setFileToShare(null); + }} + file={fileToShare} + onShare={handleShareConfirm} + /> + setShowShareSuccessModal(false)} + resourceUrl={sharedResourceUrl} + resourceName={sharedResourceName} + onOpenInApp={(url) => { + updateUrl(url, true); + }} + /> + {isDragActive && ( +
+
+

Drop files or folders to upload

+

+ They will be uploaded to the current folder +

- )} - + )} + + + {contextMenuState && ( + - - {contextMenuState && ( - - )} -
- + )} +
); } diff --git a/app/components/FileUploadHandler.tsx b/app/components/FileUploadHandler.tsx index 41c2e9d..bf01aa7 100644 --- a/app/components/FileUploadHandler.tsx +++ b/app/components/FileUploadHandler.tsx @@ -1,9 +1,10 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useRef, useEffect } from "react"; import toast from "react-hot-toast"; import { - getAuthenticatedSession, uploadFilesToContainer, uploadFolderFilesToContainer, FolderUploadFile, @@ -49,8 +50,9 @@ export default function FileUploadHandler({ let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); e.target.value = ""; return; @@ -102,8 +104,9 @@ export default function FileUploadHandler({ let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); e.target.value = ""; return; @@ -112,7 +115,7 @@ export default function FileUploadHandler({ const folderFiles: FolderUploadFile[] = Array.from(files) .map((file) => ({ file, - relativePath: (file as any).webkitRelativePath || file.name, + relativePath: file.webkitRelativePath || file.name, })) .filter((item) => item.relativePath && item.relativePath.length > 0); @@ -163,7 +166,7 @@ export default function FileUploadHandler({ void; - sidebarOpen?: boolean; } -export default function Header({ onMenuClick, sidebarOpen = false }: HeaderProps) { +export default function Header({ onMenuClick }: HeaderProps) { const [searchQuery, setSearchQuery] = useState(""); return ( diff --git a/app/components/IssuerPickerDialog.tsx b/app/components/IssuerPickerDialog.tsx new file mode 100644 index 0000000..fc85dbe --- /dev/null +++ b/app/components/IssuerPickerDialog.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Button from "./shared/Button"; +import Modal from "./shared/Modal"; + +interface IssuerPickerDialogProps { + isOpen: boolean; + issuers: string[]; + onSelect: (issuer: string) => void; + onClose: () => void; +} + +export default function IssuerPickerDialog({ + isOpen, + issuers, + onSelect, + onClose, +}: IssuerPickerDialogProps) { + const firstOptionRef = useRef(null); + + // The dialog opens without a click of its own, so focus has to be moved in. + useEffect(() => { + if (isOpen) firstOptionRef.current?.focus(); + }, [isOpen]); + + return ( + +

+ That WebID lists more than one. Pick the one to sign in with. +

+
    + {issuers.map((issuer, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index 0b73684..5af6740 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -1,44 +1,228 @@ "use client"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { SolidLoginPage } from "solid-react-component/login/next"; -import { getLastIdp, saveLastIdp } from "../lib/helpers/idpHistoryUtils"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import Image from "next/image"; +import { identify } from "../lib/auth/identify"; +import { fetchDiscovery } from "../lib/auth/discovery"; +import { + allowOrigin, + getAuthenticatedWebId, + getAuthFetch, + setCurrentSession, +} from "../lib/auth/manager"; +import { + getLastLoginEntry, + saveLastLoginEntry, +} from "../lib/helpers/loginHistoryUtils"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; +import IssuerPickerDialog from "./IssuerPickerDialog"; +import SignedOutNotice from "./SignedOutNotice"; +import Button from "./shared/Button"; +import GitHubLinks from "./shared/GitHubLinks"; +import UrlCombobox, { type ComboboxOption } from "./shared/UrlCombobox"; + +// Suggestions only — anything the user types is accepted, so this list is here +// for convenience and can grow or go without affecting the flow. +const KNOWN_ISSUERS: ComboboxOption[] = [ + { label: "Inrupt PodSpaces", value: "https://login.inrupt.com" }, + { label: "solidcommunity.net", value: "https://solidcommunity.net" }, +]; + +const LABELS = { + either: "WebID (or OIDC issuer)", + issuer: "Your login server", + webid: "Your WebID", +} as const; + +const HELPER_TEXT = { + either: "For example https://id.inrupt.com/you", + issuer: "That WebID does not name a login server, so please enter one.", + webid: "We have your login server. Now enter the WebID it signs you in as.", +} as const; export default function LoginPage() { - const router = useRouter(); - const [mounted, setMounted] = useState(false); - const [defaultIssuer, setDefaultIssuer] = useState(""); + const { login, signedOutFrom } = useSolidAuth(); + + const [entry, setEntry] = useState(""); + const [remembered, setRemembered] = useState(""); + const [webId, setWebId] = useState(null); + const [issuer, setIssuer] = useState(null); + const [choices, setChoices] = useState([]); + const [error, setError] = useState(null); + const [isResolving, setIsResolving] = useState(false); useEffect(() => { - setDefaultIssuer(getLastIdp()); - setMounted(true); + const last = getLastLoginEntry(); + setEntry(last); + setRemembered(last); }, []); - const handleSubmit = (event: React.FormEvent) => { - const input = event.currentTarget.querySelector("input"); - if (input instanceof HTMLInputElement) { - saveLastIdp(input.value); + const options = useMemo(() => { + if (!remembered) return KNOWN_ISSUERS; + return [ + { label: remembered, value: remembered, secondaryLabel: "Last used" }, + // The remembered entry is often one of the suggestions already. + ...KNOWN_ISSUERS.filter((option) => option.value !== remembered), + ]; + }, [remembered]); + + const needs = webId && !issuer ? "issuer" : issuer && !webId ? "webid" : "either"; + + /** + * Nothing authenticates until a request to a vouched origin comes back 401, + * and which URL does that differs by server: a protected profile or pod root + * on CSS, the userinfo endpoint on ESS. Try each until a flow has run. + */ + async function authenticate(candidateWebId: string | null, candidateIssuer: string) { + const discovery = await fetchDiscovery(candidateIssuer); + const probes = [candidateWebId, discovery?.userinfo_endpoint, candidateIssuer]; + + for (const probe of probes) { + if (!probe) continue; + if (await getAuthenticatedWebId(candidateIssuer)) return; + + try { + await getAuthFetch()(probe); + } catch (error) { + console.warn(`Could not sign in via ${probe}`, error); + } + } + } + + async function finish(nextWebId: string | null, nextIssuer: string | null) { + setWebId(nextWebId); + setIssuer(nextIssuer); + + if (!nextIssuer) { + // Nowhere to sign in to yet; the field relabels and asks for one. + setCurrentSession(null); + setEntry(""); + return; + } + + // Sign in here, while the submitting click is still the active gesture, so + // the popup is not blocked and the session is cached before the file + // manager mounts and fires a dozen requests. + setCurrentSession({ webId: nextWebId, issuer: nextIssuer }); + allowOrigin(nextIssuer); + await authenticate(nextWebId, nextIssuer); + + // Solid-OIDC puts the WebID in the id_token, so an issuer on its own is + // enough: sign in first, then ask who that turned out to be. + const confirmedWebId = nextWebId ?? (await getAuthenticatedWebId(nextIssuer)); + + if (!confirmedWebId) { + setCurrentSession(null); + setEntry(""); + setError("That login server did not say who you are. Please enter your WebID."); + return; } - }; - // Render only after localStorage is read, so defaultIssuer is applied on - // SolidLoginPage's initial mount (it reads defaultIssuer only once). - if (!mounted) return null; + setWebId(confirmedWebId); + saveLastLoginEntry(confirmedWebId); + login({ webId: confirmedWebId, issuer: nextIssuer }); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const value = entry.trim(); + if (!value) return; + + setError(null); + setIsResolving(true); + try { + const target = await identify(value); + + if (target.kind === "unknown") { + setError("That is not a WebID or a login server we could reach."); + return; + } + + if (target.kind === "issuer") { + await finish(webId, target.issuer); + return; + } + + if (target.issuers.length > 1) { + setWebId(target.webId); + setChoices(target.issuers); + return; + } + + await finish(target.webId, target.issuers[0] ?? issuer); + } finally { + setIsResolving(false); + } + } return ( -
- router.replace("/")} - defaultIssuer={defaultIssuer} - logo="/file-manager-logo.svg" - logoAlt="Solid File Manager Logo" - title="Sign in" - subtitle="to continue to Solid File Manager" - footerGitHubUrl="https://github.com/solid/solid-file-manager" - footerIssuesUrl="https://github.com/solid/solid-file-manager/issues/new" - /> -
- ) +
+
+
+ +

Sign in

+

+ to continue to Solid File Manager +

+
+ +
+
+ + {/* A live region: the label and this text change when we + learn half the credentials and ask for the other. */} +

+ {error ? "" : HELPER_TEXT[needs]} +

+
+ + +
-} \ No newline at end of file + {signedOutFrom && } + +
+ +
+
+ + 0} + issuers={choices} + onSelect={(choice) => { + setChoices([]); + setIsResolving(true); + void finish(webId, choice).finally(() => setIsResolving(false)); + }} + onClose={() => setChoices([])} + /> +
+ ); +} diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index 672ab20..e1805d1 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -1,5 +1,7 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useState, useEffect } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; @@ -8,7 +10,6 @@ import toast from "react-hot-toast"; import { FileItemData } from "./FileItem"; import { moveFileResource, - getAuthenticatedSession, decodeResourceNameFromUrl, ensureTrailingSlash, } from "../lib/helpers"; @@ -94,7 +95,7 @@ export default function MoveDialog({ if (fileStorage) { const loadFolders = async () => { try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); const folders = await fetchAllFolders(fileStorage.url, fetchFn); // Include the storage root itself setAllFolders([fileStorage, ...folders]); @@ -125,7 +126,7 @@ export default function MoveDialog({ setIsMoving(true); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); await moveFileResource(file, selectedFolderUrl, fetchFn); toast.success(`Moved "${file.name}"`); diff --git a/app/components/NewFolderDialog.tsx b/app/components/NewFolderDialog.tsx index 873cc17..52dd9a8 100644 --- a/app/components/NewFolderDialog.tsx +++ b/app/components/NewFolderDialog.tsx @@ -6,7 +6,7 @@ import Button from "./shared/Button"; import Input from "./shared/Input"; import { createContainerAt, getSolidDataset, UrlString } from "@inrupt/solid-client"; import toast from "react-hot-toast"; -import { getAuthenticatedSession } from "../lib/helpers"; +import { getAuthFetch } from "../lib/auth/manager"; interface NewFolderDialogProps { isOpen: boolean; @@ -53,7 +53,7 @@ export default function NewFolderDialog({ setIsCreating(true); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); // Ensure the current container exists await getSolidDataset(currentContainerUrl, { fetch: fetchFn }); diff --git a/app/components/NewMenuButton.tsx b/app/components/NewMenuButton.tsx index 7aad943..5d3efea 100644 --- a/app/components/NewMenuButton.tsx +++ b/app/components/NewMenuButton.tsx @@ -6,14 +6,12 @@ import { useClickOutside } from "../lib/hooks"; import Button from "./shared/Button"; interface NewMenuButtonProps { - currentContainerUrl: string | null; onNewFolderClick?: () => void; onFileUploadClick?: () => void; onFolderUploadClick?: () => void; } export default function NewMenuButton({ - currentContainerUrl, onNewFolderClick, onFileUploadClick, onFolderUploadClick, diff --git a/app/components/PreviewModal.tsx b/app/components/PreviewModal.tsx index e58264d..240c895 100644 --- a/app/components/PreviewModal.tsx +++ b/app/components/PreviewModal.tsx @@ -1,10 +1,14 @@ "use client"; +/* eslint-disable @next/next/no-img-element -- these render remote pod URLs, so + next/image would need every possible pod host in remotePatterns */ + import { useState, useEffect, useRef } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; import { getFile, UrlString } from "@inrupt/solid-client"; -import { getAuthenticatedSession } from "../lib/helpers"; +import { getAuthFetch } from "../lib/auth/manager"; +import { httpStatusOf } from "../lib/helpers/errorUtils"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; @@ -57,7 +61,7 @@ export default function PreviewModal({ setError(null); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); let fileBlob: Blob; @@ -67,9 +71,8 @@ export default function PreviewModal({ fileBlob = await getFile(file.url as UrlString, { fetch: fetchFn }); // Get the content-type from the blob's type property (set from HTTP response header) actualMimeType = fileBlob.type ? fileBlob.type.split(";")[0].trim() : ""; - } catch (getFileError: any) { - - const statusCode = getFileError?.response?.status; + } catch (getFileError) { + const statusCode = httpStatusOf(getFileError); const errorMessage = getFileError instanceof Error ? getFileError.message : String(getFileError); if (statusCode === 501 || @@ -94,7 +97,7 @@ export default function PreviewModal({ try { fileBlob = await response.blob(); actualMimeType = contentType.split(";")[0].trim(); - } catch (blobError) { + } catch { setFileType("other"); setIsLoading(false); return; diff --git a/app/components/ProfileIcon.tsx b/app/components/ProfileIcon.tsx index 472230b..daad491 100644 --- a/app/components/ProfileIcon.tsx +++ b/app/components/ProfileIcon.tsx @@ -1,8 +1,10 @@ "use client"; +/* eslint-disable @next/next/no-img-element -- these render remote pod URLs, so + next/image would need every possible pod host in remotePatterns */ + import { useState, useRef } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; -import { useUserProfile, useClickOutside } from "../lib/hooks"; +import { useSolidAuth, useUserProfile, useClickOutside } from "../lib/hooks"; import { UserCircleIcon, ArrowRightStartOnRectangleIcon, PhoneIcon, BuildingOfficeIcon, BriefcaseIcon, GlobeAltIcon, ClipboardIcon } from "@heroicons/react/24/outline"; import toast from "react-hot-toast"; @@ -26,8 +28,6 @@ export default function ProfileIcon() { const handleLogout = async () => { try { await logout(); - // Redirect to login page after logout - window.location.href = "/login"; } catch (error) { console.error("Logout failed:", error); toast.error("Failed to sign out"); diff --git a/app/components/RenameDialog.tsx b/app/components/RenameDialog.tsx index dd8f5f5..688cf89 100644 --- a/app/components/RenameDialog.tsx +++ b/app/components/RenameDialog.tsx @@ -1,5 +1,7 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useState, useEffect, useRef } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; @@ -7,7 +9,7 @@ import Input from "./shared/Input"; import { UrlString, getFile, overwriteFile, deleteFile, createContainerAt } from "@inrupt/solid-client"; import toast from "react-hot-toast"; import { FileItemData } from "./FileItem"; -import { getAuthenticatedSession, sanitizeResourceName, getParentContainerUrl, ensureTrailingSlash, copyFolderContents, deleteFolderResource } from "../lib/helpers"; +import { sanitizeResourceName, getParentContainerUrl, ensureTrailingSlash, copyFolderContents, deleteFolderResource } from "../lib/helpers"; interface RenameDialogProps { isOpen: boolean; @@ -56,7 +58,7 @@ export default function RenameDialog({ setIsRenaming(true); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); const sanitizedName = sanitizeResourceName(newName.trim()); const parentUrl = getParentContainerUrl(file.url); const parentWithSlash = ensureTrailingSlash(parentUrl); diff --git a/app/components/ShareSuccessModal.tsx b/app/components/ShareSuccessModal.tsx index 2e0012c..b25d56c 100644 --- a/app/components/ShareSuccessModal.tsx +++ b/app/components/ShareSuccessModal.tsx @@ -32,18 +32,6 @@ export default function ShareSuccessModal({ } }; - // Safely extract origin from URL - const getServerOrigin = () => { - if (!resourceUrl) return ""; - try { - return new URL(resourceUrl).origin; - } catch { - return ""; - } - }; - - const serverOrigin = getServerOrigin(); - return ( void; activeTab?: string; - currentContainerUrl?: string | null; onNewFolderClick?: () => void; onFileUploadClick?: () => void; onFolderUploadClick?: () => void; @@ -21,7 +20,6 @@ export default function Sidebar({ isOpen = true, onClose, activeTab = "my-storages", - currentContainerUrl, onNewFolderClick, onFileUploadClick, onFolderUploadClick, @@ -79,7 +77,6 @@ export default function Sidebar({
(null); + + useEffect(() => { + let cancelled = false; + fetchDiscovery(issuer).then((document) => { + if (!cancelled) setEndSessionUrl(document?.end_session_endpoint ?? null); + }); + return () => { + cancelled = true; + }; + }, [issuer]); + + return ( +

+ Signed out. You may still be signed in at{" "} + {hostOf(issuer)} + {endSessionUrl && ( + <> + {" — "} + + sign out there too + (opens in a new tab) + + + )} +

+ ); +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/app/components/providers/LdoProvider.tsx b/app/components/providers/LdoProvider.tsx deleted file mode 100644 index c506c09..0000000 --- a/app/components/providers/LdoProvider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { BrowserSolidLdoProvider } from "@ldo/solid-react"; - -export default function LdoProvider({ - children, -}: { - children: React.ReactNode; -}) { - return {children}; -} - diff --git a/app/components/providers/SolidAuthProvider.tsx b/app/components/providers/SolidAuthProvider.tsx new file mode 100644 index 0000000..09142bc --- /dev/null +++ b/app/components/providers/SolidAuthProvider.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { AuthorizationCodeFlow } from "@solid/reactive-authentication"; +import { ensureElementsRegistered } from "@/app/lib/auth/elements"; +import { + getCurrentSession, + initAuthManager, + resetAuthManager, + setCurrentSession, + subscribeToSession, + type SolidCredentials, +} from "@/app/lib/auth/manager"; + +const STORAGE_KEY = "solid-file-manager-session"; + +export type SolidAuthStatus = "restoring" | "anonymous" | "authenticated"; + +export interface SolidAuthContextValue { + session: { + isLoggedIn: boolean; + webId: string | null; + issuer: string | null; + }; + status: SolidAuthStatus; + // The issuer of the session just ended, so the login page can offer to sign + // out there too. In memory only: the offer is stale after a reload. + signedOutFrom: string | null; + + login: (credentials: { webId: string; issuer: string }) => void; + logout: () => Promise; +} + +export const SolidAuthContext = createContext(null); + +export default function SolidAuthProvider({ children }: { children: ReactNode }) { + const codeUiRef = useRef(null); + const generationRef = useRef(0); + + const credentials = useSyncExternalStore( + subscribeToSession, + getCurrentSession, + () => null, + ); + const [status, setStatus] = useState("restoring"); + const [signedOutFrom, setSignedOutFrom] = useState(null); + + const buildManager = useCallback( + () => + initAuthManager({ + callbackUri: `${window.location.origin}/callback.html`, + // Read the method lazily: the custom element may not have upgraded yet. + getCode: (authorizationUri, signal) => { + const ui = codeUiRef.current; + if (!ui) throw new Error("authorization-code-flow is not mounted"); + return ui.getCode(authorizationUri, signal); + }, + }), + [], + ); + + useEffect(() => { + const generation = ++generationRef.current; + + async function restore() { + await ensureElementsRegistered(); + await buildManager(); + if (generationRef.current !== generation) return; + + // Only the identity is persisted. Tokens live in the token provider + // and die with the page, so the next protected request re-runs the + // flow, silently while the issuer's cookie lasts. + const stored = readStoredCredentials(); + if (stored) setCurrentSession(stored); + setStatus(stored ? "authenticated" : "anonymous"); + } + + restore(); + }, [buildManager]); + + const login = useCallback((next: { webId: string; issuer: string }) => { + generationRef.current++; + setCurrentSession(next); + writeStoredCredentials(next); + setSignedOutFrom(null); + setStatus("authenticated"); + }, []); + + const logout = useCallback(async () => { + const generation = ++generationRef.current; + const previousIssuer = getCurrentSession()?.issuer ?? null; + + clearStoredCredentials(); + setStatus("restoring"); + + resetAuthManager(); + await buildManager(); + if (generationRef.current !== generation) return; + setSignedOutFrom(previousIssuer); + setStatus("anonymous"); + }, [buildManager]); + + const value = useMemo( + () => ({ + session: { + isLoggedIn: status === "authenticated", + webId: credentials?.webId ?? null, + issuer: credentials?.issuer ?? null, + }, + status, + signedOutFrom, + login, + logout, + }), + [credentials, status, signedOutFrom, login, logout], + ); + + return ( + + {children} + {/* Hosts the login popup and its blocked-popup dialogs. */} + + + ); +} + +function isCredentials(value: unknown): value is SolidCredentials { + return ( + typeof value === "object" && + value !== null && + typeof (value as SolidCredentials).webId === "string" && + typeof (value as SolidCredentials).issuer === "string" + ); +} + +function readStoredCredentials(): SolidCredentials | null { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); + return isCredentials(parsed) ? parsed : null; + } catch (error) { + console.warn("Discarding an unreadable stored session", error); + return null; + } +} + +function writeStoredCredentials(credentials: SolidCredentials): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials)); + } catch (error) { + // The session still works; it just won't survive a reload. + console.warn("Could not persist the session", error); + } +} + +function clearStoredCredentials(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + // The identity stays on disk until something else clears it. + console.warn("Could not clear the stored session", error); + } +} diff --git a/app/components/shared/FullPageLoader.tsx b/app/components/shared/FullPageLoader.tsx new file mode 100644 index 0000000..bd04ccb --- /dev/null +++ b/app/components/shared/FullPageLoader.tsx @@ -0,0 +1,11 @@ +"use client"; + +import LoadingSpinner from "./LoadingSpinner"; + +export default function FullPageLoader({ text = "Loading..." }: { text?: string }) { + return ( +
+ +
+ ); +} diff --git a/app/components/shared/Input.tsx b/app/components/shared/Input.tsx index b124246..1ddd5d1 100644 --- a/app/components/shared/Input.tsx +++ b/app/components/shared/Input.tsx @@ -1,6 +1,6 @@ "use client"; -import { InputHTMLAttributes, ReactNode, forwardRef } from "react"; +import { InputHTMLAttributes, ReactNode, forwardRef, useId } from "react"; interface InputProps extends InputHTMLAttributes { label?: string; @@ -20,7 +20,10 @@ const Input = forwardRef(function Input({ id, ...props }, ref) { - const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`; + // useId, not a random value: a fresh id each render breaks the label's htmlFor + // pairing and never matches what the server rendered. + const generatedId = useId(); + const inputId = id || generatedId; const hasError = !!error; const baseInputClasses = diff --git a/app/components/shared/LoadingSpinner.tsx b/app/components/shared/LoadingSpinner.tsx index 8de61cf..a8b1412 100644 --- a/app/components/shared/LoadingSpinner.tsx +++ b/app/components/shared/LoadingSpinner.tsx @@ -1,7 +1,5 @@ "use client"; -import { SVGProps } from "react"; - interface LoadingSpinnerProps { size?: "sm" | "md" | "lg"; className?: string; diff --git a/app/layout.tsx b/app/layout.tsx index f4b86b1..60800b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Toaster from "./components/shared/Toaster"; -import LdoProvider from "./components/providers/LdoProvider"; +import SolidAuthProvider from "./components/providers/SolidAuthProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -34,10 +34,10 @@ export default function RootLayout({ - + {children} - + ); diff --git a/app/lib/auth/custom-elements.d.ts b/app/lib/auth/custom-elements.d.ts new file mode 100644 index 0000000..fb41787 --- /dev/null +++ b/app/lib/auth/custom-elements.d.ts @@ -0,0 +1,18 @@ +import type { DetailedHTMLProps, HTMLAttributes } from "react"; +import type { + AuthorizationCodeFlow, + IdpPicker, + WebIdPicker, +} from "@solid/reactive-authentication"; + +type CustomElement = DetailedHTMLProps, T>; + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "authorization-code-flow": CustomElement; + "idp-picker": CustomElement; + "webid-picker": CustomElement; + } + } +} diff --git a/app/lib/auth/discovery.ts b/app/lib/auth/discovery.ts new file mode 100644 index 0000000..1eb763b --- /dev/null +++ b/app/lib/auth/discovery.ts @@ -0,0 +1,67 @@ +export interface DiscoveryDocument { + issuer: string; + end_session_endpoint?: string; + userinfo_endpoint?: string; +} + +const TIMEOUT_MS = 8000; + +// Holds the in-flight request, not just the result, so concurrent callers share +// one round trip instead of each firing their own. +const cache = new Map>(); + +/** + * Reads an authorization server's OIDC metadata. Returns null when the URL is + * not an issuer, the login page uses it to tell an issuer apart from a WebID. + */ +export function fetchDiscovery(issuer: string): Promise { + const url = discoveryUrl(issuer); + if (!url) return Promise.resolve(null); + + let pending = cache.get(url); + if (!pending) { + pending = read(url); + cache.set(url, pending); + + void pending.then( + (document) => { + if (!document) cache.delete(url); + }, + () => cache.delete(url), + ); + } + + return pending; +} + +async function read(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + if (!response.ok) return null; + + const document: unknown = await response.json(); + return isDiscoveryDocument(document) ? document : null; + } catch { + return null; + } +} + +function discoveryUrl(issuer: string): string | null { + try { + const url = new URL(issuer); + url.pathname = `${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`; + url.search = ""; + url.hash = ""; + return url.href; + } catch { + return null; + } +} + +function isDiscoveryDocument(value: unknown): value is DiscoveryDocument { + return ( + typeof value === "object" && + value !== null && + typeof (value as DiscoveryDocument).issuer === "string" + ); +} diff --git a/app/lib/auth/elements.ts b/app/lib/auth/elements.ts new file mode 100644 index 0000000..408afb8 --- /dev/null +++ b/app/lib/auth/elements.ts @@ -0,0 +1,17 @@ +let registration: Promise | null = null; + +// Dynamic import: the module runs customElements.define() on classes extending +// HTMLElement as soon as it loads, which throws during server rendering. Call +// this from an effect. +export function ensureElementsRegistered(): Promise { + if (typeof window === "undefined") { + throw new Error( + "ensureElementsRegistered() must run in the browser: the element module extends HTMLElement at import time.", + ); + } + + registration ??= import("@solid/reactive-authentication/registerElements").then( + () => undefined, + ); + return registration; +} diff --git a/app/lib/auth/identify.ts b/app/lib/auth/identify.ts new file mode 100644 index 0000000..8494a2d --- /dev/null +++ b/app/lib/auth/identify.ts @@ -0,0 +1,104 @@ +import { DataFactory, Parser, Store } from "n3"; +import { WebIdDataset } from "@/app/lib/class/WebIdDataset"; +import { fetchDiscovery } from "./discovery"; +import { LOOPBACK_HOSTS } from "./manager"; + +const TIMEOUT_MS = 8000; + +export type LoginTarget = + | { kind: "webid"; webId: string; issuers: string[] } + | { kind: "issuer"; issuer: string } + | { kind: "unknown" }; + +/** + * Works out whether the user typed a WebID or a login server, by asking both + * questions at once rather than guessing from the URL's shape + */ +export async function identify(input: string): Promise { + for (const url of candidateUrls(input)) { + const target = await identifyOne(url); + if (target.kind !== "unknown") return target; + } + return { kind: "unknown" }; +} + +async function identifyOne(url: string): Promise { + // The profile goes first so a WebID that names its issuer never triggers the + // discovery request, which would 404 and litter the console on every login. + const issuers = await probeProfile(url); + if (issuers?.length) return { kind: "webid", webId: url, issuers }; + + // An issuer identifier carries no fragment (OIDC Discovery §3), so a WebID + // ending in #me is not worth asking about — the request would 404, or 401 on + // a server that protects the whole path. + // + // A CSS pod root parses as Turtle with no issuer in it, so discovery has to + // win over an empty profile or the root would be mistaken for a WebID. + const discovery = url.includes("#") ? null : await fetchDiscovery(url); + if (discovery) return { kind: "issuer", issuer: discovery.issuer }; + + if (issuers) return { kind: "webid", webId: url, issuers: [] }; + return { kind: "unknown" }; +} + +/** + * The URLs worth trying for one input. + * + * A bare host gets https, except on loopback: a local Community Solid Server + * serves plain HTTP, so defaulting to TLS there just fails to connect. Both + * schemes are tried for loopback, http first, whichever was typed. + */ +function candidateUrls(input: string): string[] { + const trimmed = input.trim(); + if (!trimmed) return []; + + const scheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + + let url: URL; + try { + url = new URL(scheme); + } catch { + return []; + } + + if (!LOOPBACK_HOSTS.has(url.hostname)) return [url.href]; + + const insecure = new URL(url.href); + insecure.protocol = "http:"; + const secure = new URL(url.href); + secure.protocol = "https:"; + return [insecure.href, secure.href]; +} + +/** + * Returns the issuers named by a WebID profile, an empty array when the + * document is a profile we cannot read the issuers from, or null when it is not + * a profile at all. + */ +async function probeProfile(webId: string): Promise { + let response: Response; + try { + response = await fetch(webId, { + headers: { accept: "text/turtle" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch { + return null; + } + + // A profile we are not allowed to read is still a profile. The login page + // asks for the issuer instead of giving up. + if (response.status === 401 || response.status === 403) return []; + if (!response.ok) return null; + + try { + const store = new Store(); + const parser = new Parser({ baseIRI: response.url || webId }); + store.addQuads(parser.parse(await response.text())); + + const agent = new WebIdDataset(store, DataFactory).mainSubject; + return agent ? [...agent.oidcIssuers] : []; + } catch { + return null; + } +} diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts new file mode 100644 index 0000000..7f0975b --- /dev/null +++ b/app/lib/auth/manager.ts @@ -0,0 +1,199 @@ +import type { GetCodeCallback, TokenProvider } from "@solid/reactive-authentication"; + +export interface SolidCredentials { + // Null only while an issuer-first sign-in is asking userinfo who the user is. + webId: string | null; + issuer: string; +} + +export interface AuthManagerOptions { + callbackUri: string; + getCode: GetCodeCallback; +} + +let authFetch: typeof globalThis.fetch | null = null; +let initialization: Promise | null = null; +let sessionWebId: ((issuer: string) => Promise) | null = null; + +// Who we are currently authenticating as. Held here rather than in React so +// that plain modules — the session facade, the profile and contact helpers — +// read the same value the UI renders. +let currentSession: SolidCredentials | null = null; +const sessionListeners = new Set<() => void>(); + +// DPoPTokenProvider.matches() accepts every 401, from any host. This app fetches +// URLs it does not control (previews, shared resources, foreign WebIDs) so +// credentials go only to origins named here. +const allowedOrigins = new Set(); + +export const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +export function allowOrigin(url: string): void { + let origin: string; + try { + origin = new URL(url).origin; + } catch { + throw new Error(`allowOrigin() needs an absolute URL, received "${url}"`); + } + allowedOrigins.add(origin); +} + +function isAllowed(url: string): boolean { + try { + return allowedOrigins.has(new URL(url).origin); + } catch { + return false; + } +} + +function originGated(inner: TokenProvider): TokenProvider { + return { + async matches(request) { + return isAllowed(request.url) && inner.matches(request); + }, + upgrade: (request) => inner.upgrade(request), + }; +} + +export function getCurrentSession(): SolidCredentials | null { + return currentSession; +} + +export function setCurrentSession(next: SolidCredentials | null): void { + currentSession = next; + // Reading the profile is the first thing anything does with an identity. + if (next?.webId) allowOrigin(next.webId); + for (const listener of sessionListeners) listener(); +} + +export function subscribeToSession(listener: () => void): () => void { + sessionListeners.add(listener); + return () => { + sessionListeners.delete(listener); + }; +} + +export function initAuthManager( + options: AuthManagerOptions, +): Promise { + if (typeof window === "undefined") { + throw new Error( + "initAuthManager() must run in the browser: the package defines custom elements as soon as it loads.", + ); + } + + initialization ??= createFetch(options); + return initialization; +} + +async function createFetch(options: AuthManagerOptions) { + // Kept out of the top-level imports because the package declares classes extending + // HTMLElement at import time, which throws during server rendering. + const { DPoPTokenProvider, InsecureConfiguration, ReactiveFetchManager } = + await import("@solid/reactive-authentication"); + + let insecureAllowed = false; + + const tokenProvider = new DPoPTokenProvider( + options.callbackUri, + options.getCode, + async () => { + const session = getCurrentSession(); + if (!session) throw new Error("No issuer known: sign in first"); + + const issuer = new URL(session.issuer); + + // oauth4webapi refuses http:// issuers outright, which puts a local + // Community Solid Server out of reach. Its opt-out is a global switch, so + // it is flipped only for a loopback host and only once: a remote issuer + // must never be able to drop to plain HTTP. + if ( + !insecureAllowed && + issuer.protocol === "http:" && + LOOPBACK_HOSTS.has(issuer.hostname) + ) { + InsecureConfiguration.allow(); + insecureAllowed = true; + } + + return issuer; + }, + ); + + const provider = originGated(tokenProvider); + const manager = new ReactiveFetchManager([provider]); + + sessionWebId = (issuer) => tokenProvider.webId(new URL(issuer)); + + // Both read once and return a function, so we can bind them once and keep + // the result around. + const reactiveFetch = manager.fetch; + const plainFetch = globalThis.fetch.bind(globalThis); + + authFetch = async (input, init) => { + const request = new Request(input, init); + + if (getCurrentSession() && isAllowed(request.url)) { + try { + return await plainFetch(await provider.upgrade(request.clone())); + } catch (error) { + reportUpgradeFailure(request.url, error); + return plainFetch(request); + } + } + + return reactiveFetch(request); + }; + + return authFetch; +} + +const reportedFailures = new Set(); + +// A failed upgrade means the request goes out unauthenticated and will almost +// certainly come back 401, which reads as a permissions problem rather than the +// configuration error it usually is. Report each distinct reason once: these +// repeat per request, and a hundred identical lines hide the one that matters. +function reportUpgradeFailure(url: string, error: unknown): void { + const reason = error instanceof Error ? error.message : String(error); + if (reportedFailures.has(reason)) return; + + reportedFailures.add(reason); + console.error(`Sending ${url} without credentials: ${reason}`, error); +} + +/** + * The WebID the issuer asserted in its id_token, once a flow has completed for + * it. Null before that, and asking never starts one — so signing in with only an + * issuer means authenticating first, then asking who that turned out to be. + */ +export async function getAuthenticatedWebId(issuer: string): Promise { + if (sessionWebId === null) return null; + + try { + return (await sessionWebId(issuer)) ?? null; + } catch { + return null; + } +} + +export function getAuthFetch(): typeof globalThis.fetch { + if (authFetch === null) { + throw new Error( + "getAuthFetch() called before initAuthManager() resolved. Render behind the auth provider.", + ); + } + return authFetch; +} + +// The library keeps its session in a private field on the provider with no way +// to clear it, so logging out means dropping the provider. +// TODO: replace with the provider's own teardown if the library grows one. +export function resetAuthManager(): void { + authFetch = null; + initialization = null; + sessionWebId = null; + allowedOrigins.clear(); + reportedFailures.clear(); + setCurrentSession(null); +} diff --git a/app/lib/class/Agent.ts b/app/lib/class/Agent.ts index 6c0ba19..39dcb9f 100644 --- a/app/lib/class/Agent.ts +++ b/app/lib/class/Agent.ts @@ -1,6 +1,7 @@ import { LiteralAs, NamedNodeAs, NamedNodeFrom, OptionalFrom, SetFrom, TermAs, TermWrapper } from "@rdfjs/wrapper" import { FOAF, PIM, SOLID, VCARD } from "@/app/lib/class/Vocabulary" +// TODO: Migrate this to the objects library export class Agent extends TermWrapper { get vcardFn(): string | undefined { return OptionalFrom.subjectPredicate(this, VCARD.fn, LiteralAs.string) @@ -63,6 +64,11 @@ export class Agent extends TermWrapper { return SetFrom.subjectPredicate(this, SOLID.storage, NamedNodeAs.string, NamedNodeFrom.string) } + // A profile may name several; the login page asks which one to use. + get oidcIssuers(): Set { + return SetFrom.subjectPredicate(this, SOLID.oidcIssuer, NamedNodeAs.string, NamedNodeFrom.string) + } + get email(): string | null { return this.hasEmail?.actualValue ?? null } diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index 1cff865..a69c10b 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -8,7 +8,8 @@ * N3.js: for creating/updating ACRs */ -import { getAuthenticatedSession } from "./sessionUtils"; +import { getAuthFetch } from "../auth/manager"; +import { httpStatusOf } from "./errorUtils"; import { AcrDataset } from "@/app/lib/class/AcrDataset"; import type { DatasetCore } from "@rdfjs/types" import { AccessControlResource } from "@/app/lib/class/AccessControlResource" @@ -107,7 +108,7 @@ export async function detectServerAuthMethod( } return 'unknown'; - } catch (error) { + } catch { return 'unknown'; } } @@ -154,7 +155,7 @@ async function fetchAcr(acrUrl: string, fetchFn: typeof fetch): Promise { try { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const headResponse = await fetch(resourceUrl, { method: "HEAD", @@ -396,7 +397,7 @@ export async function getResourceAccessList(resourceUrl: string): Promise | null> { try { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const acrUrl = await getAcrUrl(resourceUrl, fetch); const response = await fetch(acrUrl, { @@ -465,7 +466,7 @@ export async function removeAccessFromResource( resourceUrl: string, webIdToRemove: string ): Promise { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const acrUrl = await getAcrUrl(resourceUrl, fetch); // Fetch the existing ACR diff --git a/app/lib/helpers/contactUtils.ts b/app/lib/helpers/contactUtils.ts index 2937854..6606b97 100644 --- a/app/lib/helpers/contactUtils.ts +++ b/app/lib/helpers/contactUtils.ts @@ -1,5 +1,5 @@ import { fetchAndParseProfile } from "./profileUtils"; -import { getSession, getAuthenticatedSession } from "./sessionUtils"; +import { getAuthFetch, getCurrentSession } from "../auth/manager"; import { fromRdfJsDataset, saveSolidDatasetAt } from "@inrupt/solid-client"; export interface Contact { @@ -14,16 +14,14 @@ export interface Contact { * @returns Array of contacts with their WebID, name, and email */ export async function fetchUserContacts(): Promise { - const session = getSession(); + const userWebId = getCurrentSession()?.webId; - if (!session.info.isLoggedIn || !session.info.webId) { + if (!userWebId) { return []; } try { - const userWebId = session.info.webId; - - const userProfile = await fetchAndParseProfile(session.info.webId); + const userProfile = await fetchAndParseProfile(userWebId); const contacts: Contact[] = []; @@ -62,14 +60,13 @@ export async function fetchUserContacts(): Promise { * @returns Promise that resolves when the contact is added */ export async function addContactToProfile(contactWebId: string): Promise { - const session = getSession(); + const userWebId = getCurrentSession()?.webId; - if (!session.info.isLoggedIn || !session.info.webId) { + if (!userWebId) { throw new Error("User is not logged in"); } - const userWebId = session.info.webId; - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const profileUrl = userWebId.split('#')[0]; diff --git a/app/lib/helpers/dragDropUtils.ts b/app/lib/helpers/dragDropUtils.ts index e5d28fb..a4756a4 100644 --- a/app/lib/helpers/dragDropUtils.ts +++ b/app/lib/helpers/dragDropUtils.ts @@ -2,6 +2,14 @@ import { FolderUploadFile } from "./uploadUtils"; // Type definitions for File System Access API +/** + * The File System Access API's addition to DataTransferItem, which lib.dom does + * not declare yet. It is what makes a dropped folder readable. + */ +type FileSystemAccessItem = DataTransferItem & { + getAsFileSystemHandle?: () => Promise; +}; + interface FileSystemHandle { readonly kind: "file" | "directory"; readonly name: string; @@ -68,9 +76,10 @@ export async function processDragDropItems( for (const item of items) { // Check if File System Access API is available - if ((item as any).getAsFileSystemHandle) { + const handleSource = item as FileSystemAccessItem; + if (handleSource.getAsFileSystemHandle) { try { - const handle = (await (item as any).getAsFileSystemHandle()) as FileSystemHandle | null; + const handle = await handleSource.getAsFileSystemHandle(); if (handle && handle.kind === "directory") { hasDirectories = true; // Recursively read directory contents @@ -99,7 +108,7 @@ export async function processDragDropItems( // First pass: collect all relative paths and identify folder names Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as any).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath) { allRelativePaths.add(relativePath); // If path contains "/", extract the folder name (first part) @@ -114,7 +123,7 @@ export async function processDragDropItems( // Second pass: categorize files Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as any).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath && relativePath.includes("/")) { // File inside a folder folderFiles.push({ file, relativePath }); @@ -164,7 +173,7 @@ export function isUnsupportedFolderDrag(event: React.DragEvent): bo const allRelativePaths = new Set(); Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as any).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath) { allRelativePaths.add(relativePath); } @@ -174,7 +183,7 @@ export function isUnsupportedFolderDrag(event: React.DragEvent): bo return ( dataTransferFiles.length === 1 && allRelativePaths.size === 0 && - !(dataTransferFiles[0] as any).webkitRelativePath + !dataTransferFiles[0].webkitRelativePath ); } diff --git a/app/lib/helpers/errorUtils.ts b/app/lib/helpers/errorUtils.ts new file mode 100644 index 0000000..ab6a972 --- /dev/null +++ b/app/lib/helpers/errorUtils.ts @@ -0,0 +1,15 @@ +/** + * The HTTP status an error carries, or undefined when it carries none. + * + * @inrupt/solid-client reports the status on the error itself in some paths and + * under `response` in others, which is why callers kept reaching for `any`. + */ +export function httpStatusOf(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) return undefined; + + const direct = (error as { status?: unknown }).status; + if (typeof direct === "number") return direct; + + const nested = (error as { response?: { status?: unknown } }).response?.status; + return typeof nested === "number" ? nested : undefined; +} diff --git a/app/lib/helpers/idpHistoryUtils.ts b/app/lib/helpers/idpHistoryUtils.ts deleted file mode 100644 index e4606de..0000000 --- a/app/lib/helpers/idpHistoryUtils.ts +++ /dev/null @@ -1,29 +0,0 @@ -const LAST_IDP_KEY = "solid-file-manager-last-idp"; - -export function getLastIdp(): string { - if (typeof window === "undefined") return ""; - - try { - return localStorage.getItem(LAST_IDP_KEY)?.trim() ?? ""; - } catch (error) { - console.warn( - "Could not read last identity provider from localStorage", - error, - ); - return ""; - } -} - -export function saveLastIdp(issuer: string): void { - if (typeof window === "undefined") return; - const value = issuer.trim(); - if (!value) return; - try { - localStorage.setItem(LAST_IDP_KEY, value); - } catch (error) { - console.warn( - "Could not save last identity provider to localStorage", - error, - ); - } -} diff --git a/app/lib/helpers/index.ts b/app/lib/helpers/index.ts index e32dc7f..0af8db5 100644 --- a/app/lib/helpers/index.ts +++ b/app/lib/helpers/index.ts @@ -6,7 +6,6 @@ export * from "./urlUtils"; export * from "./breadcrumbUtils"; export * from "./fileTypeUtils"; export * from "./profileUtils"; -export * from "./sessionUtils"; export * from "./copyUtils"; export * from "./downloadUtils"; export * from "./deleteUtils"; diff --git a/app/lib/helpers/loginHistoryUtils.ts b/app/lib/helpers/loginHistoryUtils.ts new file mode 100644 index 0000000..7f52bea --- /dev/null +++ b/app/lib/helpers/loginHistoryUtils.ts @@ -0,0 +1,23 @@ +const LAST_ENTRY_KEY = "solid-file-manager-last-idp"; + +export function getLastLoginEntry(): string { + if (typeof window === "undefined") return ""; + + try { + return localStorage.getItem(LAST_ENTRY_KEY)?.trim() ?? ""; + } catch (error) { + console.warn("Could not read the last login entry from localStorage", error); + return ""; + } +} + +export function saveLastLoginEntry(entry: string): void { + if (typeof window === "undefined") return; + const value = entry.trim(); + if (!value) return; + try { + localStorage.setItem(LAST_ENTRY_KEY, value); + } catch (error) { + console.warn("Could not save the last login entry to localStorage", error); + } +} diff --git a/app/lib/helpers/profileUtils.ts b/app/lib/helpers/profileUtils.ts index e0863be..87de8ad 100644 --- a/app/lib/helpers/profileUtils.ts +++ b/app/lib/helpers/profileUtils.ts @@ -1,5 +1,5 @@ import { Parser, Store, DataFactory } from "n3"; -import { getSession } from "./sessionUtils"; +import { getAuthFetch } from "../auth/manager"; import { WebIdDataset } from "@/app/lib/class/WebIdDataset"; import type { Agent } from "@/app/lib/class/Agent"; @@ -17,8 +17,7 @@ export async function fetchAndParseProfile(webId: string): Promise { return profileCache.get(webId)!; } - const session = getSession(); - const fetchFn = session.fetch || fetch; + const fetchFn = getAuthFetch(); // Try different Accept headers to get the profile const acceptHeaders = [ @@ -44,7 +43,8 @@ export async function fetchAndParseProfile(webId: string): Promise { content = await response.text(); break; } - } catch (err) { + } catch { + // This Accept header did not work; try the next one. continue; } } @@ -68,8 +68,10 @@ export async function fetchAndParseProfile(webId: string): Promise { const parser = new Parser({ baseIRI: baseUrl }); const quads = parser.parse(content); store.addQuads(quads); - } catch (e) { - // Silent error handling + } catch (error) { + // Genuinely JSON-LD, which we cannot parse. The store stays empty and the + // missing-subject check below reports it. + console.warn(`Could not parse ${webId} as Turtle`, error); } } @@ -77,8 +79,9 @@ export async function fetchAndParseProfile(webId: string): Promise { const mainSubject: Agent | undefined = webIdDataset.mainSubject; if (mainSubject === undefined) { -console.log("BOB") - throw new Error; // TODO: Handle properly + // mainSubject is located by its solid:oidcIssuer, so a profile without one + // reads as empty here even when it parsed fine. + throw new Error(`No WebID subject with an oidcIssuer found in ${webId}`); } // Cache the result diff --git a/app/lib/helpers/sessionUtils.ts b/app/lib/helpers/sessionUtils.ts deleted file mode 100644 index 955ea7b..0000000 --- a/app/lib/helpers/sessionUtils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getDefaultSession, Session } from "@inrupt/solid-client-authn-browser"; - -/** - * Gets the current Solid session and optionally validates authentication. - * - * @param requireAuth - If true, throws an error if the user is not authenticated. Defaults to true. - * @returns An object containing the session and authenticated fetch function. - * @throws Error if requireAuth is true and the user is not authenticated. - */ -export function getAuthenticatedSession(requireAuth: boolean = true): { - session: Session; - fetch: typeof fetch; -} { - const session = getDefaultSession(); - - if (requireAuth && !session.info.isLoggedIn) { - throw new Error("Not authenticated"); - } - - return { - session, - fetch: session.fetch || fetch, - }; -} - -/** - * Gets the current Solid session without requiring authentication. - * Useful for checking session state without throwing errors. - * - * @returns The current session. - */ -export function getSession(): Session { - return getDefaultSession(); -} - diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index 60243fa..d533b6b 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -1,6 +1,7 @@ // This helper file handles uploading files and folders to Solid containers. import { createContainerAt, overwriteFile, UrlString } from "@inrupt/solid-client"; import { ensureTrailingSlash, sanitizeResourceName } from "."; +import { httpStatusOf } from "./errorUtils"; import toast from "react-hot-toast"; export interface FolderUploadFile { @@ -125,8 +126,8 @@ async function ensureFolderExists( try { await createContainerAt(folderUrl as UrlString, { fetch: fetchFn }); - } catch (error: any) { - const statusCode = error?.response?.status; + } catch (error) { + const statusCode = httpStatusOf(error); const errorMessage = error instanceof Error ? error.message : String(error); if (statusCode === 409 || diff --git a/app/lib/helpers/urlUtils.ts b/app/lib/helpers/urlUtils.ts index 4834d2f..7c57886 100644 --- a/app/lib/helpers/urlUtils.ts +++ b/app/lib/helpers/urlUtils.ts @@ -15,12 +15,12 @@ export function extractNameFromUrl(url: string): string { try { name = decodeURIComponent(name); - } catch (e) { + } catch { // Keep original name if decoding fails } return name; - } catch (e) { + } catch { // If URL parsing fails, try to extract from the string directly const parts = url.split("/").filter(Boolean); const lastPart = parts[parts.length - 1] || url; @@ -41,7 +41,7 @@ export function extractNameFromUrl(url: string): string { export function resolveUrl(url: string, baseUrl: string): string { try { return new URL(url, baseUrl).href; - } catch (e) { + } catch { return url; } } diff --git a/app/lib/hooks/index.ts b/app/lib/hooks/index.ts index 77dddc2..648cbf9 100644 --- a/app/lib/hooks/index.ts +++ b/app/lib/hooks/index.ts @@ -9,6 +9,7 @@ * ``` */ +export { useSolidAuth } from "./useSolidAuth"; export { useSolidStorages } from "./useSolidStorages"; export type { SolidStorage } from "./useSolidStorages"; export { useUserProfile } from "./useUserProfile"; diff --git a/app/lib/hooks/useBrowseStorage.ts b/app/lib/hooks/useBrowseStorage.ts index f507317..0a3f9a3 100644 --- a/app/lib/hooks/useBrowseStorage.ts +++ b/app/lib/hooks/useBrowseStorage.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { getAuthenticatedSession } from "../helpers"; +import { getAuthFetch } from "../auth/manager"; import { getSolidDataset, toRdfJsDataset } from "@inrupt/solid-client"; import { FileItemData } from "../../components/FileItem"; import { ContainerDataset } from "../class/ContainerDataset"; @@ -35,7 +35,7 @@ export function useBrowseStorage(containerUrl: string | null, refreshKey?: numbe setIsLoading(true); setError(null); - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); // This is a cache-busting fetch wrapper for when refreshKey is provided // This ensures we get fresh data after uploads/deletes diff --git a/app/lib/hooks/useSolidAuth.ts b/app/lib/hooks/useSolidAuth.ts new file mode 100644 index 0000000..6135f31 --- /dev/null +++ b/app/lib/hooks/useSolidAuth.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useContext } from "react"; +import { + SolidAuthContext, + type SolidAuthContextValue, +} from "@/app/components/providers/SolidAuthProvider"; + +export function useSolidAuth(): SolidAuthContextValue { + const value = useContext(SolidAuthContext); + if (value === null) { + throw new Error("useSolidAuth() must be called inside "); + } + return value; +} diff --git a/app/lib/hooks/useSolidStorages.ts b/app/lib/hooks/useSolidStorages.ts index fe82892..3831ff8 100644 --- a/app/lib/hooks/useSolidStorages.ts +++ b/app/lib/hooks/useSolidStorages.ts @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; import { Parser, Store, NamedNode } from "n3"; +import { allowOrigin, getAuthFetch } from "../auth/manager"; import { fetchAndParseProfile } from "../helpers/profileUtils"; +import { useSolidAuth } from "./useSolidAuth"; // Storage predicates and types const PIM_STORAGE_TYPE = "http://www.w3.org/ns/pim/space#Storage"; @@ -83,8 +84,8 @@ async function discoverStorageViaTraversal( const parser = new Parser({ baseIRI: currentUrl }); const quads = parser.parse(content); store.addQuads(quads); - } catch (e) { - // Move up one level and continue + } catch { + // Not RDF we can read. Move up one level and continue. const parentUrl = currentUrl.substring(0, currentUrl.lastIndexOf('/', currentUrl.length - 2) + 1); if (parentUrl === currentUrl || parentUrl === `${url.origin}/`) { break; @@ -117,7 +118,7 @@ async function discoverStorageViaTraversal( } currentUrl = parentUrl; level++; - } catch (error) { + } catch { // If we can't fetch a container, try the parent const parentUrl = currentUrl.substring(0, currentUrl.lastIndexOf('/', currentUrl.length - 2) + 1); if (parentUrl === currentUrl || parentUrl === `${url.origin}/`) { @@ -128,9 +129,9 @@ async function discoverStorageViaTraversal( } } } catch (error) { - // Silent error handling + console.warn("Could not traverse the WebID hierarchy for a storage root", error); } - + return storageUrls; } @@ -142,6 +143,7 @@ async function discoverStorageViaTraversal( */ export function useSolidStorages(): UseSolidStoragesResult { const { session } = useSolidAuth(); + const { isLoggedIn, webId } = session; const [storages, setStorages] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -157,56 +159,41 @@ export function useSolidStorages(): UseSolidStoragesResult { setError(null); // Wait for authentication to complete - if (!session.isLoggedIn || !session.webId) { + if (!isLoggedIn || !webId) { setIsLoading(false); return; } - const webId = session.webId; - // Use shared profile fetching utility (with caching) const mainSubject = await fetchAndParseProfile(webId); - // Get storage roots using both pim:storage and solid:storage predicates const storageUrls: Set = mainSubject.storageUrls; - console.log("URLS", storageUrls) // Method 2: Hierarchical traversal (if no storage found via predicates) // Based on: https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts#L73-L104 if (storageUrls.size === 0) { try { - // LDO's session object has fetch property, but TypeScript types may not include it - // Use type assertion to access it, with fallback to regular fetch - const fetchFn = ('fetch' in session && typeof (session as any).fetch === 'function') - ? (session as any).fetch - : fetch; - const traversalStorages = await discoverStorageViaTraversal(webId, fetchFn); + const traversalStorages = await discoverStorageViaTraversal(webId, getAuthFetch()); traversalStorages.forEach(url => { if (!storageUrls.has(url)) { storageUrls.add(url); } }); } catch (err) { - // Silent error handling + console.warn("Storage traversal from the WebID failed", err); } } - // If still no storage found, try to infer from WebID + // Last resort: assume the pod is at the root of the WebID's own origin. if (storageUrls.size === 0) { - // Extract base URL from WebID - const webIdUrl = new URL(webId); - const baseUrl = `${webIdUrl.protocol}//${webIdUrl.host}/`; - - // For solidcommunity.net, storage is typically at the root - if (webId.includes("solidcommunity.net")) { - storageUrls.add(baseUrl); - } else { - // For other providers, try common patterns - storageUrls.add(baseUrl); - } + storageUrls.add(new URL(webId).origin + "/"); } + // A pod usually lives on a different origin from the WebID, so it has + // to be vouched for before anything will authenticate against it. + storageUrls.forEach((url) => allowOrigin(url)); + // Convert to SolidStorage format const discoveredStorages: SolidStorage[] = [...storageUrls].map((url) => { return { @@ -227,16 +214,16 @@ export function useSolidStorages(): UseSolidStoragesResult { } } - if (session.isLoggedIn && session.webId) { + if (isLoggedIn && webId) { fetchStorages(); } else { setIsLoading(false); } - + return () => { isMounted = false; }; - }, [session.isLoggedIn, session.webId]); + }, [isLoggedIn, webId]); return { storages, isLoading, error }; } diff --git a/app/lib/hooks/useUserProfile.ts b/app/lib/hooks/useUserProfile.ts index 2b215f0..95edf81 100644 --- a/app/lib/hooks/useUserProfile.ts +++ b/app/lib/hooks/useUserProfile.ts @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; import { fetchAndParseProfile } from "../helpers/profileUtils"; +import { useSolidAuth } from "./useSolidAuth"; export interface UserProfile { name: string | null; @@ -26,6 +26,7 @@ interface UseUserProfileResult { */ export function useUserProfile(): UseUserProfileResult { const { session } = useSolidAuth(); + const { isLoggedIn, webId } = session; const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -37,13 +38,11 @@ export function useUserProfile(): UseUserProfileResult { setError(null); // Wait for authentication to complete - if (!session.isLoggedIn || !session.webId) { + if (!isLoggedIn || !webId) { setIsLoading(false); return; } - const webId = session.webId; - // Use shared profile fetching utility (with caching) const { name, email, photoUrl, phone, organization, role, title, website } = await fetchAndParseProfile(webId); @@ -70,7 +69,7 @@ export function useUserProfile(): UseUserProfileResult { } fetchProfile(); - }, []); + }, [isLoggedIn, webId]); return { profile, isLoading, error }; } diff --git a/app/lib/types/webkitdirectory.d.ts b/app/lib/types/webkitdirectory.d.ts new file mode 100644 index 0000000..0204a14 --- /dev/null +++ b/app/lib/types/webkitdirectory.d.ts @@ -0,0 +1,12 @@ +import "react"; + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the name must match React's own declaration for the merge to apply + interface InputHTMLAttributes { + /** + * Non-standard, but supported everywhere that matters: lets a file input + * pick a whole directory. React does not declare it. + */ + webkitdirectory?: string; + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index eb59456..0b0973b 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,31 +1,22 @@ "use client"; -import { Suspense } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { - SolidLoginNavigationProviderNext, - AuthGuard, -} from "solid-react-component/login/next"; import LoginPage from "../components/LoginPage"; - -const loadingFallback = ( -
- Loading... -
-); +import FullPageLoader from "../components/shared/FullPageLoader"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; export default function Login() { const router = useRouter(); - return ( - - - - - - - - ); -} + const { status } = useSolidAuth(); + + useEffect(() => { + if (status === "authenticated") router.replace("/"); + }, [status, router]); + // Hold the form back until we know there is no session to restore, so a + // returning user is not shown a sign-in form on their way through. + if (status !== "anonymous") return ; + + return ; +} diff --git a/app/page.tsx b/app/page.tsx index 5ae4aab..95eea12 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,33 +1,17 @@ "use client"; import { Suspense } from "react"; -import { - SolidLoginNavigationProviderNext, - AuthGuard, -} from "solid-react-component/login/next"; -import LoadingSpinner from "./components/shared/LoadingSpinner"; +import AuthWrapper from "./components/AuthWrapper"; import FileManager from "./components/FileManager"; - -const loadingFallback = ( -
- -
-); - -function FileManagerContent() { - return ; -} +import FullPageLoader from "./components/shared/FullPageLoader"; export default function Home() { + // FileManager reads search params, which needs a Suspense boundary above it. return ( - - - - - - + }> + + + ); } diff --git a/next.config.ts b/next.config.ts index d3dbf82..cb651cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - transpilePackages: ["solid-react-component"], -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 2ac325a..0d12881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,14 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "^1.23.1", - "@inrupt/solid-client-authn-browser": "^3.1.1", - "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", + "@solid/reactive-authentication": "github:PreciousOritsedere/reactive-authentication", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", - "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.1" + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", @@ -80,6 +78,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -323,6 +322,7 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/@bergos/jsonparse/-/jsonparse-1.4.2.tgz", "integrity": "sha512-qUt0QNJjvg4s1zk+AuLM6s/zcsQ8MvGn7+1f0vPuxvpCYa08YtTryuDInngbEyW5fNGGYe2znKt61RMGd5HnXg==", + "dev": true, "engines": [ "node >= 0.2.0" ], @@ -6645,31 +6645,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@inrupt/oidc-client": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@inrupt/oidc-client/-/oidc-client-1.11.6.tgz", - "integrity": "sha512-1rCTk1T6pdm/7gKozutZutk7jwmYBADlnkGGoI5ypke099NOCa5KFXjkQpbjsps0PRkKZ+0EaR70XN5+xqmViA==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^7.4.1", - "base64-js": "^1.5.1", - "core-js": "^3.8.3", - "crypto-js": "^4.0.0", - "serialize-javascript": "^4.0.0" - } - }, - "node_modules/@inrupt/oidc-client-ext": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@inrupt/oidc-client-ext/-/oidc-client-ext-3.1.1.tgz", - "integrity": "sha512-vftKD2u5nufZTFkdUDMS3Uxj5xNQwArP11OFaALFkq6/3RwCAhe3lwOv8hNzL7Scv98T+KbAErBM0TwGGrS69g==", - "license": "MIT", - "dependencies": { - "@inrupt/oidc-client": "^1.11.6", - "@inrupt/solid-client-authn-core": "^3.1.1", - "jose": "^5.1.3", - "uuid": "^11.1.0" - } - }, "node_modules/@inrupt/solid-client": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@inrupt/solid-client/-/solid-client-1.23.1.tgz", @@ -6689,33 +6664,6 @@ "node": ">=14.0" } }, - "node_modules/@inrupt/solid-client-authn-browser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-browser/-/solid-client-authn-browser-3.1.1.tgz", - "integrity": "sha512-Wd7TREmvdhTp+Sk88ei3hlg54sG1fNqkkPkuS+2tDBkcsXaViRQAEugVyh5pWRkd1xSFKrEzftb7UYEG4mJ0CQ==", - "license": "MIT", - "dependencies": { - "@inrupt/oidc-client-ext": "^3.1.1", - "@inrupt/solid-client-authn-core": "^3.1.1", - "events": "^3.3.0", - "jose": "^5.1.3", - "uuid": "^11.1.0" - } - }, - "node_modules/@inrupt/solid-client-authn-core": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-3.1.1.tgz", - "integrity": "sha512-1oDSQCh/pVtPlTyvLQ2uwHo+hpLJF7izg82tjB+Ge8jqGYwkQyId0BrfncpCk//uJXxgRIcfAQp2MhXYbZo80Q==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0", - "jose": "^5.1.3", - "uuid": "^11.1.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0" - } - }, "node_modules/@inrupt/solid-client/node_modules/n3": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", @@ -6746,30 +6694,6 @@ "node": ">=12" } }, - "node_modules/@janeirodigital/interop-utils": { - "version": "1.0.0-rc.24", - "resolved": "https://registry.npmjs.org/@janeirodigital/interop-utils/-/interop-utils-1.0.0-rc.24.tgz", - "integrity": "sha512-mLOhitq6SyRSZi1DxrzTTgms7Mt0zgx/5KezkkyMBH3OYuYJBGPH6A93iBJl0wA5Ln90A9KnyiC7I/7+IUYhoQ==", - "license": "MIT", - "dependencies": { - "http-link-header": "^1.1.1", - "jsonld-streaming-parser": "^3.2.1", - "n3": "^1.17.1" - } - }, - "node_modules/@janeirodigital/interop-utils/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, "node_modules/@jeswr/prefixcc": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jeswr/prefixcc/-/prefixcc-1.2.1.tgz", @@ -6880,132 +6804,6 @@ } } }, - "node_modules/@ldo/connected": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/connected/-/connected-1.0.0-alpha.32.tgz", - "integrity": "sha512-zH9MHnPNA9fGRbWB5ON6iXg8XYgwdig03Cpg/5XhHiWi7Kvm5/yiZHVFWBQTxR2d6C0KkrCEp9jdJgyY8SDSqw==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/ldo": "^1.0.0-alpha.32", - "@ldo/rdf-utils": "^1.0.0-alpha.30" - } - }, - "node_modules/@ldo/connected-solid": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/connected-solid/-/connected-solid-1.0.0-alpha.32.tgz", - "integrity": "sha512-594i0GDwFeoqmDRcpQASuInOF1zYG+1qnBJEhyHQH5sspuIIPtskdXrTOC3tbt1zAl8wepf9tcVht6aUdwZjHA==", - "license": "MIT", - "dependencies": { - "@ldo/connected": "^1.0.0-alpha.32", - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/ldo": "^1.0.0-alpha.32", - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@solid-notifications/subscription": "^0.1.2", - "cross-fetch": "^3.1.6", - "http-link-header": "^1.1.1", - "ws": "^8.18.0" - } - }, - "node_modules/@ldo/dataset": { - "version": "1.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@ldo/dataset/-/dataset-1.0.0-alpha.30.tgz", - "integrity": "sha512-XKGtsOULCZ32AtNlGqNYGjaZADwJtIWuILmL12TWeWpjzSfEodpzudjx4Ux+SES56MflGVl5CNuazsyj9+/5Gg==", - "license": "MIT", - "dependencies": { - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@rdfjs/dataset": "^1.1.0", - "buffer": "^6.0.3", - "readable-stream": "^4.2.0" - } - }, - "node_modules/@ldo/jsonld-dataset-proxy": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/jsonld-dataset-proxy/-/jsonld-dataset-proxy-1.0.0-alpha.32.tgz", - "integrity": "sha512-ll8jOP6L6sCEce73PNkBZWhs+GmDE1vNsg/7fRv6eqROZJOhOqHTs74/qkvyOTzRVVOthIB0MMoRva4kE96r2Q==", - "license": "MIT", - "dependencies": { - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@ldo/subscribable-dataset": "^1.0.0-alpha.32", - "jsonld2graphobject": "^0.0.4" - } - }, - "node_modules/@ldo/ldo": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/ldo/-/ldo-1.0.0-alpha.32.tgz", - "integrity": "sha512-B5yEKAjpQA4VbXOv3faxYYxjgDZUSxTy4vCSATpVvGt96RxolJzewJ7ELl0C2KG0EANcWoHyUB0ac7oOJrmUCQ==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/jsonld-dataset-proxy": "^1.0.0-alpha.32", - "@ldo/subscribable-dataset": "^1.0.0-alpha.32", - "buffer": "^6.0.3", - "readable-stream": "^4.3.0" - } - }, - "node_modules/@ldo/rdf-utils": { - "version": "1.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@ldo/rdf-utils/-/rdf-utils-1.0.0-alpha.30.tgz", - "integrity": "sha512-nYCaf//tysYOhQfj1SmYTvuRzAK1VCENMOFYJlF0oNKIK/pEqXOkxFKt8yhkNEZ5e9BZ5ofLmGFeyj3OLiYivw==", - "license": "MIT", - "dependencies": { - "@rdfjs/data-model": "^1.2.0", - "n3": "^1.17.1", - "rdf-string": "^1.6.3" - } - }, - "node_modules/@ldo/rdf-utils/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@ldo/react": { - "version": "1.0.0-alpha.33", - "resolved": "https://registry.npmjs.org/@ldo/react/-/react-1.0.0-alpha.33.tgz", - "integrity": "sha512-DF/j1ZGtUIp7qo1ZQbHukgXrqdg3F7S0sLTt50Ua5no97YiorG/BXbRgrhy3tPn6azLkl4yGWPNuOm5JppA6Tg==", - "license": "MIT", - "dependencies": { - "@ldo/connected": "^1.0.0-alpha.32", - "@ldo/jsonld-dataset-proxy": "^1.0.0-alpha.32", - "@ldo/ldo": "^1.0.0-alpha.32", - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@ldo/subscribable-dataset": "^1.0.0-alpha.32", - "cross-fetch": "^3.1.6" - } - }, - "node_modules/@ldo/solid-react": { - "version": "1.0.0-alpha.33", - "resolved": "https://registry.npmjs.org/@ldo/solid-react/-/solid-react-1.0.0-alpha.33.tgz", - "integrity": "sha512-H7GN2SGWHsX1N5NF2c/lSbaPK3MLBZXtK6GvrXdWkR3I6kuUaZE6PsYMgx38CmJuIfh9dK4Vbz2+a36J30YMcg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@inrupt/solid-client-authn-browser": "^3.0.0", - "@ldo/connected": "^1.0.0-alpha.32", - "@ldo/connected-solid": "^1.0.0-alpha.32", - "@ldo/react": "^1.0.0-alpha.33", - "cross-fetch": "^3.1.6" - } - }, - "node_modules/@ldo/subscribable-dataset": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/subscribable-dataset/-/subscribable-dataset-1.0.0-alpha.32.tgz", - "integrity": "sha512-E42L2tDRxqOx5vxLGG0HiuZ6SMdW9iothJcFREV4f92kE9KE+l+Ope9hpfAmRfyNMdAEbl1wvW2iCfyj/J5naw==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "uuid": "^11.1.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -7588,53 +7386,6 @@ "text-hex": "1.0.x" } }, - "node_modules/@solid-notifications/discovery": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@solid-notifications/discovery/-/discovery-0.1.2.tgz", - "integrity": "sha512-jkqV+Ceknw2XE0Vl/4O2BBFnkCZQhNDVt6B9nzbVD4T3aNhMlK/gZS6oNHqa23obgFNCtgFBmeeRKiN1/v8lcw==", - "license": "MIT", - "dependencies": { - "@janeirodigital/interop-utils": "^1.0.0-rc.24", - "n3": "^1.17.2" - } - }, - "node_modules/@solid-notifications/discovery/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@solid-notifications/subscription": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@solid-notifications/subscription/-/subscription-0.1.2.tgz", - "integrity": "sha512-XnnqNsLOIdUAzB11aROzfRiJLHJjTOaHMSrnn3teQRtE0BwpbnAJtzGG/m3JNUR+QqyjKkB3jfibxJjzvI/HQg==", - "license": "MIT", - "dependencies": { - "@janeirodigital/interop-utils": "^1.0.0-rc.24", - "@solid-notifications/discovery": "^0.1.2", - "n3": "^1.17.2" - } - }, - "node_modules/@solid-notifications/subscription/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, "node_modules/@solid/access-token-verifier": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@solid/access-token-verifier/-/access-token-verifier-2.1.1.tgz", @@ -7817,6 +7568,29 @@ "url": "https://github.com/sponsors/rubensworks/" } }, + "node_modules/@solid/reactive-authentication": { + "version": "0.1.5", + "resolved": "git+ssh://git@github.com/PreciousOritsedere/reactive-authentication.git#9c654b371529853670247d1236ea97d8f8c58bf0", + "license": "MIT", + "dependencies": { + "@rdfjs/wrapper": "^0.34", + "dpop": "^2", + "n3": "^2", + "oauth4webapi": "^3" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@solid/reactive-authentication/node_modules/@rdfjs/wrapper": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.34.0.tgz", + "integrity": "sha512-psGw4IAH0E27P7Nlro0SzjqPJJ+aE3MkYKWfNbBk+vwT2Zt+gJJyuznuYg54peZs4rAF9FLoqCe4JdvQNDTzMw==", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@solidlab/policy-engine": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/@solidlab/policy-engine/-/policy-engine-0.0.2.tgz", @@ -8419,6 +8193,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/http-link-header/-/http-link-header-1.0.7.tgz", "integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -8459,6 +8234,7 @@ "version": "1.5.15", "resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.15.tgz", "integrity": "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==", + "dev": true, "license": "MIT" }, "node_modules/@types/keygrip": { @@ -8773,6 +8549,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8949,6 +8726,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9521,7 +9299,9 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9994,6 +9774,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10594,17 +10375,6 @@ "node": ">= 0.8" } }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -10653,17 +10423,12 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -10936,6 +10701,15 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11255,6 +11029,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11440,6 +11215,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13270,6 +13046,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -13407,71 +13184,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonld-streaming-parser": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.4.0.tgz", - "integrity": "sha512-897CloyQgQidfkB04dLM5XaAXVX/cN9A2hvgHJo4y4jRhIpvg3KLMBBfcrswepV2N3T8c/Rp2JeFdWfVsbVZ7g==", - "license": "MIT", - "dependencies": { - "@bergos/jsonparse": "^1.4.0", - "@rdfjs/types": "*", - "@types/http-link-header": "^1.0.1", - "@types/readable-stream": "^2.3.13", - "buffer": "^6.0.3", - "canonicalize": "^1.0.1", - "http-link-header": "^1.0.2", - "jsonld-context-parser": "^2.4.0", - "rdf-data-factory": "^1.1.0", - "readable-stream": "^4.0.0" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/@types/readable-stream": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", - "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "safe-buffer": "~5.1.1" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/jsonld-context-parser": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", - "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", - "license": "MIT", - "dependencies": { - "@types/http-link-header": "^1.0.1", - "@types/node": "^18.0.0", - "cross-fetch": "^3.0.6", - "http-link-header": "^1.0.2", - "relative-to-absolute-iri": "^1.0.5" - }, - "bin": { - "jsonld-context-parse": "bin/jsonld-context-parse.js" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/jsonld-streaming-parser/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/jsonld-streaming-serializer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/jsonld-streaming-serializer/-/jsonld-streaming-serializer-3.0.1.tgz", @@ -13508,58 +13220,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, - "node_modules/jsonld2graphobject": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/jsonld2graphobject/-/jsonld2graphobject-0.0.4.tgz", - "integrity": "sha512-7siWYw9/EaD9lWyMbHr2uLMy8kbNVyOtDlsAWJUlUjVfXpcJcwLN6f0qeNt0ySV4fDoAJOjJXNvo7V/McrubAg==", - "license": "MIT", - "dependencies": { - "@rdfjs/types": "^1.0.1", - "@types/jsonld": "^1.5.6", - "jsonld-context-parser": "^2.1.5", - "uuid": "^8.3.2" - } - }, - "node_modules/jsonld2graphobject/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/jsonld2graphobject/node_modules/jsonld-context-parser": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", - "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", - "license": "MIT", - "dependencies": { - "@types/http-link-header": "^1.0.1", - "@types/node": "^18.0.0", - "cross-fetch": "^3.0.6", - "http-link-header": "^1.0.2", - "relative-to-absolute-iri": "^1.0.5" - }, - "bin": { - "jsonld-context-parse": "bin/jsonld-context-parse.js" - } - }, - "node_modules/jsonld2graphobject/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/jsonld2graphobject/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -13663,6 +13323,7 @@ "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^1.3.8", "content-disposition": "~1.0.1", @@ -13706,6 +13367,7 @@ "resolved": "https://registry.npmjs.org/ky/-/ky-0.25.1.tgz", "integrity": "sha512-PjpCEWlIU7VpiMVrTwssahkYXX1by6NCT0fhTUX34F3DTinARlgMpriuroolugFPcMgpPWrOW4mTb984Qm1RXA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -14702,6 +14364,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -15227,15 +14898,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -15268,6 +14930,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz", "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "dev": true, "license": "MIT", "dependencies": { "@rdfjs/types": "^1.0.0" @@ -15669,6 +15332,7 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.3.tgz", "integrity": "sha512-HIVwQ2gOqf+ObsCLSUAGFZMIl3rh9uGcRf1KbM85UDhKqP+hy6qj7Vz8FKt3GA54RiThqK3mNcr66dm1LP0+6g==", + "dev": true, "license": "MIT", "dependencies": { "@rdfjs/types": "*", @@ -15873,6 +15537,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15882,6 +15547,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16107,6 +15773,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/relative-to-absolute-iri/-/relative-to-absolute-iri-1.0.7.tgz", "integrity": "sha512-Xjyl4HmIzg2jzK/Un2gELqbcE8Fxy85A/aLSHE6PE/3+OGsFwmKVA1vRyGaz6vLWSqLDMHA+5rjD/xbibSQN1Q==", + "dev": true, "license": "MIT" }, "node_modules/require-directory": { @@ -16336,15 +16003,6 @@ "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16669,16 +16327,6 @@ "node": ">=10" } }, - "node_modules/solid-react-component": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.1.tgz", - "integrity": "sha512-gENFQeJLeXqPo51UD+PRP8YTbflSsxwceiFAbv0c4DQM3fvbq6OtiHejWZsZoGrqHwd+Sh5h8SR4wGhN976rcg==", - "license": "MIT", - "peerDependencies": { - "@ldo/solid-react": ">=1.0.0-alpha.33", - "react": ">=18.0.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17287,6 +16935,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17554,6 +17203,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17751,6 +17401,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -18012,6 +17663,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -18114,6 +17766,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 06c0322..ba520d3 100644 --- a/package.json +++ b/package.json @@ -9,22 +9,20 @@ "build:next": "next build", "start": "next start", "start:dev": "concurrently \"npm run start:css\" \"npm run dev\"", - "start:css": "npx @solid/community-server --port 3000 --loggingLevel info --config .cssconfig.json --rootFilePath data/ --seedConfig seed-config.json", + "start:css": "npx @solid/community-server --port ${CSS_PORT:-3000} --loggingLevel info --config .cssconfig.json --rootFilePath data/ --seedConfig seed-config.json", "lint": "eslint" }, "dependencies": { "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "^1.23.1", - "@inrupt/solid-client-authn-browser": "^3.1.1", - "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", + "@solid/reactive-authentication": "github:PreciousOritsedere/reactive-authentication", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", - "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.2" + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", diff --git a/public/callback.html b/public/callback.html new file mode 100644 index 0000000..2a27872 --- /dev/null +++ b/public/callback.html @@ -0,0 +1,22 @@ + + + + + Authorization Code callback + + +

Completing sign-in…

+ + + \ No newline at end of file