Skip to content
Merged
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
10 changes: 8 additions & 2 deletions frontend/src/features/tenants/components/TenantCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,16 @@ export function TenantCard({
const hue = hueOf(tenant.name || tenant.domain)
const initial = (tenant.name || tenant.domain).charAt(0).toUpperCase()
const terminated = tenant.status === 'TERMINATED'
const canEnter = readable && !terminated

return (
<div
onClick={canEnter ? () => enterTenant(tenant) : undefined}
className={cn(
'group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-all duration-200',
readable ? 'hover:border-primary/40 hover:shadow-md' : 'opacity-60 saturate-[0.35]',
terminated && 'opacity-50'
terminated && 'opacity-50',
canEnter && 'cursor-pointer'
)}
>
<span
Expand Down Expand Up @@ -237,7 +240,10 @@ export function TenantCard({
</div>
</div>

<div className="flex shrink-0 items-center gap-1">
<div
className="flex shrink-0 items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onEdit}
Expand Down
164 changes: 164 additions & 0 deletions frontend/src/features/tenants/components/TenantSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Building2, ChevronDown, Plus } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
import { useAuth } from '@/features/auth'
import { setSupportTenant, useSupportTenant } from '@/shared/lib/current-tenant'
import {
canReadTenant,
tenantsHttpService,
} from '../services/tenants-http.service'
import type { Tenant } from '../types/tenant.types'
import { CreateTenantDialog } from './CreateTenantDialog'

/**
* Topbar switcher for the tenant the current session is reading against.
*
* Rendered only for admins. The tenants list is exempt from the support-tenant
* header, so it works even mid-session; on the endpoint responding 403 (a role
* that says admin but not to this endpoint) the switcher hides itself.
*
* Entering a tenant is a hard navigation, same reason as the tenant cards: the
* react-query caches, branding and notification feed all belong to whoever we
* were before, and a soft navigation would leave them on screen next to the
* other tenant's data.
*/
export function TenantSwitcher() {
const { t } = useTranslation()
const { isAdmin, tenantId: ownId } = useAuth()
const support = useSupportTenant()
const [tenants, setTenants] = useState<Tenant[]>([])
const [failed, setFailed] = useState(false)
const [open, setOpen] = useState(false)
const [creating, setCreating] = useState(false)
const ref = useRef<HTMLDivElement>(null)

const load = useCallback(async () => {
if (!isAdmin) return
try {
const list = await tenantsHttpService.list({ size: 200 })
setTenants(
list.filter(
(x) => x.id !== ownId && canReadTenant(x) && x.status !== 'TERMINATED'
)
)
setFailed(false)
} catch {
setFailed(true)
}
}, [ownId, isAdmin])

useEffect(() => {
void load()
}, [load])

useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])

if (!isAdmin || failed) return null

// Reload in place so the target tenant's data replaces ours, but bounce off
// /tenants first: entering a tenant strips access to that page, so staying
// there would just 403 on the next load.
const reloadAfterSwitch = () => {
if (window.location.pathname.startsWith('/tenants')) {
window.location.assign('/home')
} else {
window.location.reload()
}
}

const enterSelf = () => {
setOpen(false)
setSupportTenant(null)
reloadAfterSwitch()
}

const enter = (tenant: Tenant) => {
setOpen(false)
setSupportTenant({
id: tenant.id,
name: tenant.name,
access: tenant.supportAccess === 'FULL' ? 'FULL' : 'READ',
})
reloadAfterSwitch()
}

const selfLabel = t('tenants.switcher.self', { defaultValue: 'Default tenant' })
const current = support?.name ?? selfLabel

return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
aria-label={t('tenants.switcher.aria', { defaultValue: 'Switch tenant' })}
className={cn(
'flex h-9 items-center gap-1.5 rounded-md border border-border bg-muted/40 px-2.5 text-[12px] transition-colors',
open
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Building2 size={13} strokeWidth={1.75} />
<span className="max-w-[9rem] truncate">{current}</span>
<ChevronDown
size={12}
className={cn('transition-transform duration-150', open ? 'rotate-180' : 'rotate-0')}
/>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-64 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg">
<div className="max-h-72 overflow-y-auto py-1">
<button
onClick={enterSelf}
className={cn(
'block w-full truncate px-3 py-1.5 text-left text-sm hover:bg-muted',
!support && 'font-semibold text-primary'
)}
>
{selfLabel}
</button>
{tenants.map((tn) => (
<button
key={tn.id}
onClick={() => enter(tn)}
title={tn.name}
className={cn(
'block w-full truncate px-3 py-1.5 text-left text-sm hover:bg-muted',
support?.id === tn.id && 'font-semibold text-primary'
)}
>
{tn.name}
</button>
))}
</div>
<button
onClick={() => {
setOpen(false)
setCreating(true)
}}
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus size={13} strokeWidth={1.75} />
{t('tenants.switcher.create', { defaultValue: 'Create tenant' })}
</button>
</div>
)}
{creating && (
<CreateTenantDialog
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false)
void load()
}}
/>
)}
</div>
)
}
5 changes: 5 additions & 0 deletions frontend/src/shared/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5472,6 +5472,11 @@
"notFound": "Tenant not found",
"invalidRequest": "Invalid request",
"operationFailed": "Operation failed"
},
"switcher": {
"aria": "Switch tenant",
"self": "Default tenant",
"create": "Create tenant"
}
},
"supportAccess": {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/shared/layouts/DashboardLayout/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useCurrentInstanceId } from '@/shared/lib/current-instance'
import { InstanceSelector } from '@/features/federation/components/InstanceSelector'
import { useFederationVersion } from '@/features/federation/hooks/use-version'
import { useBilling } from '@/features/billing'
import { TenantSwitcher } from '@/features/tenants/components/TenantSwitcher'
import {
NotificationRow,
useNotificationFeed,
Expand Down Expand Up @@ -196,6 +197,7 @@ export function Topbar() {

{/* Right cluster */}
<div className="flex items-center gap-1">
<TenantSwitcher />
<div className="relative" ref={notifRef}>
<IconButton
label={t('notifications.title')}
Expand Down
Loading