diff --git a/docker/prod/README.md b/docker/prod/README.md index d033c3d3..e6f93865 100644 --- a/docker/prod/README.md +++ b/docker/prod/README.md @@ -93,8 +93,9 @@ limit. Members are unlimited by default. The workspace limit defaults to `1`, which runs the image as a single workspace: -- The first boot creates a workspace named Oxynote and its admin. Sign in as - `admin@example.com` with the password `oxynote-admin-1234`, then change +- The first boot creates a workspace and its admin. The workspace gets the + name of a place in space, such as Sea of Clouds, picked at random. Sign in + as `admin@example.com` with the password `oxynote-admin-1234`, then change the email address, the password and the workspace name in the settings. The admin's address receives no email, so a new address is confirmed by a link sent to the new address only. diff --git a/e2e/helpers/single-workspace.ts b/e2e/helpers/single-workspace.ts index e403b8a0..529eb034 100644 --- a/e2e/helpers/single-workspace.ts +++ b/e2e/helpers/single-workspace.ts @@ -16,13 +16,12 @@ import { visit } from "./page" // Changing the port means changing that service. export const SINGLE_WORKSPACE_URL = "http://localhost:19082" -// the admin and workspace the instance creates at its first boot. The -// operator README documents these values. +// the admin the instance creates at its first boot. The operator README +// documents these values. export const DEFAULT_ADMIN: Credentials = { email: "admin@example.com", password: "oxynote-admin-1234", } -export const DEFAULT_WORKSPACE_NAME = "Oxynote" // authPost calls one of better-auth's endpoints on the instance as the // context's user. better-auth refuses a request without a trusted origin, @@ -38,6 +37,26 @@ export function authPost( }) } +// defaultWorkspaceName reads the name of the instance's one workspace as +// the context's user. The instance picks the name at its first boot, so a +// test cannot know it up front. +export async function defaultWorkspaceName( + request: APIRequestContext, +): Promise { + const response = await request.get( + `${SINGLE_WORKSPACE_URL}/auth-realtime/api/auth/organization/list`, + { headers: { Origin: SINGLE_WORKSPACE_URL } }, + ) + expect(response.ok()).toBe(true) + + const [workspace] = (await response.json()) as { name: string }[] + if (!workspace) { + throw new Error("the instance has no workspace") + } + + return workspace.name +} + // signedInContext opens a browser context already signed in with the given // credentials. It signs in over the API, since the login page itself is // what other tests drive. diff --git a/e2e/helpers/workspace.ts b/e2e/helpers/workspace.ts index ed790bab..3abb62c8 100644 --- a/e2e/helpers/workspace.ts +++ b/e2e/helpers/workspace.ts @@ -18,10 +18,11 @@ export interface Workspace { // newWorkspace mints a workspace nobody else uses. The slug is what the // server checks for uniqueness, so it carries the random part; the name -// only has to pass validation, which allows no spaces. +// only has to pass validation. It holds a space, which a link has to +// encode. export function newWorkspace(): Workspace { return { - name: "Acme-Corp", + name: "Acme Corp", slug: `ws-${randomUUID().slice(0, 8)}`, } } diff --git a/e2e/tests/onboarding.test.ts b/e2e/tests/onboarding.test.ts index f34f59f5..299e7b2c 100644 --- a/e2e/tests/onboarding.test.ts +++ b/e2e/tests/onboarding.test.ts @@ -29,12 +29,12 @@ test.describe("onboarding", () => { ).toBeVisible() }) - test("rejects a workspace name with spaces", async ({ page, request }) => { + test("rejects a workspace name with a slash", async ({ page, request }) => { const credentials = await signUpAndVerify(page, request) await submitLoginForm(page, credentials) await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }) - await submitWorkspaceForm(page, { ...newWorkspace(), name: "Acme Corp" }) + await submitWorkspaceForm(page, { ...newWorkspace(), name: "Acme/Corp" }) await expect( page.getByText(t("onboarding.welcome.errors.workspace-name-regex")), diff --git a/e2e/tests/prod-single-workspace.test.ts b/e2e/tests/prod-single-workspace.test.ts index aac5844f..70d79fe7 100644 --- a/e2e/tests/prod-single-workspace.test.ts +++ b/e2e/tests/prod-single-workspace.test.ts @@ -12,7 +12,7 @@ import { visit } from "../helpers/page" import { authPost, DEFAULT_ADMIN, - DEFAULT_WORKSPACE_NAME, + defaultWorkspaceName, joinThroughInvitation, signedInContext, SINGLE_WORKSPACE_URL, @@ -48,7 +48,9 @@ test.describe("a single-workspace instance", () => { await expect(page).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 15_000 }) await expect( - page.getByRole("button", { name: DEFAULT_WORKSPACE_NAME }), + page.getByRole("button", { + name: await defaultWorkspaceName(page.request), + }), ).toBeVisible() }) diff --git a/server/auth-realtime/AGENTS.md b/server/auth-realtime/AGENTS.md index 956431f4..b36aa85c 100644 --- a/server/auth-realtime/AGENTS.md +++ b/server/auth-realtime/AGENTS.md @@ -31,7 +31,9 @@ opens the pool, connects to Valkey (only when no secondary storage) and listens. Everything else is a factory taking what it needs. Modules: `env.ts` (zod config), `core.ts` (every call into core), `db.ts` (the `Store`), `bootstrap.ts` (the single-organization mode's -organization and admin, created before listening), `reporting.ts`, +organization and admin, created before listening), +`organization-names.ts` (the names that organization can start with), +`reporting.ts`, `logging.ts`, `headers.ts`, `auth.ts` (`createAuth` + better-auth callbacks), `hocuspocus.ts` (`createHocuspocus`, `createDocumentHooks`, `flushDocument`), `routes.ts` (`createRoutes` → Hono app), `operations.ts` diff --git a/server/auth-realtime/src/auth.test.ts b/server/auth-realtime/src/auth.test.ts index 51256c0b..470339e3 100644 --- a/server/auth-realtime/src/auth.test.ts +++ b/server/auth-realtime/src/auth.test.ts @@ -253,7 +253,20 @@ describe("invitationLink", () => { }) expect(link).toBe( - "http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1", + "http://localhost:8080/accept-invite?id=inv-1&email=a%40b.c&inviter=Ada&orgName=Acme&orgId=org-1", + ) + }) + + it("encodes names that hold spaces", ({ expect }) => { + const link = invitationLink(testEnv(), { + id: "inv-1", + email: "a@b.c", + inviter: { user: { name: "Ada Lovelace" } }, + organization: { id: "org-1", name: "Sea of Clouds" }, + }) + + expect(link).toBe( + "http://localhost:8080/accept-invite?id=inv-1&email=a%40b.c&inviter=Ada+Lovelace&orgName=Sea+of+Clouds&orgId=org-1", ) }) }) @@ -400,7 +413,7 @@ describe("createOrganizationHooks", () => { { email: "a@b.c", organization: "Acme", - link: "http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1", + link: "http://localhost:8080/accept-invite?id=inv-1&email=a%40b.c&inviter=Ada&orgName=Acme&orgId=org-1", }, ) }) diff --git a/server/auth-realtime/src/auth.ts b/server/auth-realtime/src/auth.ts index 93ad25a5..94b9f667 100644 --- a/server/auth-realtime/src/auth.ts +++ b/server/auth-realtime/src/auth.ts @@ -151,16 +151,18 @@ export interface InvitationData { // the invite link the emailed button points at. Everything the // accept-invite page needs to render before the user has a session travels -// in the query string. +// in the query string. The values are encoded, because a name can hold +// spaces. export function invitationLink(env: Env, data: InvitationData): string { - return ( - env.organizationInvitationUrl + - `?id=${data.id}` + - `&email=${data.email}` + - `&inviter=${data.inviter.user.name}` + - `&orgName=${data.organization.name}` + - `&orgId=${data.organization.id}` - ) + const query = new URLSearchParams({ + id: data.id, + email: data.email, + inviter: data.inviter.user.name, + orgName: data.organization.name, + orgId: data.organization.id, + }) + + return `${env.organizationInvitationUrl}?${query.toString()}` } // bind an MCP token to the user's organization at issuance, the same way diff --git a/server/auth-realtime/src/bootstrap.test.ts b/server/auth-realtime/src/bootstrap.test.ts index cbcc50e6..87913124 100644 --- a/server/auth-realtime/src/bootstrap.test.ts +++ b/server/auth-realtime/src/bootstrap.test.ts @@ -42,6 +42,7 @@ function deps( accounts, hashPassword: vi.fn().mockResolvedValue("hashed"), createOrganization: vi.fn().mockResolvedValue({}), + random: vi.fn().mockReturnValue(0), } } @@ -71,11 +72,14 @@ describe("bootstrapSingleOrganization", () => { password: "hashed", }) expect(d.createOrganization).toHaveBeenCalledWith({ - name: "Oxynote", - slug: "oxynote", + name: "Sea of Tranquility", + slug: "sea-of-tranquility", userId: "admin-1", }) - expect.soft(d.log.warn).toHaveBeenCalledTimes(1) + expect.soft(d.random).toHaveBeenCalledTimes(1) + expect.soft(d.log.warn).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Sea of Tranquility"), + ) }) it("reuses an admin left over from a failed run", async ({ @@ -86,8 +90,8 @@ describe("bootstrapSingleOrganization", () => { await bootstrapSingleOrganization(d) expect(d.createOrganization).toHaveBeenCalledWith({ - name: "Oxynote", - slug: "oxynote", + name: "Sea of Tranquility", + slug: "sea-of-tranquility", userId: "admin-0", }) expect.soft(d.accounts.createUser).not.toHaveBeenCalled() @@ -111,6 +115,7 @@ describe("bootstrapSingleOrganization", () => { expect.soft(d.accounts.findUserByEmail).not.toHaveBeenCalled() expect.soft(d.createOrganization).not.toHaveBeenCalled() + expect.soft(d.random).not.toHaveBeenCalled() expect.soft(d.log.warn).not.toHaveBeenCalled() }) diff --git a/server/auth-realtime/src/bootstrap.ts b/server/auth-realtime/src/bootstrap.ts index 7781791f..68341db3 100644 --- a/server/auth-realtime/src/bootstrap.ts +++ b/server/auth-realtime/src/bootstrap.ts @@ -1,6 +1,7 @@ import type { Store } from "./db.js" import type { Env } from "./env.js" import type { Logger } from "./logging.js" +import { pickOrganization } from "./organization-names.js" // the first account of a single-organization deployment. No mail reaches // the address, because example.com is reserved (RFC 2606). The password @@ -11,11 +12,6 @@ export const bootstrapAdmin = { name: "Admin", } -const bootstrapOrganization = { - name: "Oxynote", - slug: "oxynote", -} - // the parts of better-auth's internal adapter that the bootstrap uses. export interface AccountAdapter { findUserByEmail(email: string): Promise<{ user: { id: string } } | null> @@ -44,6 +40,8 @@ export interface BootstrapDeps { slug: string userId: string }) => Promise + // picks the organization's name. Math.random outside tests. + random: () => number } // bootstrapSingleOrganization creates the organization and its admin when a @@ -57,6 +55,7 @@ export async function bootstrapSingleOrganization({ accounts, hashPassword, createOrganization, + random, }: BootstrapDeps): Promise { if (env.maxOrganizations !== 1) { return @@ -91,13 +90,15 @@ export async function bootstrapSingleOrganization({ userId = user.id } + const organization = pickOrganization(random) + await createOrganization({ - ...bootstrapOrganization, + ...organization, userId, }) log.warn( - `created the ${bootstrapOrganization.name} organization. Sign in as ${bootstrapAdmin.email} with the default password and change both.`, + `created the ${organization.name} organization. Sign in as ${bootstrapAdmin.email} with the default password and change both.`, ) } diff --git a/server/auth-realtime/src/index.ts b/server/auth-realtime/src/index.ts index 667a339f..40e97b53 100644 --- a/server/auth-realtime/src/index.ts +++ b/server/auth-realtime/src/index.ts @@ -53,6 +53,7 @@ await bootstrapSingleOrganization({ hashPassword: (password) => authContext.password.hash(password), createOrganization: (organization) => auth.api.createOrganization({ body: organization }), + random: Math.random, }) const { diff --git a/server/auth-realtime/src/organization-names.test.ts b/server/auth-realtime/src/organization-names.test.ts new file mode 100644 index 00000000..149a5c5b --- /dev/null +++ b/server/auth-realtime/src/organization-names.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from "vitest" +import { organizationNames, pickOrganization } from "./organization-names.js" + +// what the web forms accept as a workspace name and as its slug. +const NAME_RULE = /^[a-zA-Z0-9-_ ]+$/ +const SLUG_RULE = /^[a-zA-Z0-9-_]+$/ +// the web app cuts a name it puts in a link at this length. +const MAX_NAME_LENGTH = 20 + +describe("organizationNames", () => { + it("holds only names the workspace name rule accepts", ({ expect }) => { + expect(organizationNames.length).toBeGreaterThan(200) + expect.soft( + organizationNames.filter( + (name) => + !NAME_RULE.test(name) || + name.length > MAX_NAME_LENGTH, + ), + ).toEqual([]) + }) +}) + +describe("pickOrganization", () => { + it.for([ + { + name: "the first name at zero", + input: 0, + expected: { + name: "Sea of Tranquility", + slug: "sea-of-tranquility", + }, + }, + { + name: "the last name just below one", + input: 0.999999, + expected: { + name: "Photon Sphere", + slug: "photon-sphere", + }, + }, + ])("picks $name", ({ input, expected }, { expect }) => { + expect(pickOrganization(() => input)).toEqual(expected) + }) + + it.for([ + { name: "one", input: 1 }, + { name: "a negative number", input: -0.5 }, + ])( + "throws when the random number is $name", + ({ input }, { expect }) => { + expect(() => pickOrganization(() => input)).toThrow( + "the random number is not between zero and one", + ) + }, + ) + + it("gives every name a form the workspace rules accept", ({ + expect, + }) => { + // the half step keeps a rounding error from landing on the name + // before + const organizations = organizationNames.map((_, index) => + pickOrganization( + () => (index + 0.5) / organizationNames.length, + ), + ) + + expect(organizations.map(({ name }) => name)).toEqual( + organizationNames, + ) + expect.soft( + organizations.filter( + ({ slug }) => !SLUG_RULE.test(slug), + ), + ).toEqual([]) + expect.soft( + new Set(organizations.map(({ slug }) => slug)).size, + ).toBe(organizationNames.length) + }) +}) diff --git a/server/auth-realtime/src/organization-names.ts b/server/auth-realtime/src/organization-names.ts new file mode 100644 index 00000000..2d9e6f03 --- /dev/null +++ b/server/auth-realtime/src/organization-names.ts @@ -0,0 +1,242 @@ +// names a single-organization deployment can start with. Each is the real +// name of a place in space. Each passes the workspace name rule of the web +// forms. None is longer than 20 characters, which is where the web app +// cuts a name it puts in a link. +export const organizationNames = [ + // the Moon's seas, lakes and bays, by the English form of their names + "Sea of Tranquility", + "Sea of Serenity", + "Sea of Clouds", + "Sea of Nectar", + "Sea of Islands", + "Sea of Waves", + "Lake of Dreams", + "Lake of Time", + "Lake of Spring", + "Lake of Summer", + "Lake of Autumn", + "Wintry Lake", + "Lake of Hope", + "Lake of Joy", + "Lake of Perseverance", + "Bay of Dew", + "Bay of Harmony", + "Bay of Trust", + // landing sites and landmarks on the Moon + "Tranquility Base", + "Hadley Base", + "Hadley Rille", + "Hadley Delta", + "Descartes Highlands", + "Fra Mauro Highlands", + "Marius Hills", + "Reiner Gamma", + "Alpine Valley", + "Serpentine Ridge", + "Lunar Alps", + "Cape Fresnel", + "Sculptured Hills", + "North Massif", + "South Massif", + "Family Mountain", + "Silver Spur", + "Apollo Basin", + "Lunar South Pole", + "Shackleton Crater", + "Tycho Crater", + "Copernicus Crater", + "Clavius Crater", + "Camelot Crater", + "North Ray Crater", + "South Ray Crater", + "Plum Crater", + "Cone Crater", + "Surveyor Crater", + "Babbage Crater", + "Boole Crater", + "Lovelace Crater", + "Hilbert Crater", + "Euler Crater", + "Gauss Crater", + "Hypatia Crater", + "Kepler Crater", + "Archimedes Crater", + "Pythagoras Crater", + // Mars + "Bradbury Landing", + "Green Valley", + "Eagle Crater", + "Endurance Crater", + "Victoria Crater", + "Endeavour Crater", + "Bonneville Crater", + "Gusev Crater", + "Gale Crater", + "Jezero Crater", + "Meridiani Planum", + "Oxia Planum", + "Planum Boreum", + "Planum Australe", + "Utopia Planitia", + "Elysium Planitia", + "Arcadia Planitia", + "Olympus Mons", + "Pavonis Mons", + "Elysium Mons", + "Tharsis Montes", + "Valles Marineris", + "Candor Chasma", + "Ophir Chasma", + "Ares Vallis", + "Kasei Valles", + "Syrtis Major", + "Lake of the Sun", + "Meridian Bay", + "Bay of the Dawn", + "Golden Plain", + "Labyrinth of Night", + "Mariner Valley", + "Mount Sharp", + "Yellowknife Bay", + "Glen Torridon", + "Bagnold Dunes", + "Vera Rubin Ridge", + "Marathon Valley", + "Perseverance Valley", + "Cape York", + "Solander Point", + "Greeley Haven", + "Murray Ridge", + "Bright Angel", + "Rock Garden", + "Mermaid Dune", + // other planets, moons, rings and asteroids + "Caloris Basin", + "Caloris Montes", + "Borealis Planitia", + "Maxwell Montes", + "Artemis Chasma", + "Tara Regio", + "Galileo Regio", + "Ontario Lacus", + "Tiger Stripes", + "Ithaca Chasma", + "Odysseus Crater", + "Herschel Crater", + "Cassini Regio", + "Cassini Division", + "Roche Division", + "Encke Gap", + "Keeler Gap", + "Huygens Gap", + "Maxwell Gap", + "Colombo Gap", + "Phoebe Ring", + "Gossamer Rings", + "Adams Ring", + "Verona Rupes", + "Tenzing Montes", + "Wright Mons", + "Piccard Mons", + "Voyager Terra", + "Pioneer Terra", + "Viking Terra", + "Hayabusa Terra", + "Lowell Regio", + "Burney Crater", + "Argo Chasma", + "Kubrick Mons", + "Butler Mons", + "Ahuna Mons", + "Hanami Planum", + "Muses Sea", + // orbits, belts and landmarks of the sky + "Low Earth Orbit", + "Geostationary Orbit", + "Halo Orbit", + "Lagrange Point", + "Van Allen Belt", + "Asteroid Belt", + "Kirkwood Gap", + "Kuiper Belt", + "Oort Cloud", + "Hill Sphere", + "Planet Nine", + "Goldilocks Zone", + "Habitable Zone", + "Pale Blue Dot", + "Blue Marble", + "Proxima Centauri", + "Tau Ceti", + "Solar Neighborhood", + "Gould Belt", + "Radcliffe Wave", + "Orion Arm", + "Orion Spur", + "Perseus Arm", + "Sagittarius Arm", + "Galactic Center", + "Fermi Bubbles", + "Milky Way", + "Great Attractor", + "Cosmic Web", + "Summer Triangle", + "Winter Hexagon", + "Big Dipper", + "Jewel Box", + "Cosmic Cliffs", + "Cosmic Reef", + "Mystic Mountain", + "Cygnus Loop", + "Orion Nebula", + "Carina Nebula", + "Lagoon Nebula", + "Waterfall Nebula", + "Eagle Nebula", + "Helix Nebula", + "Ring Nebula", + "Rosette Nebula", + "Veil Nebula", + "Crab Nebula", + "Horsehead Nebula", + "Trifid Nebula", + "Butterfly Nebula", + "Pelican Nebula", + "Flame Nebula", + "Owl Nebula", + "Bubble Nebula", + "Heart Nebula", + "Cone Nebula", + "Andromeda Galaxy", + "Whirlpool Galaxy", + "Pinwheel Galaxy", + "Sunflower Galaxy", + "Cartwheel Galaxy", + "Triangulum Galaxy", + "Fireworks Galaxy", + "Tadpole Galaxy", + "Antennae Galaxies", + "Hubble Deep Field", + "Sunburst Arc", + "Cosmic Horseshoe", + "Einstein Ring", + "Photon Ring", + "Photon Sphere", +] + +// pickOrganization picks the name and the slug of a new organization. The +// random number is at least zero and below one, as Math.random answers. +export function pickOrganization(random: () => number): { + name: string + slug: string +} { + const name = + organizationNames[ + Math.floor(random() * organizationNames.length) + ] + if (name === undefined) { + throw new Error("the random number is not between zero and one") + } + + return { name, slug: name.toLowerCase().replaceAll(" ", "-") } +} diff --git a/web/app/components/settings/WorkspaceSection.nuxt.test.ts b/web/app/components/settings/WorkspaceSection.nuxt.test.ts index 51b1f688..16b5f78c 100644 --- a/web/app/components/settings/WorkspaceSection.nuxt.test.ts +++ b/web/app/components/settings/WorkspaceSection.nuxt.test.ts @@ -153,6 +153,27 @@ describe("", { concurrent: false }, () => { }) }) + it("saves a name made of several words", async ({ expect }) => { + seedWorkspace() + const calls = mockAuthEndpoint("organization/update", () => ({ + id: "org-1", + })) + const { wrapper } = await mountSection() + + await nameInput(wrapper).setValue("Sea of Clouds") + await nameInput(wrapper).trigger("blur") + + // the save chains vee-validate's validation, the update request and + // a refetch, none of which the component signals the end of + await vi.waitFor(() => { + expect(calls).toHaveLength(1) + }, WAIT_FOR_OPTIONS) + expect(calls[0]?.body).toEqual({ + data: { name: "Sea of Clouds" }, + organizationId: "org-1", + }) + }) + it("saves nothing when the name is unchanged", async ({ expect }) => { seedWorkspace() const calls = mockAuthEndpoint("organization/update", () => ({ @@ -179,7 +200,9 @@ describe("", { concurrent: false }, () => { await nameInput(wrapper).trigger("blur") await vi.waitFor(() => { - expect(wrapper.text()).toContain("Only letters, numbers, hyphens") + expect(wrapper.text()).toContain( + t("settings.workspace.errors.name-regex"), + ) }, WAIT_FOR_OPTIONS) expect(calls).toHaveLength(0) }) diff --git a/web/app/components/settings/WorkspaceSection.vue b/web/app/components/settings/WorkspaceSection.vue index 2ea63b79..26e3984e 100644 --- a/web/app/components/settings/WorkspaceSection.vue +++ b/web/app/components/settings/WorkspaceSection.vue @@ -31,7 +31,7 @@ const formSchema = toTypedSchema( .trim() .min(2) .max(50) - .regex(/^[a-zA-Z0-9-_]+$/, { + .regex(/^[a-zA-Z0-9-_ ]+$/, { message: t("settings.workspace.errors.name-regex"), }) .default(fetchOrganization.state.value.data?.data?.name || ""), diff --git a/web/app/pages/onboarding.vue b/web/app/pages/onboarding.vue index ea2dacce..7a7651d9 100644 --- a/web/app/pages/onboarding.vue +++ b/web/app/pages/onboarding.vue @@ -25,7 +25,7 @@ const formSchema = toTypedSchema( .trim() .min(2) .max(50) - .regex(/^[a-zA-Z0-9-_]+$/, { + .regex(/^[a-zA-Z0-9-_ ]+$/, { message: t("onboarding.welcome.errors.workspace-name-regex"), }), workspaceSlug: z diff --git a/web/i18n/locales/en/onboarding.json b/web/i18n/locales/en/onboarding.json index 7de42ca0..85ee438c 100644 --- a/web/i18n/locales/en/onboarding.json +++ b/web/i18n/locales/en/onboarding.json @@ -160,7 +160,7 @@ "logout": "Log out" }, "errors": { - "workspace-name-regex": "Only letters, numbers, hyphens, and underscores can be used", + "workspace-name-regex": "Only letters, numbers, spaces, hyphens, and underscores can be used", "workspace-url-regex": "Only letters, numbers, hyphens, and underscores can be used", "workspace-url-taken": "This workspace URL is already taken", "workspace-url-invalid": "This workspace URL is invalid", diff --git a/web/i18n/locales/en/settings.json b/web/i18n/locales/en/settings.json index 601c0f1a..525b6a8c 100644 --- a/web/i18n/locales/en/settings.json +++ b/web/i18n/locales/en/settings.json @@ -450,7 +450,7 @@ } }, "errors": { - "name-regex": "Only letters, numbers, hyphens, and underscores can be used", + "name-regex": "Only letters, numbers, spaces, hyphens, and underscores can be used", "toasts": { "logo": { "title": "Workspace logo couldn't be changed",