mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
## Context If a user switches account without an explicit log out via the dashboard, landing back on `/org` will redirect users to the last visited organization as stored in local storage, in which it can result in the following state if the last visited organization does not exist in the current account <img width="2538" height="1060" alt="image" src="https://github.com/user-attachments/assets/270e482a-3515-48ef-898b-87e76fce80d6" /> ## Changes involved Am opting to scope the last visited organization to the user profile instead - this would be a bit more cleaner than trying to actively clear the last visited org slug from local storage with implicit account changes as there's no deterministic way to track that (afaik) from FE side of things ## To test Can reproduce the problem as such - Ensure that you have 2 accounts to log in with, and one account has an org that the other is not a part of - For the organization that has the "extra" org, ensure that you click into it so that the last visited org slug is saved in local storage - Mimic changing accounts by visiting `/auth/v1/authorize?provider=github` (using the domain for the env that you're testing on - e.g localhost:8000 for local, or green for staging preview) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Unified “last visited organization” handling across the Studio UI with a shared hook, improving consistency for home/dashboard return, cancel/back navigation, and account routing. * **Bug Fixes** * Updated redirects to only route to an organization when a valid last-visited value is available; otherwise users go to the general organizations page. * Kept MFA enrollment and factor delete/leave flows aligned to the unified last-visited organization value. * **Tests** * Updated onboarding and layout tests to match the new last-visited organization storage key format and hook/query success behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
import { Check, ChevronDown } from 'lucide-react'
|
|
import { useMemo, useState, type ReactNode } from 'react'
|
|
import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
|
|
|
|
import {
|
|
CreateOrganizationCard,
|
|
OrganizationCard,
|
|
} from '@/components/interfaces/Organization/OrganizationCard'
|
|
import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization'
|
|
import type { Organization } from '@/types'
|
|
|
|
const VISIBLE_ORGANIZATIONS_LIMIT = 3
|
|
const CONNECT_DISCLOSURE_TRIGGER_CLASSNAME = cn(
|
|
'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2',
|
|
'text-xs text-foreground-lighter transition-colors',
|
|
'hover:bg-surface-200 hover:text-foreground',
|
|
'[&[data-state=open]>svg]:-rotate-180!'
|
|
)
|
|
|
|
export const OrganizationSelector = ({
|
|
organizations,
|
|
unavailableOrganizations = [],
|
|
selectedSlug,
|
|
disabled = false,
|
|
description,
|
|
createLabel,
|
|
createHrefParams,
|
|
onCreate,
|
|
onSelect,
|
|
getOrganizationDescription,
|
|
getUnavailableOrganizationDescription,
|
|
unavailableReason,
|
|
}: {
|
|
organizations: Organization[]
|
|
unavailableOrganizations?: Organization[]
|
|
selectedSlug?: string | null
|
|
disabled?: boolean
|
|
description?: ReactNode
|
|
createLabel?: string
|
|
createHrefParams?: { [key: string]: string }
|
|
onCreate?: () => void
|
|
onSelect: (slug: string) => void
|
|
getOrganizationDescription?: (organization: Organization) => ReactNode
|
|
getUnavailableOrganizationDescription?: (organization: Organization) => ReactNode
|
|
unavailableReason?: ReactNode
|
|
}) => {
|
|
const [showMore, setShowMore] = useState(false)
|
|
const { lastVisitedOrganization } = useLastVisitedOrganization()
|
|
|
|
const { visibleOrganizations, overflowOrganizations } = useMemo(() => {
|
|
const lastVisitedOrg = organizations.find(({ slug }) => slug === lastVisitedOrganization)
|
|
const selectedIndex = organizations.findIndex(({ slug }) => slug === selectedSlug)
|
|
const selectedInOverflow = selectedIndex >= VISIBLE_ORGANIZATIONS_LIMIT
|
|
|
|
if (!!lastVisitedOrg) {
|
|
const withoutLastVisited = organizations.filter(
|
|
({ slug }) => slug !== lastVisitedOrganization
|
|
)
|
|
return {
|
|
visibleOrganizations: [
|
|
lastVisitedOrg,
|
|
...withoutLastVisited.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
|
],
|
|
overflowOrganizations: withoutLastVisited.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
|
}
|
|
}
|
|
|
|
if (!selectedInOverflow || !selectedSlug) {
|
|
return {
|
|
visibleOrganizations: organizations.slice(0, VISIBLE_ORGANIZATIONS_LIMIT),
|
|
overflowOrganizations: organizations.slice(VISIBLE_ORGANIZATIONS_LIMIT),
|
|
}
|
|
}
|
|
|
|
const selected = organizations[selectedIndex]
|
|
const withoutSelected = organizations.filter(({ slug }) => slug !== selectedSlug)
|
|
|
|
return {
|
|
visibleOrganizations: [
|
|
...withoutSelected.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
|
selected,
|
|
],
|
|
overflowOrganizations: withoutSelected.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
|
}
|
|
}, [lastVisitedOrganization, organizations, selectedSlug])
|
|
|
|
const hasOverflow = overflowOrganizations.length > 0
|
|
const hasUnavailableOrganizations = unavailableOrganizations.length > 0
|
|
|
|
return (
|
|
<section className="space-y-2" aria-label="Organizations">
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
|
|
Organization
|
|
</p>
|
|
{description && <p className="text-xs text-foreground-lighter pr-4">{description}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{visibleOrganizations.map((organization) => (
|
|
<ConnectOrganizationButton
|
|
key={organization.slug}
|
|
organization={organization}
|
|
selected={selectedSlug === organization.slug}
|
|
disabled={disabled}
|
|
onClick={() => onSelect(organization.slug)}
|
|
description={
|
|
getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{!!createLabel && (!!createHrefParams || !!onCreate) && (
|
|
<CreateOrganizationCard
|
|
params={createHrefParams}
|
|
label={createLabel}
|
|
disabled={disabled}
|
|
onClick={onCreate}
|
|
/>
|
|
)}
|
|
|
|
{hasOverflow && (
|
|
<Collapsible open={showMore} onOpenChange={setShowMore}>
|
|
<CollapsibleTrigger className={CONNECT_DISCLOSURE_TRIGGER_CLASSNAME}>
|
|
<span>{showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`}</span>
|
|
<ChevronDown className="size-3.5 transition-transform" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
|
|
<div className="space-y-2 pt-1">
|
|
{overflowOrganizations.map((organization) => (
|
|
<ConnectOrganizationButton
|
|
key={organization.slug}
|
|
organization={organization}
|
|
selected={selectedSlug === organization.slug}
|
|
disabled={disabled}
|
|
onClick={() => onSelect(organization.slug)}
|
|
description={
|
|
getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{hasUnavailableOrganizations && (
|
|
<Collapsible>
|
|
<CollapsibleTrigger className={CONNECT_DISCLOSURE_TRIGGER_CLASSNAME}>
|
|
<span>Organizations that can't be linked</span>
|
|
<ChevronDown className="size-3.5 transition-transform" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
|
|
<div className="space-y-2 pt-1">
|
|
{unavailableOrganizations.map((organization) => (
|
|
<ConnectOrganizationButton
|
|
key={organization.slug}
|
|
organization={organization}
|
|
disabled
|
|
description={
|
|
getUnavailableOrganizationDescription?.(organization) ??
|
|
getPlanDescription(organization)
|
|
}
|
|
/>
|
|
))}
|
|
{unavailableReason && (
|
|
<p className="mx-auto max-w-xs text-center text-xs text-foreground-lighter text-balance">
|
|
{unavailableReason}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const getPlanDescription = (organization: Organization) => `${organization.plan.name} Plan`
|
|
|
|
const ConnectOrganizationButton = ({
|
|
organization,
|
|
selected,
|
|
disabled,
|
|
onClick,
|
|
description,
|
|
}: {
|
|
organization: Organization
|
|
selected?: boolean
|
|
disabled?: boolean
|
|
onClick?: () => void
|
|
description?: ReactNode
|
|
}) => (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
'group relative block w-full cursor-pointer text-left disabled:cursor-not-allowed disabled:opacity-50',
|
|
disabled && 'pointer-events-none'
|
|
)}
|
|
>
|
|
<OrganizationCard
|
|
isLink={false}
|
|
organization={organization}
|
|
description={description}
|
|
className={cn(
|
|
'pointer-events-none shadow-none transition-colors',
|
|
!disabled && !selected && 'group-hover:border-default group-hover:bg-surface-200',
|
|
selected &&
|
|
'border-brand bg-brand-200/20 dark:bg-brand-300 pr-10 group-hover:border-brand group-hover:bg-brand-200/20'
|
|
)}
|
|
/>
|
|
{selected && (
|
|
<span className="pointer-events-none absolute right-3 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full bg-brand-500 dark:bg-brand-200 text-white dark:text-brand">
|
|
<Check className="size-3.5" strokeWidth={2} />
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|