mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-30 00:56:50 +00:00
feat: a team seat needs no email address, and is never asked to confirm one
PUT-1792. Two separate problems, both from treating a provisioned account like a self-registered one. `email` was required, so an admin creating ten seats had to invent ten addresses and then keep track of ten uniqueness constraints -- for accounts that sign in by username and never use the address. It is now optional at every layer, and the add-account form does not ask for it at all: username is the only thing a seat needs. `requires_email_confirmation` was set to true, with the reasoning that an admin-supplied address is unverified. True, but `requireVerifiedAccount` turns away on exactly `requires_email_confirmation && !email_confirmed`, so a freshly created seat was asked to confirm an address it may not hold and could not use the product until it did. The team creating the account is the trust anchor, not the mailbox, so this is now false either way. An address is still accepted and still stored when given, because the notices are worth delivering. `#notifyUser` already returned early on a missing address, so `team_account_created`, `team_account_disabled`, `team_password_reset` and `team_closed` degrade quietly with no new branching -- the temporary password is in the API response, which is the documented delivery. `idx_user_owned_email` is partial and skips password-null rows, so omitting the address sidesteps it rather than creating a collision surface. Two seats with no address do not conflict, and there is a test for it. Docs now say an emailless seat is recoverable only through its team's owner. That falls out of the design rather than being a limitation of this change, but it should be written down rather than discovered. Falsified: putting `requires_email_confirmation: true` back fails "never demands confirmation, with or without an address" with `expected true to be false`, and nothing else. 164 team tests, 40 SDK tests, typecheck clean.
This commit is contained in:
@@ -249,7 +249,7 @@ export class TeamController extends PuterController {
|
||||
const body = this.#body(req);
|
||||
const result = await this.services.team.provisionAccount(uid, userId, {
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#requireString(body.email, 'email'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
});
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
@@ -426,6 +426,17 @@ export class TeamController extends PuterController {
|
||||
return (req.body ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Absent or empty means "not given"; a wrong type is still a 400. */
|
||||
#optionalString(value: unknown, field: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `${field} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
|
||||
#requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new HttpError(400, `${field} is required`, {
|
||||
|
||||
@@ -577,6 +577,73 @@ describe('TeamService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT-1792: seats sign in by username, so an address is optional.
|
||||
it('provisions with no email at all', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `noem_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
});
|
||||
|
||||
const row = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
created.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(row?.email ?? null).toBeNull();
|
||||
expect(created.temporaryPassword).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('never demands confirmation, with or without an address', async () => {
|
||||
// The gate is `requires_email_confirmation && !email_confirmed`.
|
||||
const { team } = await makeTeam();
|
||||
const bare = `bare_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const withEmail = `wem_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const a = await service.provisionAccount(team.uid, owner.id, {
|
||||
username: bare,
|
||||
});
|
||||
const b = await service.provisionAccount(team.uid, owner.id, {
|
||||
username: withEmail,
|
||||
email: `${withEmail}@test.local`,
|
||||
});
|
||||
|
||||
for (const id of [a.userId, b.userId]) {
|
||||
const row = await server.stores.user.getByProperty('id', id, {
|
||||
force: true,
|
||||
});
|
||||
expect(Boolean(row?.requires_email_confirmation)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an address when one is given', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `kept_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
|
||||
const row = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
created.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(row?.email).toBe(`${username}@test.local`);
|
||||
});
|
||||
|
||||
it('does not collide two seats that both have no address', async () => {
|
||||
// The uniqueness index is on the address; absent is not a value.
|
||||
const { team } = await makeTeam();
|
||||
for (const n of [1, 2]) {
|
||||
await expect(
|
||||
service.provisionAccount(team.uid, owner.id, {
|
||||
username: `dup${n}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
}),
|
||||
).resolves.toMatchObject({ username: expect.any(String) });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an invalid email', async () => {
|
||||
const { team } = await makeTeam();
|
||||
await expect(
|
||||
|
||||
@@ -700,7 +700,7 @@ export class TeamService extends PuterService {
|
||||
async provisionAccount(
|
||||
teamUid: string,
|
||||
actorUserId: number,
|
||||
input: { username: string; email: string },
|
||||
input: { username: string; email?: string | null },
|
||||
): Promise<{
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -714,7 +714,7 @@ export class TeamService extends PuterService {
|
||||
async #provisionAccountLocked(
|
||||
teamUid: string,
|
||||
actorUserId: number,
|
||||
input: { username: string; email: string },
|
||||
input: { username: string; email?: string | null },
|
||||
): Promise<{
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -732,7 +732,8 @@ export class TeamService extends PuterService {
|
||||
}
|
||||
|
||||
this.#assertUsableUsername(input.username);
|
||||
if (!validator.isEmail(input.email)) {
|
||||
const email = typeof input.email === 'string' ? input.email.trim() : '';
|
||||
if (email && !validator.isEmail(email)) {
|
||||
throw new HttpError(400, 'Invalid email', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
@@ -749,7 +750,7 @@ export class TeamService extends PuterService {
|
||||
}
|
||||
|
||||
// `idx_user_owned_email` is partial and skips password-null rows.
|
||||
if (await this.stores.user.findEmailOwner(input.email)) {
|
||||
if (email && (await this.stores.user.findEmailOwner(email))) {
|
||||
throw new HttpError(409, 'That email is already in use', {
|
||||
legacyCode: 'email_already_in_use',
|
||||
});
|
||||
@@ -759,10 +760,10 @@ export class TeamService extends PuterService {
|
||||
username: input.username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: input.email,
|
||||
clean_email: cleanEmail(input.email),
|
||||
// The address came from the administrator, not its holder.
|
||||
requires_email_confirmation: true,
|
||||
email: email || null,
|
||||
clean_email: email ? cleanEmail(email) : null,
|
||||
// Never demanded: the team creating the account is the trust anchor.
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
|
||||
await generateDefaultFsentries(this.clients.db, this.stores.user, user);
|
||||
|
||||
@@ -28,9 +28,13 @@ The team's identifier.
|
||||
|
||||
The username for the new account. Usernames come from the same pool as ordinary sign-ups, so it must be free across the whole of Puter.
|
||||
|
||||
#### `options.email` (String) (required)
|
||||
#### `options.email` (String) (optional)
|
||||
|
||||
The address the member is reachable at. It must not already own an account. The address came from the administrator rather than its holder, so the account is created needing email confirmation.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## Return value
|
||||
|
||||
@@ -53,7 +57,6 @@ Rejects with `username_already_in_use` — with free alternatives in `fields.sug
|
||||
const name = 'member' + Math.random().toString(36).slice(2, 8);
|
||||
const account = await puter.teams.createMember(team.uid, {
|
||||
username: name,
|
||||
email: `${name}@example.com`,
|
||||
});
|
||||
// Shown once; hand it over out of band.
|
||||
puter.print(`${account.username}: ${account.temporaryPassword}`);
|
||||
|
||||
@@ -124,7 +124,6 @@ const renderAddAccount = () => {
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_add_account_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('email'))}">`;
|
||||
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,13 +309,12 @@ const showCredential = ($el_window, username, temporaryPassword) => {
|
||||
|
||||
const addAccount = async ($el_window) => {
|
||||
const username = $el_window.find(`${SECTION} .teams-new-username`).val().trim();
|
||||
const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim();
|
||||
if ( ! username || ! email ) return;
|
||||
if ( ! username ) return;
|
||||
|
||||
const $button = $el_window.find(`${SECTION} .teams-add-btn`);
|
||||
$button.prop('disabled', true);
|
||||
try {
|
||||
const created = await puter.teams.createMember(state.selected.uid, { username, email });
|
||||
const created = await puter.teams.createMember(state.selected.uid, { username });
|
||||
await refresh($el_window);
|
||||
showCredential($el_window, created.username, created.temporaryPassword);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,6 +10,9 @@ import { req, requireSegment } from './lib/req.js';
|
||||
* The returned password is shown once and is not retrievable afterwards —
|
||||
* deliver it out of band. The member must change it at first sign-in.
|
||||
*
|
||||
* `email` is optional; these accounts sign in by username. Without one the
|
||||
* account is only recoverable through its team's admin.
|
||||
*
|
||||
* @this {import('./index.js').TeamsModule}
|
||||
* @param {string} uid
|
||||
* @param {import('./types.js').CreateMemberOptions} options
|
||||
@@ -20,12 +23,14 @@ export async function createMember (uid, options) {
|
||||
if ( typeof options?.username !== 'string' || options.username.trim() === '' ) {
|
||||
throw new PuterJSError('`username` is required', 'invalid_request');
|
||||
}
|
||||
if ( typeof options?.email !== 'string' || options.email.trim() === '' ) {
|
||||
throw new PuterJSError('`email` is required', 'invalid_request');
|
||||
if ( options?.email !== undefined && options.email !== null
|
||||
&& typeof options.email !== 'string' ) {
|
||||
throw new PuterJSError('`email` must be a string', 'invalid_request');
|
||||
}
|
||||
|
||||
const email = typeof options?.email === 'string' ? options.email.trim() : '';
|
||||
const result = /** @type {Record<string, unknown>} */ (await req(this.puter, 'POST', `/teams/${segment}/members`, {
|
||||
body: { username: options.username, email: options.email },
|
||||
body: { username: options.username, ...(email ? { email } : {}) },
|
||||
operation: 'createMember',
|
||||
}));
|
||||
return {
|
||||
|
||||
@@ -193,8 +193,27 @@ describe('members', () => {
|
||||
expect(call().body).toEqual({ username: 'bob', email: 'bob@example.com' });
|
||||
});
|
||||
|
||||
it('refuses a member without an email without making a request', async () => {
|
||||
it('provisions with no email, omitting the key rather than sending empty', async () => {
|
||||
routes({ 'POST /teams/t-1/members': { username: 'bob', temporary_password: 'hunter2' } });
|
||||
await expect(teams.createMember('t-1', { username: 'bob' }))
|
||||
.resolves.toEqual({ username: 'bob', temporaryPassword: 'hunter2' });
|
||||
expect(call().body).toEqual({ username: 'bob' });
|
||||
});
|
||||
|
||||
it('treats a blank email as absent', async () => {
|
||||
routes({ 'POST /teams/t-1/members': { username: 'bob', temporary_password: 'hunter2' } });
|
||||
await teams.createMember('t-1', { username: 'bob', email: ' ' });
|
||||
expect(call().body).toEqual({ username: 'bob' });
|
||||
});
|
||||
|
||||
it('refuses a non-string email without making a request', async () => {
|
||||
await expect(teams.createMember('t-1', { username: 'bob', email: 42 }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
expect(mockReq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still refuses a member without a username', async () => {
|
||||
await expect(teams.createMember('t-1', {}))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
expect(mockReq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
*
|
||||
* @typedef {Object} CreateMemberOptions
|
||||
* @property {string} username The username for the new account. Must be free across all of Puter.
|
||||
* @property {string} email The address the member is reachable at. It must not already own an account.
|
||||
* @property {string} [email] Optional. These accounts sign in by username, so an
|
||||
* address is not needed; supply one only to have the team's notices delivered.
|
||||
* If given it must not already own an account.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user