From 96e7b9842fac85fd7f098517399c55ab8dd7e6be Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 00:51:26 -0700 Subject: [PATCH] Dashboard greeting names who is signed in It greets the account by its full name where it has one, and otherwise by the name it signs in with. The line beneath says who is signed in. The account is looked up by its local part and matched on the whole address, so a same-named account on another domain can't answer. The username is kept from the JMAP session and cleared on sign-out. --- .../dashboard/components/Greeting.tsx | 47 ++++++++++++++++--- src/i18n/en.json | 3 +- src/pages/AdminPanel.tsx | 1 + src/stores/authStore.ts | 9 ++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/components/Greeting.tsx b/src/features/dashboard/components/Greeting.tsx index 91a7930..3ebfeb6 100644 --- a/src/features/dashboard/components/Greeting.tsx +++ b/src/features/dashboard/components/Greeting.tsx @@ -4,8 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '@/stores/authStore'; +import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client'; import inbuxaMark from '@/assets/inbuxa-mark.png'; function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' { @@ -14,13 +16,40 @@ function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' { return 'evening'; } -/** The dashboard's hello: whose server this is, and a nod to the time of day. */ +/** + * The name to greet: the account's full name where it has one, otherwise the + * name it signs in with. Looked up by the local part and matched on the whole + * address, so an account of the same name on another domain can't answer. + */ +function useFullName(username: string | null): string | null { + const [fullName, setFullName] = useState(null); + useEffect(() => { + if (!username) return; + let live = true; + const localPart = username.split('@')[0]; + jmapQueryAndGet('x:Account', getAccountId('x:Account'), { filter: { name: localPart } }, ['description', 'emailAddress']) + .then((responses) => { + const list = (responses[1]?.[1] as { list?: { description?: string | null; emailAddress?: string }[] }).list ?? []; + const own = list.find((a) => a.emailAddress?.toLowerCase() === username.toLowerCase()); + const name = own?.description?.trim(); + if (live && name) setFullName(name); + }) + .catch(() => { + /* the sign-in name stands */ + }); + return () => { + live = false; + }; + }, [username]); + return fullName; +} + +/** The dashboard's hello: to whoever is signed in, with a nod to the time of day. */ export function Greeting() { const { t } = useTranslation(); - const accounts = useAuthStore((s) => s.accounts); - const activeAccountId = useAuthStore((s) => s.activeAccountId); - const full = (activeAccountId && accounts[activeAccountId]?.name) || ''; - const name = full.split('@')[0]; + const username = useAuthStore((s) => s.username); + const fullName = useFullName(username); + const name = fullName ?? username?.split('@')[0] ?? ''; const part = partOfDay(new Date().getHours()); const hello = part === 'morning' @@ -36,7 +65,13 @@ export function Greeting() { {hello} {name && `, ${name}`} -

+

+ {username && ( + <> + {t('greeting.signedInAs', 'Signed in as {{username}}', { username })} + {' ยท '} + + )} {t('greeting.subtitle', "Here's how your mail server is doing.")}

diff --git a/src/i18n/en.json b/src/i18n/en.json index bbdf247..be197d3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -389,6 +389,7 @@ "morning": "Good morning", "afternoon": "Good afternoon", "evening": "Good evening", - "subtitle": "Here's how your mail server is doing." + "subtitle": "Here's how your mail server is doing.", + "signedInAs": "Signed in as {{username}}" } } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 7324799..d0dc825 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -143,6 +143,7 @@ export default function AdminPanel() { if (cancelled) return; setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet); + useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null); const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]); diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 34d15a3..6e1a79d 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -26,6 +26,8 @@ interface AuthState { apiUrl: string | null; maxObjectsInGet: number; maxObjectsInSet: number; + /** INBUXA: who is signed in, from the JMAP session (`username`). */ + username: string | null; setTokens: ( access: string, @@ -42,6 +44,7 @@ interface AuthState { maxObjectsInSet?: number, ) => void; switchAccount: (accountId: string) => void; + setUsername: (username: string | null) => void; logout: () => void; isAuthenticated: () => boolean; isTokenExpiringSoon: () => boolean; @@ -58,6 +61,11 @@ export const useAuthStore = create()( accounts: {}, primaryAccountId: null, activeAccountId: null, + username: null, + + setUsername: (username) => { + set({ username }); + }, apiUrl: null, maxObjectsInGet: 500, maxObjectsInSet: 500, @@ -103,6 +111,7 @@ export const useAuthStore = create()( primaryAccountId: null, activeAccountId: null, apiUrl: null, + username: null, }); },