Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import { SpaceBreadcrumbItem } from '@hypha-platform/epics';
import { useBreadcrumbFrom } from './breadcrumbs-root-selector';

export function BreadcrumbSpaceItem({
breadcrumb,
lang,
}: {
breadcrumb: { slug: string; title: string };
lang: string;
}) {
const fromQuery = useBreadcrumbFrom();
return (
<SpaceBreadcrumbItem
breadcrumb={breadcrumb}
lang={lang}
fromQuery={fromQuery}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
'use client';

import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from '@hypha-platform/ui';
import { useSearchParams, useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { createContext, useContext, useEffect, useMemo } from 'react';

const BREADCRUMB_ORIGIN_COOKIE = 'breadcrumb_origin';
const COOKIE_MAX_AGE_DAYS = 1;

type BreadcrumbOrigin = 'network' | 'profile' | 'my-spaces';

function getBreadcrumbOriginFromCookie(): {
from: BreadcrumbOrigin;
profileSlug?: string;
} | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(
new RegExp(`(?:^|; )${BREADCRUMB_ORIGIN_COOKIE}=([^;]*)`),
);
if (!match?.[1]) return null;
try {
return JSON.parse(decodeURIComponent(match[1])) as {
from: BreadcrumbOrigin;
profileSlug?: string;
};
} catch {
return null;
}
}

function setBreadcrumbOriginCookie(from: BreadcrumbOrigin, profileSlug?: string) {
if (typeof document === 'undefined') return;
const value = JSON.stringify(
profileSlug ? { from, profileSlug } : { from },
);
document.cookie = `${BREADCRUMB_ORIGIN_COOKIE}=${encodeURIComponent(value)}; path=/; max-age=${60 * 60 * 24 * COOKIE_MAX_AGE_DAYS}; SameSite=Lax`;
}

function getFromQuery(from: BreadcrumbOrigin, profileSlug?: string): string {
if (from === 'profile' && profileSlug) {
return `from=profile&profileSlug=${encodeURIComponent(profileSlug)}`;
}
return `from=${from}`;
}

const BreadcrumbFromContext = createContext<string | undefined>(undefined);

export function useBreadcrumbFrom() {
return useContext(BreadcrumbFromContext);
}

/** Returns the from query string for preserving breadcrumb origin in links */
export function useBreadcrumbFromQuery(): string | undefined {
const searchParams = useSearchParams();
const from = searchParams.get('from') as BreadcrumbOrigin | null;
const profileSlug = searchParams.get('profileSlug');

Comment on lines +61 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate from before using it in cookie/query propagation.

Line 61 and Line 95 trust from via type-cast only. A crafted value (e.g. ?from=foo) can be persisted to cookie and forwarded in fromQuery, creating inconsistent breadcrumb behavior across navigation.

🔧 Proposed fix
 type BreadcrumbOrigin = 'network' | 'profile' | 'my-spaces';
+const BREADCRUMB_ORIGINS: readonly BreadcrumbOrigin[] = [
+  'network',
+  'profile',
+  'my-spaces',
+];
+
+function parseBreadcrumbOrigin(value: string | null): BreadcrumbOrigin | null {
+  if (!value) return null;
+  return BREADCRUMB_ORIGINS.includes(value as BreadcrumbOrigin)
+    ? (value as BreadcrumbOrigin)
+    : null;
+}
@@
-  const from = searchParams.get('from') as BreadcrumbOrigin | null;
+  const from = parseBreadcrumbOrigin(searchParams.get('from'));
@@
-  const from = searchParams.get('from') as BreadcrumbOrigin | null;
+  const from = parseBreadcrumbOrigin(searchParams.get('from'));

Also applies to: 95-97, 111-114, 138-140

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`[lang]/dho/[id]/_components/breadcrumbs-root-selector.tsx
around lines 61 - 63, The code currently casts searchParams.get('from') to
BreadcrumbOrigin without validation; add a validation step (e.g., an
isValidBreadcrumbOrigin type guard or whitelist check) and only
use/persist/forward `from` when it passes that check, otherwise treat it as
null/undefined; update all usages that currently do `const from =
searchParams.get('from') as BreadcrumbOrigin` and the later propagation sites
(the cookie/query propagation logic around the `fromQuery` and cookie set code
referenced near lines 95-97, 111-114, 138-140) to call the validator and guard
before writing to cookies or including in `fromQuery`.

return useMemo(() => {
let resolvedFrom = from;
let resolvedProfileSlug = profileSlug;

if (!resolvedFrom) {
const cookie = getBreadcrumbOriginFromCookie();
if (cookie) {
resolvedFrom = cookie.from;
resolvedProfileSlug = cookie.profileSlug ?? null;
} else {
return undefined;
}
}

return getFromQuery(
resolvedFrom,
resolvedProfileSlug ?? undefined,
);
}, [from, profileSlug]);
}

export function BreadcrumbsRootSelector({
children,
}: {
children: React.ReactNode;
}) {
const searchParams = useSearchParams();
const params = useParams<{ lang: string }>();
const lang = params?.lang ?? 'en';
const tNavigation = useTranslations('Navigation');

const from = searchParams.get('from') as BreadcrumbOrigin | null;
const profileSlug = searchParams.get('profileSlug');

const { rootHref, rootLabel, fromQuery } = useMemo(() => {
let resolvedFrom = from;
let resolvedProfileSlug = profileSlug;

if (!resolvedFrom) {
const cookie = getBreadcrumbOriginFromCookie();
if (cookie) {
resolvedFrom = cookie.from;
resolvedProfileSlug = cookie.profileSlug ?? null;
} else {
resolvedFrom = 'my-spaces';
}
} else {
setBreadcrumbOriginCookie(
resolvedFrom,
resolvedProfileSlug ?? undefined,
);
}

let href: string;
let label: string;

switch (resolvedFrom) {
case 'network':
href = `/${lang}/network`;
label = tNavigation('network');
break;
case 'profile':
href = resolvedProfileSlug
? `/${lang}/profile/${resolvedProfileSlug}`
: `/${lang}/profile`;
label = tNavigation('profile');
break;
case 'my-spaces':
default:
href = `/${lang}/my-spaces`;
label = tNavigation('mySpaces');
break;
}

const query = getFromQuery(resolvedFrom, resolvedProfileSlug ?? undefined);

return { rootHref: href, rootLabel: label, fromQuery: query };
}, [from, profileSlug, lang, tNavigation]);

useEffect(() => {
if (from) {
setBreadcrumbOriginCookie(from, profileSlug ?? undefined);
}
}, [from, profileSlug]);

const contextValue = useMemo(() => fromQuery, [fromQuery]);

return (
<BreadcrumbFromContext.Provider value={contextValue}>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={rootHref} className="flex items-center">
{rootLabel}
</BreadcrumbLink>
</BreadcrumbItem>
{children}
</BreadcrumbList>
</Breadcrumb>
</BreadcrumbFromContext.Provider>
);
}
16 changes: 5 additions & 11 deletions apps/web/src/app/[lang]/dho/[id]/_components/breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { findParentSpaceById } from '@hypha-platform/core/server';
import { SpaceBreadcrumb, SpaceBreadcrumbItem } from '@hypha-platform/epics';
import { db } from '@hypha-platform/storage-postgres';
import { Locale } from '@hypha-platform/i18n';
import { getTranslations } from 'next-intl/server';
import { Fragment } from 'react';
import { BreadcrumbsRootSelector } from './breadcrumbs-root-selector';
import { BreadcrumbSpaceItem } from './breadcrumb-space-item';

async function RecursiveBreadcrumbItem({
spaceId,
Expand All @@ -16,7 +16,6 @@ async function RecursiveBreadcrumbItem({
depth?: number;
maxDepth?: number;
}) {
console.debug('RecursiveBreadcrumbItem', { spaceId, depth, maxDepth });
const space = await findParentSpaceById({ id: spaceId }, { db });
if (!space || depth > maxDepth) return null;

Expand All @@ -30,7 +29,7 @@ async function RecursiveBreadcrumbItem({
maxDepth={maxDepth}
/>
)}
<SpaceBreadcrumbItem
<BreadcrumbSpaceItem
lang={lang}
breadcrumb={{ slug: space.slug, title: space.title }}
/>
Expand All @@ -45,14 +44,9 @@ export async function Breadcrumbs({
spaceId: number;
lang: Locale;
}) {
const tNavigation = await getTranslations('Navigation');

return (
<SpaceBreadcrumb
rootHref={`/${lang}/my-spaces`}
rootLabel={tNavigation('mySpaces')}
>
<BreadcrumbsRootSelector>
<RecursiveBreadcrumbItem spaceId={spaceId} lang={lang} />
</SpaceBreadcrumb>
</BreadcrumbsRootSelector>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import {
SpaceCard,
getDhoPathAgreements,
} from '@hypha-platform/epics';
import {
isSpaceArchived,
DEFAULT_SPACE_LEAD_IMAGE,
type Space,
} from '@hypha-platform/core/client';
import { Carousel, CarouselContent, CarouselItem } from '@hypha-platform/ui';
import Link from 'next/link';
import { Locale } from '@hypha-platform/i18n';
import { useBreadcrumbFromQuery } from './breadcrumbs-root-selector';

export function SpacesCarouselWithFrom({
spaces,
lang,
}: {
spaces: Space[];
lang: Locale;
}) {
const fromQuery = useBreadcrumbFromQuery();

return (
<Carousel className="my-6 mt-6">
<CarouselContent className="pb-5" showScrollbar>
{spaces.map((space) => {
const baseHref = getDhoPathAgreements(lang, space.slug as string);
const href = fromQuery ? `${baseHref}?${fromQuery}` : baseHref;
return (
<CarouselItem
key={space.id}
className="w-full sm:w-[454px] max-w-[454px] flex-shrink-0"
>
<Link className="flex flex-col flex-1" href={href}>
<SpaceCard
description={space.description as string}
icon={space.logoUrl || ''}
leadImage={space.leadImage || DEFAULT_SPACE_LEAD_IMAGE}
members={space.memberCount}
agreements={space.documentCount}
title={space.title as string}
isSandbox={space.flags?.includes('sandbox') ?? false}
isDemo={space.flags?.includes('demo') ?? false}
isArchived={isSpaceArchived(space)}
web3SpaceId={space.web3SpaceId as number}
configPath={`${baseHref}/space-configuration`}
createdAt={space.createdAt}
/>
</Link>
</CarouselItem>
);
})}
</CarouselContent>
</Carousel>
);
}
39 changes: 2 additions & 37 deletions apps/web/src/app/[lang]/dho/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
JoinSpace,
SalesBanner,
SpaceCard,
SpaceModeLabel,
WebLinks,
SubscriptionBadge,
Expand All @@ -16,8 +15,6 @@ import {
} from '@hypha-platform/ui';
import { Text } from '@radix-ui/themes';
import Image from 'next/image';
import { Carousel, CarouselContent, CarouselItem } from '@hypha-platform/ui';
import Link from 'next/link';
import { getAllSpaces, findSpaceBySlug } from '@hypha-platform/core/server';
import { getDhoPathAgreements } from './@tab/agreements/constants';
import { ActionButtons } from './_components/action-buttons';
Expand All @@ -27,11 +24,11 @@ import {
DEFAULT_SPACE_LEAD_IMAGE,
fetchSpaceDetails,
fetchSpaceProposalsIds,
isSpaceArchived,
} from '@hypha-platform/core/client';
import { notFound } from 'next/navigation';
import { db } from '@hypha-platform/storage-postgres';
import { Breadcrumbs } from './_components/breadcrumbs';
import { SpacesCarouselWithFrom } from './_components/spaces-carousel-with-from';
import { canConvertToBigInt, formatDate } from '@hypha-platform/ui-utils';
import { getTranslations } from 'next-intl/server';

Expand Down Expand Up @@ -193,39 +190,7 @@ export default async function DhoLayout({
<Text className="text-4 font-medium pb-4 pt-4">
{tSpaces('spacesYouMightLike')}
</Text>
<Carousel className="my-6 mt-6">
<CarouselContent className="pb-5" showScrollbar>
{spaces.map((space) => (
<CarouselItem
key={space.id}
className="w-full sm:w-[454px] max-w-[454px] flex-shrink-0"
>
<Link
className="flex flex-col flex-1"
href={getDhoPathAgreements(lang, space.slug as string)}
>
<SpaceCard
description={space.description as string}
icon={space.logoUrl || ''}
leadImage={space.leadImage || DEFAULT_SPACE_LEAD_IMAGE}
members={space.memberCount}
agreements={space.documentCount}
title={space.title as string}
isSandbox={space.flags?.includes('sandbox') ?? false}
isDemo={space.flags?.includes('demo') ?? false}
isArchived={isSpaceArchived(space)}
web3SpaceId={space.web3SpaceId as number}
configPath={`${getDhoPathAgreements(
lang,
space.slug,
)}/space-configuration`}
createdAt={space.createdAt}
/>
</Link>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<SpacesCarouselWithFrom spaces={spaces} lang={lang} />
</div>
</div>
</Container>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/app/[lang]/my-spaces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
import { Heading } from '@hypha-platform/ui';
import { Text } from '@radix-ui/themes';
import { getAllSpaces } from '@hypha-platform/core/server';
import { getDhoPathAgreements } from '../dho/[id]/@tab/agreements/constants';
import {
addFromParam,
getDhoPathAgreements,
} from '@hypha-platform/epics';
import { PlusIcon } from '@radix-ui/react-icons';
import { DEFAULT_SPACE_LEAD_IMAGE } from '@hypha-platform/core/client';
import { getTranslations } from 'next-intl/server';
Expand Down Expand Up @@ -83,7 +86,10 @@ export default async function Index(props: PageProps) {
>
<Link
className="flex flex-col flex-1"
href={getDhoPathAgreements(lang, space.slug as string)}
href={addFromParam(
getDhoPathAgreements(lang, space.slug as string),
'my-spaces',
)}
>
<SpaceCard
description={space.description as string}
Expand Down
Loading
Loading