diff --git a/backend/__tests__/service/auth.test.js b/backend/__tests__/service/auth.test.js index 902930b81c..f764ab8efb 100644 --- a/backend/__tests__/service/auth.test.js +++ b/backend/__tests__/service/auth.test.js @@ -74,6 +74,19 @@ describe('Auth Routes Integration Tests', () => { expect(typeof user.verified).toBe('boolean'); }); + // TASK-290 A: the register page sends no username; the handle comes from + // the email, and the signup gets its workspace like any other. + it('registers without a username, deriving one from the email', async () => { + await request(app) + .post('/api/auth/register') + .send({ email: 'Ada.Lovelace@example.com', password: 'Password123!' }) + .expect(201); + + const user = await User.findOne({ email: 'ada.lovelace@example.com' }); + expect(user.username).toBe('ada-lovelace'); + expect(await Pod.countDocuments({ createdBy: user._id })).toBe(1); + }); + it('creates a default private workspace pod owned by the new user', async () => { const userData = { username: 'workspaceowner', @@ -184,18 +197,9 @@ describe('Auth Routes Integration Tests', () => { expect(response.body.error).toContain('Username already exists'); }); + // A missing username is no longer one of them: it is derived (TASK-290 A, + // the test above). it('should validate required fields', async () => { - // Missing username - const missingUsername = { - email: 'test@example.com', - password: 'Password123!', - }; - - await request(app) - .post('/api/auth/register') - .send(missingUsername) - .expect(400); - // Missing email const missingEmail = { username: 'testuser', diff --git a/backend/__tests__/unit/controllers/authController.derivedUsername.test.js b/backend/__tests__/unit/controllers/authController.derivedUsername.test.js new file mode 100644 index 0000000000..29cfd95eed --- /dev/null +++ b/backend/__tests__/unit/controllers/authController.derivedUsername.test.js @@ -0,0 +1,147 @@ +// TASK-290 A: the register page asks only for an email and a password, so a +// signup with no username gets one derived from the email. A client that sends +// a username keeps it. Real models on the in-memory Mongo, so "taken" and +// "reserved" are answered by the same rows and indexes production uses. +const User = require('../../../models/User'); +const { AgentRegistry } = require('../../../models/AgentRegistry'); + +jest.mock('../../../services/communityPodService', () => ({ + ensureUserInCommunityPod: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../../services/emailService', () => ({ + sendEmail: jest.fn().mockResolvedValue({ data: { succeeded: 1 } }), +})); +jest.mock('../../../models/Task', () => ({ create: jest.fn().mockResolvedValue([]) })); +jest.mock('../../../services/pgPodSyncService', () => ({ + syncPodFromMongo: jest.fn().mockResolvedValue({}), +})); + +const authController = require('../../../controllers/authController'); +const { usernameBaseFromEmail } = require('../../../services/derivedUsername'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); + +// What every @-mention reader accepts: agentMentionService needs 2+ of +// [a-z0-9_-], agentMessageService [a-z0-9][a-z0-9-]{0,39}, the v2 renderer +// [a-zA-Z0-9][a-zA-Z0-9_-]*. A derived handle has to sit inside all three. +const MENTION_SAFE = /^[a-z0-9][a-z0-9-]{1,39}$/; + +const register = async (body) => { + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await authController.register({ body: { password: 'Password123!', ...body } }, res); + return res; +}; + +const usernameOf = async (email) => (await User.findOne({ email }).lean())?.username; + +describe('register with no username (TASK-290 A)', () => { + beforeAll(async () => { + await setupMongoDb(); + // The unique username index has to exist for the race below to collide. + // Not User.init(): the model's collation index on the same key clashes + // with it by name and rejects. + await User.collection.createIndex({ username: 1 }, { unique: true }); + }); + afterAll(async () => { await closeMongoDb(); }); + afterEach(async () => { + await clearMongoDb(); + jest.restoreAllMocks(); + }); + + it('derives the handle from the email local part, plus-tag dropped', async () => { + const res = await register({ email: 'Sam.Lee+news@Example.com' }); + + expect(res.status).toHaveBeenCalledWith(201); + expect(await usernameOf('sam.lee+news@example.com')).toBe('sam-lee'); + }); + + it('keeps a username the client sends, exactly as before', async () => { + const res = await register({ username: 'Typed_Name', email: 'sam.lee@example.com' }); + + expect(res.status).toHaveBeenCalledWith(201); + expect(await usernameOf('sam.lee@example.com')).toBe('Typed_Name'); + }); + + it('adds a short suffix when another account holds the name in any case', async () => { + await User.create({ username: 'Sam-Lee', email: 'first@example.com', password: 'x-password' }); + + const res = await register({ email: 'sam.lee@example.com' }); + + expect(res.status).toHaveBeenCalledWith(201); + expect(await usernameOf('sam.lee@example.com')).toMatch(/^sam-lee-[0-9a-f]{4}$/); + }); + + it('never derives a name reserved for an agent install (TASK-133 b)', async () => { + await AgentRegistry.create({ + agentName: 'pixel-helper', + displayName: 'Pixel Helper', + description: 'a pod helper', + manifest: { name: 'pixel-helper', version: '1.0.0' }, + }); + + const typeName = await register({ email: 'claude-code@example.com' }); + const claimed = await register({ email: 'pixel.helper@example.com' }); + + expect(typeName.status).toHaveBeenCalledWith(201); + expect(claimed.status).toHaveBeenCalledWith(201); + expect(await usernameOf('claude-code@example.com')).toMatch(/^claude-code-[0-9a-f]{4}$/); + expect(await usernameOf('pixel.helper@example.com')).toMatch(/^pixel-helper-[0-9a-f]{4}$/); + }); + + it.each([ + ['one character', 'a@example.com', /^a-[0-9a-f]{4}$/], + ['no letters or digits it can keep', '李雷@example.com', /^user-[0-9a-f]{4}$/], + ])('suffixes a local part with %s', async (_label, email, expected) => { + const res = await register({ email }); + + expect(res.status).toHaveBeenCalledWith(201); + expect(await usernameOf(email)).toMatch(expected); + }); + + it('derives a handle every mention reader accepts', () => { + const cases = [ + 'sam.lee', 'sam_lee', '-dash-', 'a..b__c', 'UPPER.Case', '1st', 'x'.repeat(60), + `${'ab.'.repeat(14)}z`, // a separator lands on the cut + ]; + cases.forEach((local) => { + const base = usernameBaseFromEmail(`${local}@example.com`); + expect(base.length).toBeLessThanOrEqual(30); + // No hyphen at either end or doubled, so the suffix reads "sam-lee-1a2b". + expect(base).not.toMatch(/^-|-$|--/); + expect(`${base}-ffff`).toMatch(MENTION_SAFE); + if (base.length >= 2) expect(base).toMatch(MENTION_SAFE); + }); + }); + + it('derives again when another signup takes the name between the check and the save', async () => { + const originalSave = User.prototype.save; + let raced = false; + jest.spyOn(User.prototype, 'save').mockImplementation(async function save(...args) { + if (!raced && this.username === 'racer') { + raced = true; + await User.create({ username: 'racer', email: 'other@example.com', password: 'x-password' }); + } + return originalSave.apply(this, args); + }); + + const res = await register({ email: 'racer@example.com' }); + + expect(res.status).toHaveBeenCalledWith(201); + expect(await usernameOf('racer@example.com')).toMatch(/^racer-[0-9a-f]{4}$/); + }); + + it('still answers an address that is already registered', async () => { + await User.create({ username: 'someone', email: 'taken@example.com', password: 'x-password' }); + + const res = await register({ email: 'taken@example.com' }); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'User already exists' }); + }); + + it('requires only an email and a password', async () => { + const res = await register({ email: '', password: '' }); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Email and password are required.' }); + }); +}); diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index 39417a2e39..38b2dc27a8 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -9,6 +9,7 @@ const { sendEmail } = require('../services/emailService'); const { ensureUserInCommunityPod } = require('../services/communityPodService'); const { normalizeAvatarUrl } = require('../services/avatarService'); const { cleanSignupRef } = require('../utils/signupRef'); +const { deriveUsernameFromEmail } = require('../services/derivedUsername'); // The first-touch ref a signup arrived with (?ref=x), stored once on the new // account. Anything that is not a short plain code is dropped, not refused. @@ -395,13 +396,21 @@ exports.register = async (req: any, res: any) => { } = req.body; const normalizedEmail = normalizeEmail(email); - const normalizedUsername = String(username || '').trim(); + const typedUsername = String(username || '').trim(); const rawPassword = String(password || ''); - if (!normalizedUsername || !normalizedEmail || !rawPassword) { - return res.status(400).json({ error: 'Username, email, and password are required.' }); + if (!normalizedEmail || !rawPassword) { + return res.status(400).json({ error: 'Email and password are required.' }); } + // The register page asks only for an email and a password (TASK-290 A), so + // the handle comes from the email. A client that sends a username keeps it, + // with every check below as before. + const usernameDerived = !typedUsername; + const normalizedUsername = usernameDerived + ? await deriveUsernameFromEmail(normalizedEmail) + : typedUsername; + // Reserved for agents (TASK-133 b). A row that takes an agent's derived name // or its address IS the row an install would adopt, and registration is the // only moment it can be defended: once it exists, the install's choice is @@ -492,7 +501,15 @@ exports.register = async (req: any, res: any) => { }); // Save user to database - await user.save(); + try { + await user.save(); + } catch (saveErr: any) { + // A derived name another signup took in the meantime: derive again + // rather than tell someone who typed no username that theirs is taken. + if (!usernameDerived || saveErr?.code !== 11000 || !saveErr.keyPattern?.username) throw saveErr; + user.username = await deriveUsernameFromEmail(normalizedEmail); + await user.save(); + } await createDefaultWorkspacePod(user._id); diff --git a/backend/services/derivedUsername.ts b/backend/services/derivedUsername.ts new file mode 100644 index 0000000000..fa78c80eff --- /dev/null +++ b/backend/services/derivedUsername.ts @@ -0,0 +1,57 @@ +// A username for a signup that didn't type one (TASK-290 A): the register page +// asks only for an email and a password, and the handle comes from the email. +// +// The handle has to work wherever a person is @-mentioned, so it keeps to what +// every mention reader accepts: a letter or digit, then letters, digits and +// hyphens, at least 2 long (agentMentionService needs 2, agentMessageService +// stops at 40). A dot or an underscore would cut the mention short. +// +// Free means: no other account holds the name in any case ("Sam" holds "sam"), +// and it isn't reserved for an agent install (TASK-133 b), the check register +// runs on a typed name. The case-insensitive match is done by the query's +// collation, not by an index: the unique index on username is case-sensitive, +// and the User model's collation index on the same key has never built beside +// it (same auto-generated name; production has only the unique one, checked +// 2026-10-17). The Google path names its own accounts (oauthController +// generateUsername) and is not changed here. +const crypto = require('crypto'); +const User = require('../models/User'); +const AgentIdentityService = require('./agentIdentityService'); + +// The account-label comparison userController uses. +const ACCOUNT_LABEL_COLLATION = { locale: 'en', strength: 2 }; +// Leaves room for the "-xxxx" suffix under the 40-character mention limit. +const MAX_BASE_LENGTH = 30; + +/** The email's local part as a handle: plus-tag dropped, runs of anything else made one hyphen. */ +const usernameBaseFromEmail = (email: unknown): string => String(email || '') + .split('@')[0] + .split('+')[0] + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+/, '') + .slice(0, MAX_BASE_LENGTH) + .replace(/-+$/, ''); + +const isFreeUsername = async (candidate: string): Promise => { + if (await AgentIdentityService.resolveAccountNameConflict({ username: candidate })) return false; + return !await User.exists({ username: candidate }).collation(ACCOUNT_LABEL_COLLATION); +}; + +/** A free username from the email: its local part, or that plus a short suffix when taken or too short. */ +const deriveUsernameFromEmail = async (email: unknown): Promise => { + const base = usernameBaseFromEmail(email); + if (base.length >= 2 && await isFreeUsername(base)) return base; + const stem = base || 'user'; + for (let i = 0; i < 5; i += 1) { + const candidate = `${stem}-${crypto.randomBytes(2).toString('hex')}`; + // eslint-disable-next-line no-await-in-loop + if (await isFreeUsername(candidate)) return candidate; + } + // 30 + 1 + 8 stays under 40. + return `${stem}-${crypto.randomBytes(4).toString('hex')}`; +}; + +module.exports = { usernameBaseFromEmail, deriveUsernameFromEmail }; + +export {}; diff --git a/docs/development/BACKEND.md b/docs/development/BACKEND.md index 8135564272..9eaa711065 100644 --- a/docs/development/BACKEND.md +++ b/docs/development/BACKEND.md @@ -66,7 +66,7 @@ backend/ | Method | Endpoint | Description | Request Body | Response | |--------|------------------------|-----------------------------|--------------------------------------|---------------------------------| -| POST | /api/auth/register | Register a new user | `{username, email, password, invitationCode?}` | Registration status message | +| POST | /api/auth/register | Register a new user; without a `username`, one is derived from the email | `{email, password, username?, invitationCode?}` | Registration status message | | GET | /api/auth/registration-policy | Public registration mode | - | `{inviteOnly, invitationRequired, hasInvitationCodes, registrationOpen}` | | POST | /api/auth/login | Login user | `{email, password}` | User object with token | | GET | /api/auth/user | Get current user | - | User object | diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 3540a350e7..5395b8b351 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -5,16 +5,16 @@ const uniqueTag = () => `e2e${Date.now()}`; test.describe('Authentication', () => { test('register form renders correctly', async ({ page }) => { await page.goto('/v2/register'); - await expect(page.getByLabel('Username')).toBeVisible(); await expect(page.getByLabel('Email')).toBeVisible(); await expect(page.getByLabel('Password')).toBeVisible(); await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible(); + // TASK-290 A: no username field; the server derives one from the email. + await expect(page.getByLabel('Username')).toHaveCount(0); }); test('register new user enters the starter workspace', async ({ page }) => { const tag = uniqueTag(); await page.goto('/v2/register'); - await page.getByLabel('Username').fill(`user_${tag}`); await page.getByLabel('Email').fill(`${tag}@commonly.test`); await page.getByLabel('Password').fill('TestPass123!'); await page.getByRole('button', { name: 'Create account' }).click(); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0ed63fecb4..e47d007612 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -201,7 +201,6 @@ }, "auth": { "fields": { - "username": "Username", "email": "Email", "password": "Password" }, diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 623e7e9b7e..cdefc9aee7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -201,7 +201,6 @@ }, "auth": { "fields": { - "username": "用户名", "email": "邮箱", "password": "密码" }, diff --git a/frontend/src/v2/__tests__/V2Register.test.tsx b/frontend/src/v2/__tests__/V2Register.test.tsx index 150a03cf93..359e2a8aae 100644 --- a/frontend/src/v2/__tests__/V2Register.test.tsx +++ b/frontend/src/v2/__tests__/V2Register.test.tsx @@ -77,7 +77,6 @@ describe('V2Register', () => { const { login } = renderRegister(); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy')); - fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } }); fireEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -86,9 +85,23 @@ describe('V2Register', () => { expect(screen.getByTestId('location')).toHaveTextContent('/v2'); }); + // TASK-290 A: the page asks for an email and a password only; the server + // derives the handle from the email. + it('asks for no username and sends none', async () => { + renderRegister(); + + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy')); + expect(screen.queryByLabelText(/username/i)).toBeNull(); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } }); + fireEvent.click(screen.getByRole('button', { name: /create account/i })); + + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/auth/register', expect.anything())); + expect(axios.post.mock.calls[0][1]).not.toHaveProperty('username'); + }); + const createAccount = async () => { await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy')); - fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } }); fireEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -133,7 +146,6 @@ describe('V2Register', () => { const { login } = renderRegister(jest.fn().mockRejectedValue(new Error('login unavailable'))); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy')); - fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } }); fireEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -158,7 +170,6 @@ describe('V2Register', () => { renderRegister(jest.fn().mockRejectedValue(new Error('login unavailable'))); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy')); - fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } }); fireEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -178,7 +189,6 @@ describe('V2Register', () => { renderRegister(jest.fn().mockRejectedValue(new Error('login unavailable'))); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy')); - fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } }); fireEvent.click(screen.getByRole('button', { name: /create account/i })); diff --git a/frontend/src/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx index 896e1d884d..187a8cd30e 100644 --- a/frontend/src/v2/components/V2Register.tsx +++ b/frontend/src/v2/components/V2Register.tsx @@ -32,7 +32,6 @@ const V2Register: React.FC = () => { const { t } = useTranslation(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [invitationCode, setInvitationCode] = useState(searchParams.get('invite') || ''); @@ -67,8 +66,8 @@ const V2Register: React.FC = () => { setError(null); setSubmitting(true); try { + // No username: the server derives the handle from the email (TASK-290 A). const res = await axios.post('/api/auth/register', { - username: username.trim(), email: email.trim(), password, invitationCode: invitationCode.trim(), @@ -151,18 +150,6 @@ const V2Register: React.FC = () => { {t('auth.register.subtitle')}

- -