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.
This commit is contained in:
2026-09-19 00:51:26 -07:00
parent 3fec79545c
commit 96e7b9842f
4 changed files with 53 additions and 7 deletions
+41 -6
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client';
import inbuxaMark from '@/assets/inbuxa-mark.png'; import inbuxaMark from '@/assets/inbuxa-mark.png';
function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' { function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' {
@@ -14,13 +16,40 @@ function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' {
return '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<string | null>(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() { export function Greeting() {
const { t } = useTranslation(); const { t } = useTranslation();
const accounts = useAuthStore((s) => s.accounts); const username = useAuthStore((s) => s.username);
const activeAccountId = useAuthStore((s) => s.activeAccountId); const fullName = useFullName(username);
const full = (activeAccountId && accounts[activeAccountId]?.name) || ''; const name = fullName ?? username?.split('@')[0] ?? '';
const name = full.split('@')[0];
const part = partOfDay(new Date().getHours()); const part = partOfDay(new Date().getHours());
const hello = const hello =
part === 'morning' part === 'morning'
@@ -36,7 +65,13 @@ export function Greeting() {
{hello} {hello}
{name && `, ${name}`} {name && `, ${name}`}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="truncate text-sm text-muted-foreground">
{username && (
<>
{t('greeting.signedInAs', 'Signed in as {{username}}', { username })}
{' · '}
</>
)}
{t('greeting.subtitle', "Here's how your mail server is doing.")} {t('greeting.subtitle', "Here's how your mail server is doing.")}
</p> </p>
</div> </div>
+2 -1
View File
@@ -389,6 +389,7 @@
"morning": "Good morning", "morning": "Good morning",
"afternoon": "Good afternoon", "afternoon": "Good afternoon",
"evening": "Good evening", "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}}"
} }
} }
+1
View File
@@ -143,6 +143,7 @@ export default function AdminPanel() {
if (cancelled) return; if (cancelled) return;
setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet); setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet);
useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null);
const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]); const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]);
+9
View File
@@ -26,6 +26,8 @@ interface AuthState {
apiUrl: string | null; apiUrl: string | null;
maxObjectsInGet: number; maxObjectsInGet: number;
maxObjectsInSet: number; maxObjectsInSet: number;
/** INBUXA: who is signed in, from the JMAP session (`username`). */
username: string | null;
setTokens: ( setTokens: (
access: string, access: string,
@@ -42,6 +44,7 @@ interface AuthState {
maxObjectsInSet?: number, maxObjectsInSet?: number,
) => void; ) => void;
switchAccount: (accountId: string) => void; switchAccount: (accountId: string) => void;
setUsername: (username: string | null) => void;
logout: () => void; logout: () => void;
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
isTokenExpiringSoon: () => boolean; isTokenExpiringSoon: () => boolean;
@@ -58,6 +61,11 @@ export const useAuthStore = create<AuthState>()(
accounts: {}, accounts: {},
primaryAccountId: null, primaryAccountId: null,
activeAccountId: null, activeAccountId: null,
username: null,
setUsername: (username) => {
set({ username });
},
apiUrl: null, apiUrl: null,
maxObjectsInGet: 500, maxObjectsInGet: 500,
maxObjectsInSet: 500, maxObjectsInSet: 500,
@@ -103,6 +111,7 @@ export const useAuthStore = create<AuthState>()(
primaryAccountId: null, primaryAccountId: null,
activeAccountId: null, activeAccountId: null,
apiUrl: null, apiUrl: null,
username: null,
}); });
}, },