@open-mercato/ui 0.8.1-develop.7238.1.3abe669ea5 → 0.8.1-develop.7240.1.g2661390300
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -103,7 +103,7 @@ function PortalShell({
|
|
|
103
103
|
const orgSlug = portalCtx?.orgSlug ?? orgSlugProp;
|
|
104
104
|
const orgName = portalCtx?.tenant.organizationName ?? orgNameProp;
|
|
105
105
|
const user = portalCtx?.auth.user ?? null;
|
|
106
|
-
const authenticated = authenticatedProp ??
|
|
106
|
+
const authenticated = !!user || (authenticatedProp ?? false);
|
|
107
107
|
const onLogout = onLogoutProp ?? portalCtx?.auth.logout;
|
|
108
108
|
const userName = userNameProp ?? user?.displayName;
|
|
109
109
|
const userEmail = userEmailProp ?? user?.email;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/portal/PortalShell.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport { type ReactNode, useEffect, useState, useCallback, useMemo } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { Skeleton } from '../primitives/skeleton'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\nimport { apiCall } from '../backend/utils/apiCall'\nimport type { PortalNavGroup } from './utils/nav'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type ShellLogo = {\n src: string\n alt?: string\n}\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Override the brand logo rendered in the header, footer, and sidebar. */\n logo?: ShellLogo\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\nfunction SidebarNavSkeleton() {\n return (\n <div className=\"flex flex-col gap-2 px-3 py-1\" data-testid=\"portal-nav-loading\">\n <Skeleton className=\"h-9 w-full rounded-lg\" />\n <Skeleton className=\"h-9 w-11/12 rounded-lg\" />\n <Skeleton className=\"h-9 w-5/6 rounded-lg\" />\n <Skeleton className=\"h-9 w-10/12 rounded-lg\" />\n </div>\n )\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-overline font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n logo,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n const authenticated = authenticatedProp ?? !!user\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])\n const [isNavLoading, setIsNavLoading] = useState(authenticated)\n useEffect(() => {\n if (!authenticated) {\n setAutoNavGroups([])\n setIsNavLoading(false)\n return\n }\n let cancelled = false\n setIsNavLoading(true)\n const load = async () => {\n try {\n const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(\n '/api/customer_accounts/portal/nav',\n )\n if (cancelled || !ok || !result?.ok) return\n setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])\n } catch {\n if (!cancelled) setAutoNavGroups([])\n } finally {\n if (!cancelled) setIsNavLoading(false)\n }\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [authenticated])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, autoNavGroups, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, autoNavGroups, injectedAccountItems])\n\n const shouldRenderMainNav = isNavLoading || mergedNavItems.length > 0\n const shouldRenderAccountNav = mergedAccountItems.length > 0\n const shouldRenderNav = shouldRenderMainNav || shouldRenderAccountNav\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-sticky border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={28} height={28} className=\"\" priority />\n <span className=\"text-base font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-sm\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-sm\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={22} height={22} className=\"\" />\n <span className=\"text-sm font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <div\n className=\"hidden\"\n data-testid=\"portal-nav-ready\"\n data-ready={isNavLoading ? 'false' : 'true'}\n aria-hidden=\"true\"\n />\n\n {shouldRenderNav ? (\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n {shouldRenderMainNav ? (\n <>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n {isNavLoading ? (\n <SidebarNavSkeleton />\n ) : (\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n )}\n </>\n ) : null}\n\n {shouldRenderAccountNav ? (\n <div className={shouldRenderMainNav ? 'mt-8' : ''}>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n ) : null}\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-sm font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-overline text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-modal lg:hidden\">\n <div className=\"absolute inset-0 bg-black/20 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-overline text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
|
|
5
|
-
"mappings": ";AA0DI,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport { type ReactNode, useEffect, useState, useCallback, useMemo } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { Skeleton } from '../primitives/skeleton'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\nimport { apiCall } from '../backend/utils/apiCall'\nimport type { PortalNavGroup } from './utils/nav'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type ShellLogo = {\n src: string\n alt?: string\n}\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Override the brand logo rendered in the header, footer, and sidebar. */\n logo?: ShellLogo\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\nfunction SidebarNavSkeleton() {\n return (\n <div className=\"flex flex-col gap-2 px-3 py-1\" data-testid=\"portal-nav-loading\">\n <Skeleton className=\"h-9 w-full rounded-lg\" />\n <Skeleton className=\"h-9 w-11/12 rounded-lg\" />\n <Skeleton className=\"h-9 w-5/6 rounded-lg\" />\n <Skeleton className=\"h-9 w-10/12 rounded-lg\" />\n </div>\n )\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-overline font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n logo,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n // A context user wins over the prop. The layout that supplies `authenticated`\n // sits above the [...slug] segment, so a client-side navigation never re-runs\n // it and the prop can describe the route the visitor arrived on rather than\n // the one being rendered. Trusting a stale `false` there painted the\n // logged-out header over authenticated content (#5678).\n const authenticated = !!user || (authenticatedProp ?? false)\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])\n const [isNavLoading, setIsNavLoading] = useState(authenticated)\n useEffect(() => {\n if (!authenticated) {\n setAutoNavGroups([])\n setIsNavLoading(false)\n return\n }\n let cancelled = false\n setIsNavLoading(true)\n const load = async () => {\n try {\n const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(\n '/api/customer_accounts/portal/nav',\n )\n if (cancelled || !ok || !result?.ok) return\n setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])\n } catch {\n if (!cancelled) setAutoNavGroups([])\n } finally {\n if (!cancelled) setIsNavLoading(false)\n }\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [authenticated])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, autoNavGroups, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, autoNavGroups, injectedAccountItems])\n\n const shouldRenderMainNav = isNavLoading || mergedNavItems.length > 0\n const shouldRenderAccountNav = mergedAccountItems.length > 0\n const shouldRenderNav = shouldRenderMainNav || shouldRenderAccountNav\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-sticky border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={28} height={28} className=\"\" priority />\n <span className=\"text-base font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-sm\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-sm\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={22} height={22} className=\"\" />\n <span className=\"text-sm font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <div\n className=\"hidden\"\n data-testid=\"portal-nav-ready\"\n data-ready={isNavLoading ? 'false' : 'true'}\n aria-hidden=\"true\"\n />\n\n {shouldRenderNav ? (\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n {shouldRenderMainNav ? (\n <>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n {isNavLoading ? (\n <SidebarNavSkeleton />\n ) : (\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n )}\n </>\n ) : null}\n\n {shouldRenderAccountNav ? (\n <div className={shouldRenderMainNav ? 'mt-8' : ''}>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n ) : null}\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-sm font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-overline text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-modal lg:hidden\">\n <div className=\"absolute inset-0 bg-black/20 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-overline text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7240.1.g2661390300",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.8.1-develop.7240.1.g2661390300",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.8.1-develop.7240.1.g2661390300",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -185,7 +185,12 @@ export function PortalShell({
|
|
|
185
185
|
const orgSlug = portalCtx?.orgSlug ?? orgSlugProp
|
|
186
186
|
const orgName = portalCtx?.tenant.organizationName ?? orgNameProp
|
|
187
187
|
const user = portalCtx?.auth.user ?? null
|
|
188
|
-
|
|
188
|
+
// A context user wins over the prop. The layout that supplies `authenticated`
|
|
189
|
+
// sits above the [...slug] segment, so a client-side navigation never re-runs
|
|
190
|
+
// it and the prop can describe the route the visitor arrived on rather than
|
|
191
|
+
// the one being rendered. Trusting a stale `false` there painted the
|
|
192
|
+
// logged-out header over authenticated content (#5678).
|
|
193
|
+
const authenticated = !!user || (authenticatedProp ?? false)
|
|
189
194
|
const onLogout = onLogoutProp ?? portalCtx?.auth.logout
|
|
190
195
|
const userName = userNameProp ?? user?.displayName
|
|
191
196
|
const userEmail = userEmailProp ?? user?.email
|
|
@@ -4,7 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
import * as React from 'react'
|
|
6
6
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
7
|
+
import type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'
|
|
7
8
|
import { PortalShell } from '../PortalShell'
|
|
9
|
+
import { PortalProvider } from '../PortalContext'
|
|
10
|
+
import { PortalLayoutShell } from '../PortalLayoutShell'
|
|
11
|
+
|
|
12
|
+
const signedInAuth: CustomerAuthContext = {
|
|
13
|
+
sub: 'customer-1',
|
|
14
|
+
sid: 'session-1',
|
|
15
|
+
type: 'customer',
|
|
16
|
+
tenantId: 'tenant-1',
|
|
17
|
+
orgId: 'org-1',
|
|
18
|
+
email: 'ada@example.com',
|
|
19
|
+
displayName: 'Ada Lovelace',
|
|
20
|
+
customerEntityId: null,
|
|
21
|
+
personEntityId: null,
|
|
22
|
+
resolvedFeatures: [],
|
|
23
|
+
}
|
|
8
24
|
|
|
9
25
|
const apiCallMock = jest.fn()
|
|
10
26
|
|
|
@@ -151,4 +167,126 @@ describe('PortalShell', () => {
|
|
|
151
167
|
|
|
152
168
|
expect(screen.queryByRole('navigation', { name: 'Portal navigation' })).not.toBeInTheDocument()
|
|
153
169
|
})
|
|
170
|
+
|
|
171
|
+
// The layout that supplies `authenticated` sits above the [...slug] segment, so a
|
|
172
|
+
// client-side navigation leaves a stale `false` behind. Trusting it painted the
|
|
173
|
+
// logged-out header over authenticated content (#5678).
|
|
174
|
+
it('renders the authenticated chrome when the context holds a user and the prop says otherwise', async () => {
|
|
175
|
+
apiCallMock.mockImplementation(async (url: string) => {
|
|
176
|
+
if (url === '/api/customer_accounts/portal/profile') {
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
status: 200,
|
|
180
|
+
result: {
|
|
181
|
+
ok: true,
|
|
182
|
+
user: {
|
|
183
|
+
id: 'customer-1',
|
|
184
|
+
email: 'ada@example.com',
|
|
185
|
+
displayName: 'Ada Lovelace',
|
|
186
|
+
emailVerified: true,
|
|
187
|
+
customerEntityId: null,
|
|
188
|
+
personEntityId: null,
|
|
189
|
+
isActive: true,
|
|
190
|
+
lastLoginAt: null,
|
|
191
|
+
createdAt: '',
|
|
192
|
+
},
|
|
193
|
+
roles: [],
|
|
194
|
+
resolvedFeatures: [],
|
|
195
|
+
isPortalAdmin: false,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, status: 200, result: { ok: true, groups: [] } }
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
render(
|
|
203
|
+
<PortalProvider
|
|
204
|
+
orgSlug="acme"
|
|
205
|
+
initialAuth={signedInAuth}
|
|
206
|
+
initialTenant={{ tenantId: 'tenant-1', organizationId: 'org-1', organizationName: 'Acme' }}
|
|
207
|
+
>
|
|
208
|
+
<PortalShell authenticated={false} orgSlug="acme" organizationName="Acme">
|
|
209
|
+
<div>Portal content</div>
|
|
210
|
+
</PortalShell>
|
|
211
|
+
</PortalProvider>,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
await waitFor(() => {
|
|
215
|
+
expect(screen.getByTestId('portal-nav-ready')).toBeInTheDocument()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
expect(screen.getByRole('button', { name: 'Log Out' })).toBeInTheDocument()
|
|
219
|
+
expect(screen.queryByRole('link', { name: 'Log In' })).not.toBeInTheDocument()
|
|
220
|
+
expect(screen.queryByRole('link', { name: 'Sign Up' })).not.toBeInTheDocument()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('keeps the public chrome when no context user backs the prop', async () => {
|
|
224
|
+
render(
|
|
225
|
+
<PortalShell authenticated={false} orgSlug="acme" organizationName="Acme">
|
|
226
|
+
<div>Portal content</div>
|
|
227
|
+
</PortalShell>,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
expect(screen.getByRole('link', { name: 'Log In' })).toHaveAttribute('href', '/acme/portal/login')
|
|
231
|
+
expect(screen.queryByTestId('portal-nav-ready')).not.toBeInTheDocument()
|
|
232
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// The composed path — the real props the (frontend) layout passes for an auth
|
|
236
|
+
// route — is the seam where the two halves of the #5678 fix meet. Withholding
|
|
237
|
+
// the session there is what keeps the context from upgrading the chrome.
|
|
238
|
+
it('renders the public chrome for the props the layout passes on an auth route', async () => {
|
|
239
|
+
render(
|
|
240
|
+
<PortalLayoutShell
|
|
241
|
+
orgSlug="acme"
|
|
242
|
+
organizationName="Acme"
|
|
243
|
+
tenantId="tenant-1"
|
|
244
|
+
organizationId="org-1"
|
|
245
|
+
authenticated={false}
|
|
246
|
+
userName={null}
|
|
247
|
+
userEmail={null}
|
|
248
|
+
customerAuth={null}
|
|
249
|
+
>
|
|
250
|
+
<div>Login form</div>
|
|
251
|
+
</PortalLayoutShell>,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
expect(screen.getByRole('link', { name: 'Log In' })).toHaveAttribute('href', '/acme/portal/login')
|
|
255
|
+
expect(screen.getByRole('link', { name: 'Sign Up' })).toHaveAttribute('href', '/acme/portal/signup')
|
|
256
|
+
expect(screen.queryByTestId('portal-nav-ready')).not.toBeInTheDocument()
|
|
257
|
+
expect(screen.queryByRole('button', { name: 'Log Out' })).not.toBeInTheDocument()
|
|
258
|
+
|
|
259
|
+
// Neither the portal nav nor the profile endpoint may be reached from a
|
|
260
|
+
// public page: PortalProvider treats a null `customerAuth` as "server
|
|
261
|
+
// confirmed: no session" and skips its enrichment fetch entirely.
|
|
262
|
+
await waitFor(() => {
|
|
263
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('renders the authenticated chrome for the props the layout passes on a signed-in route', async () => {
|
|
268
|
+
apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true, groups: [] } })
|
|
269
|
+
|
|
270
|
+
render(
|
|
271
|
+
<PortalLayoutShell
|
|
272
|
+
orgSlug="acme"
|
|
273
|
+
organizationName="Acme"
|
|
274
|
+
tenantId="tenant-1"
|
|
275
|
+
organizationId="org-1"
|
|
276
|
+
authenticated
|
|
277
|
+
userName="Ada Lovelace"
|
|
278
|
+
userEmail="ada@example.com"
|
|
279
|
+
customerAuth={signedInAuth}
|
|
280
|
+
>
|
|
281
|
+
<div>Dashboard</div>
|
|
282
|
+
</PortalLayoutShell>,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
expect(screen.getByTestId('portal-nav-ready')).toBeInTheDocument()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
expect(screen.getByRole('button', { name: 'Log Out' })).toBeInTheDocument()
|
|
290
|
+
expect(screen.queryByRole('link', { name: 'Log In' })).not.toBeInTheDocument()
|
|
291
|
+
})
|
|
154
292
|
})
|