Skip to content

Commit 7c86ce5

Browse files
Backlog/v12 tenant selection (#2494)
* feat[frontend](tenants): make tenant card click enter the tenant * feat[frontend](tenants): topbar tenant switcher for admins
1 parent 90860e9 commit 7c86ce5

4 files changed

Lines changed: 179 additions & 2 deletions

File tree

frontend/src/features/tenants/components/TenantCard.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,16 @@ export function TenantCard({
198198
const hue = hueOf(tenant.name || tenant.domain)
199199
const initial = (tenant.name || tenant.domain).charAt(0).toUpperCase()
200200
const terminated = tenant.status === 'TERMINATED'
201+
const canEnter = readable && !terminated
201202

202203
return (
203204
<div
205+
onClick={canEnter ? () => enterTenant(tenant) : undefined}
204206
className={cn(
205207
'group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-all duration-200',
206208
readable ? 'hover:border-primary/40 hover:shadow-md' : 'opacity-60 saturate-[0.35]',
207-
terminated && 'opacity-50'
209+
terminated && 'opacity-50',
210+
canEnter && 'cursor-pointer'
208211
)}
209212
>
210213
<span
@@ -237,7 +240,10 @@ export function TenantCard({
237240
</div>
238241
</div>
239242

240-
<div className="flex shrink-0 items-center gap-1">
243+
<div
244+
className="flex shrink-0 items-center gap-1"
245+
onClick={(e) => e.stopPropagation()}
246+
>
241247
<button
242248
type="button"
243249
onClick={onEdit}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { Building2, ChevronDown, Plus } from 'lucide-react'
4+
import { cn } from '@/shared/lib/utils'
5+
import { useAuth } from '@/features/auth'
6+
import { setSupportTenant, useSupportTenant } from '@/shared/lib/current-tenant'
7+
import {
8+
canReadTenant,
9+
tenantsHttpService,
10+
} from '../services/tenants-http.service'
11+
import type { Tenant } from '../types/tenant.types'
12+
import { CreateTenantDialog } from './CreateTenantDialog'
13+
14+
/**
15+
* Topbar switcher for the tenant the current session is reading against.
16+
*
17+
* Rendered only for admins. The tenants list is exempt from the support-tenant
18+
* header, so it works even mid-session; on the endpoint responding 403 (a role
19+
* that says admin but not to this endpoint) the switcher hides itself.
20+
*
21+
* Entering a tenant is a hard navigation, same reason as the tenant cards: the
22+
* react-query caches, branding and notification feed all belong to whoever we
23+
* were before, and a soft navigation would leave them on screen next to the
24+
* other tenant's data.
25+
*/
26+
export function TenantSwitcher() {
27+
const { t } = useTranslation()
28+
const { isAdmin, tenantId: ownId } = useAuth()
29+
const support = useSupportTenant()
30+
const [tenants, setTenants] = useState<Tenant[]>([])
31+
const [failed, setFailed] = useState(false)
32+
const [open, setOpen] = useState(false)
33+
const [creating, setCreating] = useState(false)
34+
const ref = useRef<HTMLDivElement>(null)
35+
36+
const load = useCallback(async () => {
37+
if (!isAdmin) return
38+
try {
39+
const list = await tenantsHttpService.list({ size: 200 })
40+
setTenants(
41+
list.filter(
42+
(x) => x.id !== ownId && canReadTenant(x) && x.status !== 'TERMINATED'
43+
)
44+
)
45+
setFailed(false)
46+
} catch {
47+
setFailed(true)
48+
}
49+
}, [ownId, isAdmin])
50+
51+
useEffect(() => {
52+
void load()
53+
}, [load])
54+
55+
useEffect(() => {
56+
if (!open) return
57+
const onDoc = (e: MouseEvent) => {
58+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
59+
}
60+
document.addEventListener('mousedown', onDoc)
61+
return () => document.removeEventListener('mousedown', onDoc)
62+
}, [open])
63+
64+
if (!isAdmin || failed) return null
65+
66+
// Reload in place so the target tenant's data replaces ours, but bounce off
67+
// /tenants first: entering a tenant strips access to that page, so staying
68+
// there would just 403 on the next load.
69+
const reloadAfterSwitch = () => {
70+
if (window.location.pathname.startsWith('/tenants')) {
71+
window.location.assign('/home')
72+
} else {
73+
window.location.reload()
74+
}
75+
}
76+
77+
const enterSelf = () => {
78+
setOpen(false)
79+
setSupportTenant(null)
80+
reloadAfterSwitch()
81+
}
82+
83+
const enter = (tenant: Tenant) => {
84+
setOpen(false)
85+
setSupportTenant({
86+
id: tenant.id,
87+
name: tenant.name,
88+
access: tenant.supportAccess === 'FULL' ? 'FULL' : 'READ',
89+
})
90+
reloadAfterSwitch()
91+
}
92+
93+
const selfLabel = t('tenants.switcher.self', { defaultValue: 'Default tenant' })
94+
const current = support?.name ?? selfLabel
95+
96+
return (
97+
<div className="relative" ref={ref}>
98+
<button
99+
onClick={() => setOpen((v) => !v)}
100+
aria-label={t('tenants.switcher.aria', { defaultValue: 'Switch tenant' })}
101+
className={cn(
102+
'flex h-9 items-center gap-1.5 rounded-md border border-border bg-muted/40 px-2.5 text-[12px] transition-colors',
103+
open
104+
? 'bg-muted text-foreground'
105+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
106+
)}
107+
>
108+
<Building2 size={13} strokeWidth={1.75} />
109+
<span className="max-w-[9rem] truncate">{current}</span>
110+
<ChevronDown
111+
size={12}
112+
className={cn('transition-transform duration-150', open ? 'rotate-180' : 'rotate-0')}
113+
/>
114+
</button>
115+
{open && (
116+
<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">
117+
<div className="max-h-72 overflow-y-auto py-1">
118+
<button
119+
onClick={enterSelf}
120+
className={cn(
121+
'block w-full truncate px-3 py-1.5 text-left text-sm hover:bg-muted',
122+
!support && 'font-semibold text-primary'
123+
)}
124+
>
125+
{selfLabel}
126+
</button>
127+
{tenants.map((tn) => (
128+
<button
129+
key={tn.id}
130+
onClick={() => enter(tn)}
131+
title={tn.name}
132+
className={cn(
133+
'block w-full truncate px-3 py-1.5 text-left text-sm hover:bg-muted',
134+
support?.id === tn.id && 'font-semibold text-primary'
135+
)}
136+
>
137+
{tn.name}
138+
</button>
139+
))}
140+
</div>
141+
<button
142+
onClick={() => {
143+
setOpen(false)
144+
setCreating(true)
145+
}}
146+
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"
147+
>
148+
<Plus size={13} strokeWidth={1.75} />
149+
{t('tenants.switcher.create', { defaultValue: 'Create tenant' })}
150+
</button>
151+
</div>
152+
)}
153+
{creating && (
154+
<CreateTenantDialog
155+
onClose={() => setCreating(false)}
156+
onCreated={() => {
157+
setCreating(false)
158+
void load()
159+
}}
160+
/>
161+
)}
162+
</div>
163+
)
164+
}

frontend/src/shared/i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5472,6 +5472,11 @@
54725472
"notFound": "Tenant not found",
54735473
"invalidRequest": "Invalid request",
54745474
"operationFailed": "Operation failed"
5475+
},
5476+
"switcher": {
5477+
"aria": "Switch tenant",
5478+
"self": "Default tenant",
5479+
"create": "Create tenant"
54755480
}
54765481
},
54775482
"supportAccess": {

frontend/src/shared/layouts/DashboardLayout/Topbar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { useCurrentInstanceId } from '@/shared/lib/current-instance'
2828
import { InstanceSelector } from '@/features/federation/components/InstanceSelector'
2929
import { useFederationVersion } from '@/features/federation/hooks/use-version'
3030
import { useBilling } from '@/features/billing'
31+
import { TenantSwitcher } from '@/features/tenants/components/TenantSwitcher'
3132
import {
3233
NotificationRow,
3334
useNotificationFeed,
@@ -196,6 +197,7 @@ export function Topbar() {
196197

197198
{/* Right cluster */}
198199
<div className="flex items-center gap-1">
200+
<TenantSwitcher />
199201
<div className="relative" ref={notifRef}>
200202
<IconButton
201203
label={t('notifications.title')}

0 commit comments

Comments
 (0)