Skip to content

Commit 44a3c0a

Browse files
author
Christoph Henrici
committed
Multitenant capability
1 parent 337e5fe commit 44a3c0a

20 files changed

+297
-56
lines changed

frontend/.env.development

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
VITE_USE_MOCK=true
1+
VITE_USE_MOCK=false
22
# If you want real backend in dev, flip to false and (optionally) set:
33
VITE_BACKEND_URL=http://localhost:8080

frontend/src/api/clients.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ export type Client = v.InferOutput<typeof Client>
1010
const Clients = v.array(Client)
1111

1212
export async function getClients() {
13-
const response = await fetch('/clients')
13+
const response = await fetch('/clients', {credentials: 'include'})
1414
if (!response.ok) {
15-
throw new Error('Failed to fetch')
15+
throw new Error('Failed to fetch Clients')
1616
}
1717
return v.parse(Clients, await response.json())
1818
}

frontend/src/api/tenantInfo.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as v from 'valibot'
2+
3+
const TenantInfo = v.object({
4+
tenantDisplay: v.string(),
5+
clientCount: v.optional(v.number())
6+
})
7+
export type TenantInfo = v.InferOutput<typeof TenantInfo>
8+
9+
export async function getTenantInfo() {
10+
const res = await fetch('/tenantInfo', { credentials: 'include' })
11+
if (!res.ok) throw new Error(`Tenant load failed: ${res.status}`)
12+
return v.parse(TenantInfo, await res.json())
13+
}

frontend/src/components/PageLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ReactNode } from 'react'
22
import { Head } from '@/components/Head' // your existing title helper
3+
import { TenantBadge } from '@/components/TenantBadge'
4+
35

46
interface PageLayoutProps {
57
title: string
@@ -11,9 +13,12 @@ export function PageLayout({ title, children }: PageLayoutProps) {
1113
<div className="min-h-screen bg-gray-200 text-gray-900 flex flex-col">
1214
<Head title={title} />
1315
<main className="flex-1 container mx-auto p-6">{children}</main>
14-
<footer className="text-sm text-gray-500 text-center py-4">
16+
17+
<footer className="text-sm text-gray-500 text-center py-4 flex items-center justify-center gap-3">
1518
© {new Date().getFullYear()} Christoph Henrici · Spring + React CRUD Demo
19+
<span className="hidden sm:inline">·</span>
20+
<TenantBadge />
1621
</footer>
1722
</div>
1823
)
19-
}
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getTenantInfo} from '@/api/tenantInfo'
2+
import { useSuspenseQuery } from '@tanstack/react-query'
3+
4+
export function TenantBadge() {
5+
const { data } = useSuspenseQuery({
6+
queryFn: getTenantInfo,
7+
queryKey: ['tenantInfo']
8+
})
9+
if (!data?.tenantDisplay) return null
10+
return (
11+
<span className="text-xs text-gray-500">
12+
Workspace: <span className="font-mono">{data.tenantDisplay}</span>
13+
{typeof data.clientCount === 'number' && <> · {data.clientCount} clients</>}
14+
</span>
15+
)
16+
}

frontend/src/mocks/data/clients.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
},
1717
{
1818
"id": 4,
19-
"name": "Wtf",
19+
"name": "Whatever Client",
2020
"email": "fourth©example.com"
2121
}
2222

frontend/src/mocks/handlers.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
11
import {delay, HttpResponse, http} from 'msw'
22
import clients from './data/clients.json' with {type: 'json'}
33

4+
45
export const handlers = [
56
http.get('/clients', async () => {
67
await delay('real')
78
return HttpResponse.json(clients)
8-
})
9+
}),
10+
http.get('/api/tenant', async () => {
11+
await delay('real')
12+
return HttpResponse.json({
13+
tenantDisplay: getMockTenantDisplay(),
14+
createdAt: new Date().toISOString(),
15+
clientCount: 3 // or compute from your mock clients.json length
16+
})
17+
})
918
]
19+
// Persist a friendly token in *sessionStorage* to emulate a session-scoped tenant
20+
function getMockTenantDisplay(): string {
21+
const k = 'mock-tenant-display'
22+
let val = sessionStorage.getItem(k)
23+
if (!val) {
24+
val = crypto.randomUUID().slice(0, 8)
25+
sessionStorage.setItem(k, val)
26+
}
27+
return val
28+
}
29+
30+

frontend/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export default defineConfig(({ mode }) => {
3434
target: BACKEND_URL,
3535
changeOrigin: true,
3636
},
37+
'/tenantInfo': {
38+
target: BACKEND_URL,
39+
changeOrigin: true,
40+
},
3741
},
3842
}),
3943
},

pom.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@
4040
<groupId>org.springframework.boot</groupId>
4141
<artifactId>spring-boot-starter-web</artifactId>
4242
</dependency>
43-
43+
<dependency>
44+
<groupId>org.springframework.session</groupId>
45+
<artifactId>spring-session-jdbc</artifactId>
46+
</dependency>
4447
<dependency>
4548
<groupId>com.h2database</groupId>
4649
<artifactId>h2</artifactId>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package ch.henr.reactboot;
2+
3+
// TenantContext.java
4+
public final class TenantContext {
5+
private static final ThreadLocal<String> CUR = new ThreadLocal<>();
6+
public static void set(String t){ CUR.set(t); }
7+
public static String get(){ return CUR.get(); }
8+
public static void clear(){ CUR.remove(); }
9+
private TenantContext(){}
10+
}

0 commit comments

Comments
 (0)