Files
webmail/lib/stalwart/principal.ts
T
Linus Rath 91a1b1cb51 fix: let non-admin users see their name and change a TOTP password
Bulwark read the live display name (#900) and the account profile with
`x:Account/get`, which Stalwart only allows for admins. Every other
user got `forbidden`, so the settings page showed no name and the
identity kept the stale snapshot. The name now comes from
`x:AccountSettings/get`, the account's own settings object; aliases
and quota still use `x:Account/get` where it is permitted.

With TOTP on, Stalwart requires the current code for a password change
and for switching TOTP off ("Current OTP code is required"). Neither
dialog asked for it. Both now do, and the password change sends it as
the `otpAuth/otpCode` patch, because a whole `otpAuth` object would
reset `otpUrl` and turn TOTP off.

Saving the display name also ignored a `notUpdated` answer and showed
success.
2026-09-22 20:28:01 +02:00

69 lines
2.8 KiB
TypeScript

import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { stalwartJmap, requireResult } from '@/lib/stalwart/jmap-passthrough';
import { debug } from '@/lib/debug';
import { fetchConfig } from '@/hooks/use-config';
export const STALWART_JMAP_CAPABILITY = 'urn:stalwart:jmap';
/**
* Whether the server-side /api/account/stalwart/jmap passthrough is enabled
* (STALWART_JMAP_PASSTHROUGH_ENABLED, also off when Stalwart features are
* disabled). Callers skip the passthrough entirely when it is off instead of
* collecting a 404 on every login. Unknown config counts as enabled. (#904)
*/
export async function isStalwartJmapPassthroughEnabled(): Promise<boolean> {
try {
const config = await fetchConfig();
return config.stalwartFeaturesEnabled !== false && config.stalwartJmapPassthroughEnabled !== false;
} catch {
return true;
}
}
interface AccountSettingsGetResponse {
list?: Array<{ description?: string | null }>;
}
/**
* Live "Full name" of the authenticated Stalwart principal (#900).
*
* Stalwart seeds the default JMAP Identity's `name` from the principal
* description only once, when the identity is first created. An admin
* renaming the user afterwards never propagates to `Identity/get`, so the
* identity name is a stale snapshot. The current value is the `description`
* of `x:AccountSettings`, the account's own settings object, read through the
* server-side passthrough (the browser never holds the credentials).
* (`x:Account/get` holds it too, but is forbidden for everyone but admins.)
*
* `slot` addresses the account's cookie slot explicitly so the lookup works
* for accounts that are being restored in the background, not just the
* active one.
*
* Returns null when the server is not Stalwart, the call is not permitted
* for this user, or the principal has no description - callers fall back to
* the identity name in that case.
*/
export async function fetchPrincipalDisplayName(
client: IJMAPClient,
slot?: number,
): Promise<string | null> {
if (!client.hasAccountCapability?.(STALWART_JMAP_CAPABILITY)) return null;
if (!(await isStalwartJmapPassthroughEnabled())) return null;
try {
const accountId = client.getAccountId();
const responses = await stalwartJmap(
[['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']],
{ slot },
);
const result = requireResult<AccountSettingsGetResponse>(responses, 'x:AccountSettings/get');
const description = result.list?.[0]?.description;
const name = typeof description === 'string' ? description.trim() : '';
return name || null;
} catch (error) {
// Not fatal: the cached display name (or the identity name) stays in use.
debug.warn('auth', 'Failed to read principal display name:', error);
return null;
}
}