feat: email a seat its username and temporary password, when an address is given

Follows the previous commit. Dropping the email field entirely went one step too
far: without an address the temporary password shown once in the panel is the
only copy, and an admin who closes that panel has to issue a new one. The field
is back, marked optional, and now it buys something concrete.

`team_account_created` carried no credential -- it said the team "will send you
a temporary password separately". It now carries the username and the temporary
password, so an admin who supplies an address hands nothing over by side channel.

`#notifyUser` takes extra template variables, and both credential-issuing paths
pass the one they just minted: provisioning and re-issue. Re-issue passing the
fresh credential rather than the stale one is the case worth checking, and there
is a test that asserts the old password is absent from that mail.

With no address nothing is sent, which was already true -- `#notifyUser` returns
early without one -- and is now covered.

The docs said the notice carries no credential in two places. Both corrected.

Falsified: dropping the credential from the re-issue call fails "emails the fresh
credential on re-issue, not the old one" and nothing else.

178 backend tests, 326 GUI/SDK tests, typecheck clean.
This commit is contained in:
Juan Castro
2026-09-10 16:41:03 -04:00
parent a983969f32
commit fb74789de1
7 changed files with 94 additions and 17 deletions
+5 -3
View File
@@ -351,9 +351,11 @@ support@puter.com immediately.
subject: 'Your {{team_name}} account on Puter',
html: `
<p>Hi there,</p>
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
They will send you a temporary password separately; you will be asked to
choose your own the first time you sign in.</p>
<p>{{team_name}} has created a Puter account for you.</p>
<p>Username: <b>{{username}}</b><br>
Temporary password: <b>{{temporary_password}}</b></p>
<p>You will be asked to choose your own password the first time you sign in.
This temporary one stops working then, and it expires on its own if unused.</p>
<p>What this means:</p>
<ul>
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
+67 -2
View File
@@ -938,21 +938,86 @@ describe('TeamService', () => {
/** Captures what would go out, without standing up a transport. */
const captureMail = () => {
const sent: { to: string; subject: string }[] = [];
const sent: { to: string; subject: string; html: string }[] = [];
const client = server.clients.email as unknown as {
sendRaw: (o: { to?: string; subject?: string }) => Promise<unknown>;
sendRaw: (o: {
to?: string;
subject?: string;
html?: string;
}) => Promise<unknown>;
};
const original = client.sendRaw.bind(client);
client.sendRaw = async (options) => {
sent.push({
to: String(options.to ?? ''),
subject: String(options.subject ?? ''),
html: String(options.html ?? ''),
});
return null;
};
return { sent, restore: () => (client.sendRaw = original) };
};
it('emails the credential when an address is given', async () => {
const { team } = await makeTeam();
const username = `mail_${Math.random().toString(36).slice(2, 9)}`;
const mail = captureMail();
let created;
try {
created = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
} finally {
mail.restore();
}
expect(mail.sent).toHaveLength(1);
expect(mail.sent[0].to).toBe(`${username}@test.local`);
expect(mail.sent[0].html).toContain(username);
// The point of the address: without it this is the only copy.
expect(mail.sent[0].html).toContain(created.temporaryPassword);
});
it('sends nothing when no address is given', async () => {
const { team } = await makeTeam();
const mail = captureMail();
try {
await service.provisionAccount(team.uid, owner.id, {
username: `nomail_${Math.random().toString(36).slice(2, 9)}`,
});
} finally {
mail.restore();
}
expect(mail.sent).toHaveLength(0);
});
it('emails the fresh credential on re-issue, not the old one', async () => {
const { team } = await makeTeam();
const username = `re_${Math.random().toString(36).slice(2, 9)}`;
const first = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
const mail = captureMail();
let again;
try {
again = await service.reissueCredential(
team.uid,
owner.id,
first.userId,
);
} finally {
mail.restore();
}
expect(mail.sent).toHaveLength(1);
expect(mail.sent[0].html).toContain(again.temporaryPassword);
expect(mail.sent[0].html).not.toContain(first.temporaryPassword);
});
it('tells a member their account was disabled', async () => {
const { team } = await makeTeam();
const username = `dis_${Math.random().toString(36).slice(2, 9)}`;
+9 -8
View File
@@ -788,7 +788,9 @@ export class TeamService extends PuterService {
// Returned once; forced change on first use is what bounds it.
const temporaryPassword = await this.#issueTemporaryPassword(user.id);
await this.#notifyUser(user, 'team_account_created', team);
await this.#notifyUser(user, 'team_account_created', team, {
temporary_password: temporaryPassword,
});
// Last: the seat is only chargeable once it exists and can be used.
this.#emitBilling('team.account.created', {
@@ -831,7 +833,9 @@ export class TeamService extends PuterService {
});
const temporaryPassword =
await this.#issueTemporaryPassword(targetUserId);
await this.#notifyUser(user, 'team_account_created', team);
await this.#notifyUser(user, 'team_account_created', team, {
temporary_password: temporaryPassword,
});
return { temporaryPassword };
}
@@ -910,22 +914,19 @@ export class TeamService extends PuterService {
return temporaryPassword;
}
/**
* A notice about something the team did to a member's account. It carries
* no credential, so delivery is best effort -- nothing the caller did
* depends on it arriving, and an address the administrator supplied may not
* even reach its holder.
*/
/** Best effort: the admin also gets the credential in the API response. */
async #notifyUser(
user: UserRow | null | undefined,
template: EmailTemplateName,
team: TeamRow,
vars: Record<string, string> = {},
): Promise<void> {
if (!this.clients.email || !user?.email) return;
try {
const sent = await this.clients.email.send(user.email, template, {
username: user.username,
team_name: team.name ?? 'Your team',
...vars,
});
// `sendRaw` returns null with no transport rather than throwing.
if (sent === null) {
+2 -2
View File
@@ -30,9 +30,9 @@ The username for the new account. Usernames come from the same pool as ordinary
#### `options.email` (String) (optional)
Where the team's notices about this account are delivered. These accounts sign in by **username**, so an address is not needed and the form does not ask for one.
Where this account's notices are delivered. These accounts sign in by **username**, so an address is optional.
Supply it only if you want `team_account_created`, `team_account_disabled` and `team_password_reset` to reach the member; if you leave it out, those notices are simply not sent and the temporary password in the return value is the only delivery. If given, it must not already own an account.
Give one and the member is emailed their username and temporary password directly, and later notices (`team_account_disabled`, `team_password_reset`) reach them too. Leave it out and nothing is sent the temporary password in the return value is then the only copy, so hand it over before you lose it. If given, it must not already own an account.
The account is never asked to confirm the address — the team creating it is the trust anchor — so it can be used immediately either way. An account with no address is recoverable only through its team's owner, via `resetPassword`.
+1 -1
View File
@@ -10,7 +10,7 @@ Issues a fresh one-time credential for an account that has never signed in, inva
**It refuses once the account has been activated**, rejecting with `conflict`. After activation the member owns their own password, and an administrator able to replace it would be able to reach their files. An activated member resets their own password through the normal Puter flow.
The credential comes back once and is not retrievable afterwards. The member is emailed a notice that the account was set up; the notice carries no credential.
The credential comes back once and is not retrievable afterwards. If the account has an email address, the new credential is emailed to it as well; if it has none, the return value is the only copy.
## Syntax
+8 -1
View File
@@ -122,8 +122,10 @@ const renderAddAccount = () => {
let h = '<div class="dashboard-card teams-panel">';
h += `<h3>${i18n('teams_add_account')}</h3>`;
h += `<p class="teams-panel-hint">${i18n('teams_add_account_hint')}</p>`;
h += `<p class="teams-panel-hint">${i18n('teams_add_account_email_hint')}</p>`;
h += '<div class="teams-form">';
h += `<input class="teams-new-username" type="text" autocomplete="off" spellcheck="false" placeholder="${html_encode(i18n('username'))}">`;
h += `<input class="teams-new-email" type="email" autocomplete="off" spellcheck="false" placeholder="${html_encode(i18n('teams_email_optional'))}">`;
h += `<button class="button button-primary teams-add-btn">${i18n('teams_add_account')}</button>`;
h += '</div>';
h += '<div class="teams-credential" style="display:none;"></div>';
@@ -310,11 +312,16 @@ const showCredential = ($el_window, username, temporaryPassword) => {
const addAccount = async ($el_window) => {
const username = $el_window.find(`${SECTION} .teams-new-username`).val().trim();
if ( ! username ) return;
// With an address the credential is emailed too; without it, only shown here.
const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim();
const $button = $el_window.find(`${SECTION} .teams-add-btn`);
$button.prop('disabled', true);
try {
const created = await puter.teams.createMember(state.selected.uid, { username });
const created = await puter.teams.createMember(state.selected.uid, {
username,
...(email ? { email } : {}),
});
await refresh($el_window);
showCredential($el_window, created.username, created.temporaryPassword);
} catch (e) {
+2
View File
@@ -529,6 +529,8 @@ const en = {
teams_accounts: 'Accounts',
teams_no_accounts: 'This team has no accounts yet.',
teams_add_account: 'Add an account',
teams_email_optional: 'Email (optional)',
teams_add_account_email_hint: 'If you add an address, we email the username and temporary password to it. Otherwise the password below is the only copy.',
teams_add_account_hint:
'Puter creates the account and gives you a one-time password to pass on. The username has to be free across all of Puter.',
teams_member_kind: 'Kind',