From 0b5e283de2abf0a6cca15069df581837c4aa8085 Mon Sep 17 00:00:00 2001 From: "tracy.ma" Date: Sun, 4 Jan 2026 17:18:19 +0800 Subject: [PATCH] add multi-tenancy --- src/lib/helpers/http.js | 12 ++- src/lib/helpers/store.js | 74 +++++++++++++++++ src/lib/services/api-endpoints.js | 3 + src/lib/services/auth-service.js | 44 +++++++++- .../(authentication)/login/+page.svelte | 82 ++++++++++++++++++- src/routes/VerticalLayout/Header.svelte | 20 ++++- 6 files changed, 226 insertions(+), 9 deletions(-) diff --git a/src/lib/helpers/http.js b/src/lib/helpers/http.js index 91eef803..c03bb81c 100644 --- a/src/lib/helpers/http.js +++ b/src/lib/helpers/http.js @@ -1,5 +1,5 @@ import axios from 'axios'; -import { getUserStore, globalErrorStore, loaderStore, userStore } from '$lib/helpers/store.js'; +import { getUserStore, globalErrorStore, loaderStore, userStore, getTenantId } from '$lib/helpers/store.js'; import { renewToken } from '$lib/services/auth-service'; import { delay } from './utils/common'; @@ -79,6 +79,11 @@ const retryQueue = { config.headers = config.headers || {}; // @ts-ignore config.headers.Authorization = `Bearer ${newToken}`; + const tenantId = getTenantId(); + if (tenantId) { + // @ts-ignore + config.headers['__tenant'] = tenantId; + } chain = chain.then(() => delay(this.timeout)) .then(() => { @@ -102,12 +107,17 @@ axios.interceptors.request.use( (config) => { // Add your authentication logic here const user = getUserStore(); + const tenantId = getTenantId(); if (!skipLoader(config)) { loaderStore.set(true); } // Attach an authentication token to the request headers if (user.token) { config.headers.Authorization = `Bearer ${user.token}`; + + if (tenantId) { + config.headers['__tenant'] = tenantId; + } } else { retryQueue.queue = []; redirectToLogin(); diff --git a/src/lib/helpers/store.js b/src/lib/helpers/store.js index 19c7608b..86b99097 100644 --- a/src/lib/helpers/store.js +++ b/src/lib/helpers/store.js @@ -7,6 +7,8 @@ const conversationKey = "conversation"; const conversationUserStatesKey = "conversation_user_states"; const conversationSearchOptionKey = "conversation_search_option"; const conversationUserMessageKey = "conversation_user_messages"; +const tenantKey = "tenant_id"; +const tenantNameKey = "tenant_name"; /** @type {Writable} */ const createGlobalEventStore = () => { @@ -53,6 +55,76 @@ export function getUserStore() { } }; + +/** @returns {string} */ +export function getTenantId() { + if (!browser) return ''; + return sessionStorage.getItem(tenantKey) || ''; +} + +/** @param {string} tenantId */ +export function setTenantId(tenantId) { + if (!browser) return; + if (!tenantId) { + sessionStorage.removeItem(tenantKey); + return; + } + sessionStorage.setItem(tenantKey, tenantId); +} + +export function clearTenantId() { + if (!browser) return; + sessionStorage.removeItem(tenantKey); +} + +/** @returns {string} */ +export function getTenantName() { + if (!browser) return ''; + return sessionStorage.getItem(tenantNameKey) || ''; +} + +/** @param {string} tenantName */ +export function setTenantName(tenantName) { + if (!browser) return; + if (!tenantName) { + sessionStorage.removeItem(tenantNameKey); + } else { + sessionStorage.setItem(tenantNameKey, tenantName); + } + + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('tenantChanged', { + detail: { + tenantId: getTenantId(), + tenantName: getTenantName() + } + })); + } +} + +export function clearTenantName() { + if (!browser) return; + sessionStorage.removeItem(tenantNameKey); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('tenantChanged', { + detail: { + tenantId: getTenantId(), + tenantName: '' + } + })); + } +} + +export function notifyTenantChanged() { + if (!browser || typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent('tenantChanged', { + detail: { + tenantId: getTenantId(), + tenantName: getTenantName() + } + })); +} + userStore.subscribe(value => { if (browser && value.token) { sessionStorage.setItem(userKey, JSON.stringify(value)); @@ -228,6 +300,8 @@ export function resetStorage(resetUser = false) { if (resetUser) { sessionStorage.removeItem(userKey); + sessionStorage.removeItem(tenantKey); + sessionStorage.removeItem(tenantNameKey); } } diff --git a/src/lib/services/api-endpoints.js b/src/lib/services/api-endpoints.js index 1dd73bd3..38d18ac1 100644 --- a/src/lib/services/api-endpoints.js +++ b/src/lib/services/api-endpoints.js @@ -17,6 +17,9 @@ export const endpoints = { userUpdateUrl: `${host}/user`, usrCreationUrl: `${host}/user`, userAvatarUrl: `${host}/user/avatar`, + + //tenant + userTenantsUrl: `${host}/tenants/options`, // setting settingListUrl: `${host}/settings`, diff --git a/src/lib/services/auth-service.js b/src/lib/services/auth-service.js index 7bbe3fee..5cc518fe 100644 --- a/src/lib/services/auth-service.js +++ b/src/lib/services/auth-service.js @@ -1,19 +1,25 @@ -import { userStore, getUserStore } from '$lib/helpers/store.js'; +import { userStore, getUserStore, resetStorage, setTenantId, clearTenantId, getTenantId, clearTenantName, notifyTenantChanged } from '$lib/helpers/store.js'; import { endpoints } from './api-endpoints.js'; import axios from 'axios'; /** * @param {string} email * @param {string} password + * @param {string} tenantId * @param {function} onSucceed * @param {function} onError */ -export async function getToken(email, password, onSucceed, onError) { +export async function getToken(email, password, tenantId, onSucceed, onError) { const credentials = btoa(`${email}:${password}`); + /** @type {Record} */ const headers = { Authorization: `Basic ${credentials}`, }; + if (tenantId) { + headers['__tenant'] = tenantId; + } + await fetch(endpoints.tokenUrl, { method: 'POST', headers: headers, @@ -34,6 +40,15 @@ export async function getToken(email, password, onSucceed, onError) { user.expires = result.expires; user.renew_token_count = 0; userStore.set(user); + + if (tenantId) { + setTenantId(tenantId); + notifyTenantChanged(); + } else { + clearTenantId(); + clearTenantName(); + } + onSucceed(); }) .catch(() => { @@ -47,11 +62,13 @@ export async function getToken(email, password, onSucceed, onError) { * @param {(() => void) | null} [onError] */ export async function renewToken(token, onSucceed = null, onError = null) { + const tenantId = getTenantId(); await fetch(endpoints.renewTokenUrl, { method: 'POST', headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${token}` + "Authorization": `Bearer ${token}`, + ...(tenantId ? { "__tenant": tenantId } : {}) }, body: JSON.stringify({ refresh_token: token, access_token: token }), }).then(response => { @@ -137,4 +154,25 @@ export async function register(firstName, lastName, email, password, onSucceed) export async function uploadUserAvatar(file) { const response = await axios.post(endpoints.userAvatarUrl, { ...file }); return response?.data; +} + +export async function getTenantOptions() { + try { + const response = await fetch(`${endpoints.userTenantsUrl}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return result; + } catch (error) { + return null; + } } \ No newline at end of file diff --git a/src/routes/(authentication)/login/+page.svelte b/src/routes/(authentication)/login/+page.svelte index 9c177757..3c6ba213 100644 --- a/src/routes/(authentication)/login/+page.svelte +++ b/src/routes/(authentication)/login/+page.svelte @@ -14,7 +14,7 @@ Alert } from '@sveltestrap/sveltestrap'; import Headtitle from '$lib/common/HeadTitle.svelte'; - import { getToken } from '$lib/services/auth-service.js'; + import { getToken, getTenantOptions } from '$lib/services/auth-service.js'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { @@ -31,7 +31,7 @@ PUBLIC_AUTH_ENABLE_FIND_PWD, } from '$env/static/public'; import { onMount } from 'svelte'; - import { resetStorage } from '$lib/helpers/store'; + import { resetStorage, setTenantName, clearTenantName } from '$lib/helpers/store'; let username = PUBLIC_ADMIN_USERNAME; let password = PUBLIC_ADMIN_PASSWORD; @@ -40,13 +40,20 @@ let status = ''; let isSubmitting = false; let isRememberMe = false; + let tenantId = ''; + /** @type {{ tenantId: string, name: string }[]} */ + let tenantOptions = []; + let tenantOptionsLoaded = false; - onMount(() => { + onMount(async () => { const userName = localStorage.getItem('user_name'); isRememberMe = userName !== null; if(isRememberMe){ username = userName || ''; } + + // Load tenant options when opening the login page + await getTenamtOptions(); }); function handleRememberMe(){ if(isRememberMe){ @@ -62,10 +69,29 @@ isSubmitting = true; handleRememberMe(); e.preventDefault(); - await getToken(username, password, () => { + + // Ensure tenant options have been fetched at least once + if (!tenantOptionsLoaded) { + await getTenamtOptions(); + } + + if (tenantOptions?.length > 0 && !tenantId) { + isSubmitting = false; + return; + } + + await getToken(username, password, tenantOptions?.length > 0 ? tenantId : '', () => { isOpen = true; msg = 'Authentication success'; status = 'success'; + + if (tenantOptions?.length > 0) { + const selected = tenantOptions.find((x) => x.tenantId === tenantId); + setTenantName(selected?.name || ''); + } else { + clearTenantName(); + } + const redirectUrl = $page.url.searchParams.get('redirect'); isSubmitting = false; resetStorage(); @@ -88,6 +114,34 @@ isSubmitting = false; } + async function getTenamtOptions() { + try { + let data = await getTenantOptions(); + const raw = Array.isArray(data) ? data : []; + tenantOptions = raw + .map((/** @type {any} */ x) => ({ + tenantId: x?.tenantId || x?.id || '', + name: x?.name || x?.tenantName || x?.displayName || x?.id || x?.tenantId || '' + })) + .filter((/** @type {{tenantId: string}} */ x) => !!x.tenantId); + + if (tenantOptions.length === 0) { + tenantId = ''; + } else if (tenantOptions.length === 1) { + tenantId = tenantOptions[0].tenantId; + } else { + // keep current selection if still valid + const stillValid = tenantOptions.some((x) => x.tenantId === tenantId); + if (!stillValid) tenantId = ''; + } + } catch (error) { + tenantOptions = []; + tenantId = ''; + } finally { + tenantOptionsLoaded = true; + } + } + function onPasswordToggle() { const x = document.getElementById('user-password'); if (!x) return; @@ -143,6 +197,26 @@
{msg}
+ {#if tenantOptions.length > 0} +
+ + + {#if tenantOptions.length > 1} + + {/if} + {#each tenantOptions as t (t.tenantId)} + + {/each} + +
+ {/if} +
import { browser } from '$app/environment'; + import { onMount } from 'svelte'; import { _ } from 'svelte-i18n'; import { Input } from '@sveltestrap/sveltestrap'; import LanguageDropdown from '$lib/common/LanguageDropdown.svelte'; @@ -8,9 +9,10 @@ import ProfileDropdown from '$lib/common/ProfileDropdown.svelte'; import { OverlayScrollbars } from 'overlayscrollbars'; import { PUBLIC_LOGO_URL } from '$env/static/public'; - import { globalEventStore } from '$lib/helpers/store'; + import { globalEventStore, getTenantName } from '$lib/helpers/store'; import { GlobalEvent } from '$lib/helpers/enums'; + /** @type {any} */ export let user; @@ -20,6 +22,19 @@ /** @type {string} */ let searchText = ''; + /** @type {string} */ + let tenantName = ''; + + onMount(() => { + tenantName = getTenantName(); + const handler = (/** @type {any} */ e) => { + tenantName = e?.detail?.tenantName || getTenantName() || ''; + }; + window.addEventListener('tenantChanged', handler); + return () => window.removeEventListener('tenantChanged', handler); + }); + + const toggleSideBar = () => { if (browser) { document.body.classList.toggle('sidebar-enable'); @@ -105,6 +120,9 @@
+ {#if tenantName} + Tenant: {tenantName} + {/if}