mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add space-themed names to org name generator
This commit is contained in:
parent
3d81fdeafd
commit
eadca45942
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<unknown>
|
||||
// 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<void> {
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ await bootstrapSingleOrganization({
|
|||
hashPassword: (password) => authContext.password.hash(password),
|
||||
createOrganization: (organization) =>
|
||||
auth.api.createOrganization({ body: organization }),
|
||||
random: Math.random,
|
||||
})
|
||||
|
||||
const {
|
||||
|
|
|
|||
80
server/auth-realtime/src/organization-names.test.ts
Normal file
80
server/auth-realtime/src/organization-names.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
242
server/auth-realtime/src/organization-names.ts
Normal file
242
server/auth-realtime/src/organization-names.ts
Normal file
|
|
@ -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(" ", "-") }
|
||||
}
|
||||
|
|
@ -153,6 +153,27 @@ describe("<WorkspaceSection>", { 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("<WorkspaceSection>", { 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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 || ""),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user