From 0cbc6ad5ae805a088b5ee423c9f2f42638c11282 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 21 Aug 2026 07:05:01 +1000 Subject: [PATCH 1/3] Add isolated Connect environment targeting --- .env.example | 3 ++ apps/editor/README.md | 3 +- apps/editor/src/EnvironmentBadge.tsx | 24 ++++++++++++ apps/editor/src/environment-badge.css | 39 +++++++++++++++++++ apps/editor/src/main.tsx | 4 +- docker-compose.yml | 1 + package.json | 1 + scripts/deploy-editor-dev.mjs | 6 ++- scripts/deploy-editor-dev.test.mjs | 3 ++ scripts/dev-environment.mjs | 1 + scripts/isolated-desktop.mjs | 24 +++++++++--- scripts/lib/isolated-desktop.test.mjs | 20 ++++++++++ services/server/src/app.test.ts | 22 +++++++++++ services/server/src/app.ts | 2 + services/server/src/features/system/routes.ts | 5 +++ services/server/src/index.ts | 1 + services/server/src/runtime-config.test.ts | 16 ++++++++ services/server/src/runtime-config.ts | 9 +++++ 18 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 apps/editor/src/EnvironmentBadge.tsx create mode 100644 apps/editor/src/environment-badge.css diff --git a/.env.example b/.env.example index d048138d..25a575e9 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,9 @@ MDBASE_CONNECT_TRUST_PROXY=0 # Local agent only: fixed browser loopback port. The SDK default is 28485. # MDBASE_CONNECT_LOOPBACK_PORT=28485 +# Non-secret deployment identity published by /health. Managed deployments set +# production, staging, or lab; the local compose environment sets local. +# MDBASE_CONNECT_ENVIRONMENT=local # Optional Web Push. Generate one VAPID keypair and configure all three values # together. The subject must be a mailto: or HTTPS URI. diff --git a/apps/editor/README.md b/apps/editor/README.md index 84b85866..3fc503d8 100644 --- a/apps/editor/README.md +++ b/apps/editor/README.md @@ -83,7 +83,8 @@ for CI, sign in with Wrangler once, then run: ```sh pnpm dlx wrangler@4.114.0 login -pnpm deploy:dev +pnpm deploy:dev # lab (experimental default) +MDBASE_ENV=staging pnpm deploy:dev # staging release rehearsal ``` The command builds workspace packages, generates the editor for the staging diff --git a/apps/editor/src/EnvironmentBadge.tsx b/apps/editor/src/EnvironmentBadge.tsx new file mode 100644 index 00000000..789d2b1c --- /dev/null +++ b/apps/editor/src/EnvironmentBadge.tsx @@ -0,0 +1,24 @@ +import type { JSX } from "react"; + +export type VisibleEnvironment = "lab" | "staging" | "local"; + +const visibleEnvironments = new Set(["lab", "staging", "local"]); + +export function visibleEnvironment(value: string | undefined): VisibleEnvironment | null { + const normalized = value?.trim().toLowerCase() as VisibleEnvironment | undefined; + return normalized && visibleEnvironments.has(normalized) ? normalized : null; +} + +export function EnvironmentBadge(): JSX.Element | null { + const environment = visibleEnvironment(import.meta.env.VITE_MDBASE_ENV); + if (!environment) return null; + return ( +
+ {environment} +
+ ); +} diff --git a/apps/editor/src/environment-badge.css b/apps/editor/src/environment-badge.css new file mode 100644 index 00000000..1d2f82b9 --- /dev/null +++ b/apps/editor/src/environment-badge.css @@ -0,0 +1,39 @@ +.environment-badge { + position: fixed; + z-index: 10000; + top: 5px; + right: 5px; + padding: 4px 7px 3px; + border: 1px solid currentColor; + background: #f4f7f7; + box-shadow: 2px 2px 0 rgb(20 34 35 / 14%); + color: #34585d; + font: 500 9px/1.2 "Azeret Mono", ui-monospace, monospace; + letter-spacing: 0.14em; + pointer-events: none; + text-transform: uppercase; +} + +.environment-badge.is-lab { + background: #fff2c7; + color: #7b4d00; +} + +.environment-badge.is-staging { + background: #ffebe8; + color: #8d3029; +} + +.environment-badge.is-local { + background: #e5f5f8; + color: #176377; +} + +@media (max-width: 640px) { + .environment-badge { + top: 3px; + right: 3px; + padding: 3px 5px 2px; + font-size: 8px; + } +} diff --git a/apps/editor/src/main.tsx b/apps/editor/src/main.tsx index 47142962..ed93639b 100644 --- a/apps/editor/src/main.tsx +++ b/apps/editor/src/main.tsx @@ -6,9 +6,11 @@ import { createRoot } from "react-dom/client"; import { AppErrorBoundary } from "./AppErrorBoundary"; import { DemoCollectionGateway } from "./demo-gateway"; import { ConnectCollectionGateway } from "./gateway"; +import { EnvironmentBadge } from "./EnvironmentBadge"; import "@mdbase/connect-ui/motion.css"; import "./phosphor-icons.generated.css"; import "./styles.css"; +import "./environment-badge.css"; const EditorApp = lazy(() => import("./App").then((module) => ({ default: module.App }))); const ConnectWorkspace = lazy(() => import("./ConnectApp").then((module) => ({ default: module.ConnectApp }))); @@ -25,5 +27,5 @@ const gateway = demoCount > 0 : new ConnectCollectionGateway(); createRoot(document.getElementById("root")!).render( - Opening mdbase…}>{connectWorkspace ? : } + Opening mdbase…}>{connectWorkspace ? : } ); diff --git a/docker-compose.yml b/docker-compose.yml index a19d7934..3fa5c6b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,7 @@ services: HOST: 0.0.0.0 PORT: 8787 PUBLIC_URL: ${PUBLIC_URL:-http://127.0.0.1:8787} + MDBASE_CONNECT_ENVIRONMENT: ${MDBASE_CONNECT_ENVIRONMENT:-unspecified} DATABASE_URL: postgres://${POSTGRES_USER:-mdbase}:${POSTGRES_PASSWORD:-replace-this-password}@postgres:5432/${POSTGRES_DB:-mdbase_connect} MDBASE_CONNECT_DEV_AUTH: ${MDBASE_CONNECT_DEV_AUTH:-1} MDBASE_CONNECT_MANAGEMENT_ORIGINS: ${MDBASE_CONNECT_MANAGEMENT_ORIGINS:-http://127.0.0.1:5173} diff --git a/package.json b/package.json index 6ab72dba..a52a0aa6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dev:desktop:isolated": "node scripts/isolated-desktop.mjs", "dev:desktop:fresh": "node scripts/isolated-desktop.mjs --fresh", "dev:desktop:staging": "node scripts/isolated-desktop.mjs --staging", + "dev:desktop:environment": "node scripts/isolated-desktop.mjs", "deploy:dev": "node scripts/deploy-editor-dev.mjs", "e2e": "node test/system/run.mjs --suite local", "e2e:container": "node test/system/run.mjs --suite container", diff --git a/scripts/deploy-editor-dev.mjs b/scripts/deploy-editor-dev.mjs index 4dc29e91..d2363b98 100644 --- a/scripts/deploy-editor-dev.mjs +++ b/scripts/deploy-editor-dev.mjs @@ -29,18 +29,20 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { } export async function deployDevelopmentEditor(environment, run = runCommand) { - const target = environment.MDBASE_ENV ?? "lab"; + const target = environment.MDBASE_ENV?.trim() || "lab"; if (target !== "lab" && target !== "staging") { throw new Error("Development editor deployments are restricted to lab and staging."); } const deployment = developmentDeployments[target]; - if (environment.MDBASE_CONNECT_URL && environment.MDBASE_CONNECT_URL !== deployment.connectOrigin) { + const requestedOrigin = environment.MDBASE_CONNECT_URL?.trim(); + if (requestedOrigin && requestedOrigin !== deployment.connectOrigin) { throw new Error(`MDBASE_CONNECT_URL does not match the ${target} environment.`); } const previousManifest = await readFile(manifestPath); const deploymentEnvironment = { ...environment, MDBASE_ENV: target, + VITE_MDBASE_ENV: target, MDBASE_EDITOR_ORIGIN: deployment.editorOrigin, MDBASE_EDITOR_BASE_PATH: "/", MDBASE_CONNECT_URL: deployment.connectOrigin, diff --git a/scripts/deploy-editor-dev.test.mjs b/scripts/deploy-editor-dev.test.mjs index 51f1ea26..0a53d8e9 100644 --- a/scripts/deploy-editor-dev.test.mjs +++ b/scripts/deploy-editor-dev.test.mjs @@ -9,6 +9,8 @@ test("builds and deploys the editor against lab by default", async () => { }); const build = calls.find(({ args }) => args.includes("mdbase-editor") && args.includes("build")); + assert.equal(build.environment.MDBASE_ENV, "lab"); + assert.equal(build.environment.VITE_MDBASE_ENV, "lab"); assert.equal(build.environment.MDBASE_EDITOR_ORIGIN, developmentDeployments.lab.editorOrigin); assert.equal(build.environment.MDBASE_CONNECT_URL, developmentDeployments.lab.connectOrigin); assert.equal(build.environment.VITE_MDBASE_CONNECT_URL, developmentDeployments.lab.connectOrigin); @@ -33,6 +35,7 @@ test("staging requires an explicit environment and production is rejected", asyn const deploy = calls.find(({ args }) => args.includes("wrangler@4.114.0")); assert.ok(deploy.args.includes("--branch=staging")); assert.equal(deploy.environment.MDBASE_CONNECT_URL, developmentDeployments.staging.connectOrigin); + assert.equal(deploy.environment.VITE_MDBASE_ENV, "staging"); await assert.rejects( deployDevelopmentEditor({ MDBASE_ENV: "production" }, async () => undefined), diff --git a/scripts/dev-environment.mjs b/scripts/dev-environment.mjs index 22c94b44..8691d346 100644 --- a/scripts/dev-environment.mjs +++ b/scripts/dev-environment.mjs @@ -25,6 +25,7 @@ const environment = await createConnectEnvironment({ allowLocalApps: true, environment: { PUBLIC_URL: origins.publicUrl, + MDBASE_CONNECT_ENVIRONMENT: "local", MDBASE_CONNECT_MANAGEMENT_ORIGINS: origins.managementOrigins.join(","), MDBASE_EDITOR_ORIGIN: origins.editorOrigin, MDBASE_CONNECT_REGISTRATION: "invite", diff --git a/scripts/isolated-desktop.mjs b/scripts/isolated-desktop.mjs index 5d4f6c11..775d3d26 100644 --- a/scripts/isolated-desktop.mjs +++ b/scripts/isolated-desktop.mjs @@ -25,9 +25,11 @@ export async function isolatedDesktopConfiguration( allocatePort = availablePort ) { const staging = arguments_.includes("--staging"); + const namedEnvironment = environment.MDBASE_ENV?.trim() + || (staging ? "staging" : "development"); const profileDirectory = staging ? stagingDesktop.profileDirectory - : "desktop-development-profile"; + : `desktop-${namedEnvironment}-profile`; const userData = resolve( environment.MDBASE_CONNECT_DEV_USER_DATA ?? resolve(repoRoot, ".tmp", profileDirectory) @@ -39,20 +41,27 @@ export async function isolatedDesktopConfiguration( ?? (staging ? stagingDesktop.loopbackPort : String(await allocatePort())); const childEnvironment = { ...environment, + VITE_MDBASE_ENV: environment.VITE_MDBASE_ENV ?? namedEnvironment, MDBASE_CONNECT_HOME: connectHome, MDBASE_CONNECT_USER_DATA_DIR: userData, MDBASE_CONNECT_LOOPBACK_PORT: loopbackPort, MDBASE_CONNECT_REGISTER_DEEP_LINKS: environment.MDBASE_CONNECT_REGISTER_DEEP_LINKS ?? "0" }; - if (staging) { + const configuredServer = environment.MDBASE_CONNECT_URL?.trim() + || environment.MDBASE_CONNECT_SERVER_URL?.trim(); + const configuredEditor = environment.MDBASE_EDITOR_URL?.trim(); + if (staging || configuredServer || configuredEditor) { childEnvironment.MDBASE_EDITOR_URL = - environment.MDBASE_EDITOR_URL ?? stagingDesktop.editorUrl; + configuredEditor ?? stagingDesktop.editorUrl; childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL = - environment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL ?? stagingDesktop.serverUrl; + environment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL + ?? configuredServer + ?? stagingDesktop.serverUrl; } return { staging, + namedEnvironment, fresh: arguments_.includes("--fresh"), userData, connectHome, @@ -79,8 +88,11 @@ export async function runIsolatedDesktop( console.log(`Isolated Electron profile: ${configuration.userData}`); console.log(`Isolated connector state: ${configuration.connectHome}`); console.log(`Connector loopback port: ${configuration.loopbackPort}`); - if (configuration.staging) { - console.log(`Staging Connect service: ${stagingDesktop.serverUrl}`); + if (configuration.childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL) { + console.log( + `${configuration.namedEnvironment} Connect service: ` + + configuration.childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL + ); } console.log("The normal mdbase connect profile and credentials will not be read."); diff --git a/scripts/lib/isolated-desktop.test.mjs b/scripts/lib/isolated-desktop.test.mjs index f135d2fa..c5d69da9 100644 --- a/scripts/lib/isolated-desktop.test.mjs +++ b/scripts/lib/isolated-desktop.test.mjs @@ -65,3 +65,23 @@ test("explicit development overrides remain available without reading production assert.equal(configuration.childEnvironment.MDBASE_CONNECT_HOME, connectHome); assert.equal(configuration.childEnvironment.MDBASE_CONNECT_REGISTER_DEEP_LINKS, "1"); }); + +test("registry-provided environments configure Electron without staging-only flags", async () => { + const configuration = await isolatedDesktopConfiguration({ + MDBASE_ENV: "lab", + MDBASE_CONNECT_URL: "https://mdbase-connect-lab.onrender.com", + MDBASE_EDITOR_URL: "https://candidate-b.mdbase-editor.pages.dev", + MDBASE_CONNECT_LOOPBACK_PORT: "28487" + }, [], async () => 1); + + assert.equal(configuration.namedEnvironment, "lab"); + assert.equal(configuration.loopbackPort, "28487"); + assert.equal( + configuration.childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL, + "https://mdbase-connect-lab.onrender.com" + ); + assert.equal( + configuration.childEnvironment.MDBASE_EDITOR_URL, + "https://candidate-b.mdbase-editor.pages.dev" + ); +}); diff --git a/services/server/src/app.test.ts b/services/server/src/app.test.ts index 8b469f73..00474c09 100644 --- a/services/server/src/app.test.ts +++ b/services/server/src/app.test.ts @@ -116,6 +116,28 @@ describe("mdbase connect server", () => { }); }); + it("publishes a verifiable non-secret environment identity", async () => { + const db = await createDatabase("memory"); + resources.push(() => db.end()); + const { app } = await buildApp({ + db, + devAuth: true, + publicUrl: "https://connect-lab.example", + environment: "lab" + }); + resources.push(() => app.close()); + + const health = await app.inject({ method: "GET", url: "/health" }); + + expect(health.statusCode).toBe(200); + expect(health.json()).toMatchObject({ + ok: true, + service: "mdbase-connect", + environment: "lab", + public_origin: "https://connect-lab.example" + }); + }); + it("stays ready when the hosted provider reports retryable notification degradation", async () => { const db = await createDatabase("memory"); resources.push(() => db.end()); diff --git a/services/server/src/app.ts b/services/server/src/app.ts index 7e52526e..4149034e 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -63,6 +63,7 @@ import { sessionToken } from "./platform/session-cookies.js"; interface BuildOptions { db: DatabasePool; revision?: string; + environment?: string; devAuth?: boolean; tailscaleAuth?: boolean; githubAuth?: GitHubAuthConfig; @@ -252,6 +253,7 @@ export async function buildApp(options: BuildOptions) { hostedCollections: options.hostedCollections === true, hostedProvider: options.hostedProvider, revision: options.revision, + environment: options.environment, publicUrl, editorOrigin: options.editorOrigin }); diff --git a/services/server/src/features/system/routes.ts b/services/server/src/features/system/routes.ts index f58fa980..b704fb22 100644 --- a/services/server/src/features/system/routes.ts +++ b/services/server/src/features/system/routes.ts @@ -9,6 +9,7 @@ export interface SystemRoutesOptions { hostedCollections: boolean; hostedProvider?: Pick; revision?: string; + environment?: string; publicUrl: string; editorOrigin?: string; } @@ -18,11 +19,15 @@ export function registerSystemRoutes( options: SystemRoutesOptions ): void { const revision = options.revision?.trim() || undefined; + const environment = options.environment?.trim() || undefined; app.get("/health", async () => ({ ok: true, service: "mdbase-connect", protocol_version: 1, + ...(environment + ? { environment, public_origin: new URL(options.publicUrl).origin } + : {}), ...(revision ? { revision } : {}) })); diff --git a/services/server/src/index.ts b/services/server/src/index.ts index 85a3c636..ac98791a 100644 --- a/services/server/src/index.ts +++ b/services/server/src/index.ts @@ -24,6 +24,7 @@ const { app } = await buildApp({ db, revision: process.env.RENDER_GIT_COMMIT, publicUrl: runtime.publicUrl, + environment: runtime.environment, portalDist, devAuth: runtime.devAuth, tailscaleAuth: runtime.tailscaleAuth, diff --git a/services/server/src/runtime-config.test.ts b/services/server/src/runtime-config.test.ts index b694e507..ec830a26 100644 --- a/services/server/src/runtime-config.test.ts +++ b/services/server/src/runtime-config.test.ts @@ -10,6 +10,7 @@ function config(overrides: Partial[0]> return { host: "127.0.0.1", publicUrl: "http://127.0.0.1:8787", + environment: "local", devAuth: false, tailscaleAuth: false, githubAuth: null, @@ -33,6 +34,21 @@ function config(overrides: Partial[0]> } describe("public runtime configuration", () => { + it("normalizes and validates the non-secret environment identity", () => { + expect(validateRuntimeConfig(config({ + environment: " lab ", + devAuth: true + })).environment).toBe("lab"); + expect(() => validateRuntimeConfig(config({ environment: "Production!" }))).toThrow( + /MDBASE_CONNECT_ENVIRONMENT/ + ); + expect(runtimeConfigFromEnv({ + PUBLIC_URL: "http://localhost:8787", + MDBASE_CONNECT_DEV_AUTH: "1", + MDBASE_CONNECT_ENVIRONMENT: "local" + }).environment).toBe("local"); + }); + it("allows explicit loopback development authentication", () => { expect(() => validateRuntimeConfig(config({ host: "0.0.0.0", diff --git a/services/server/src/runtime-config.ts b/services/server/src/runtime-config.ts index 8945b82e..cfffd194 100644 --- a/services/server/src/runtime-config.ts +++ b/services/server/src/runtime-config.ts @@ -20,6 +20,7 @@ export interface TransactionalEmailConfig { export interface RuntimeConfig { host: string; publicUrl: string; + environment: string; devAuth: boolean; tailscaleAuth: boolean; githubAuth: GitHubAuthConfig | null; @@ -45,6 +46,12 @@ export interface RuntimeConfig { export function validateRuntimeConfig(config: RuntimeConfig): RuntimeConfig { const publicUrl = new URL(config.publicUrl); + const environment = config.environment.trim(); + if (!/^[a-z][a-z0-9-]{0,31}$/.test(environment)) { + throw new Error( + "MDBASE_CONNECT_ENVIRONMENT must be a lowercase environment identifier." + ); + } const localPublicOrigin = isLoopback(publicUrl.hostname); if (publicUrl.username || publicUrl.password || publicUrl.pathname !== "/" || publicUrl.search || publicUrl.hash) { throw new Error("PUBLIC_URL must be an origin without credentials, a path, a query, or a fragment."); @@ -212,6 +219,7 @@ export function validateRuntimeConfig(config: RuntimeConfig): RuntimeConfig { return { ...config, publicUrl: publicUrl.origin, + environment, betaAccessOrigin, managementOrigins: [...new Set(managementOrigins)], editorOrigin, @@ -310,6 +318,7 @@ export function runtimeConfigFromEnv(env: NodeJS.ProcessEnv): RuntimeConfig { return validateRuntimeConfig({ host, publicUrl: env.PUBLIC_URL ?? `http://${host}:${port}`, + environment: env.MDBASE_CONNECT_ENVIRONMENT?.trim() || "unspecified", devAuth: env.MDBASE_CONNECT_DEV_AUTH === "1", tailscaleAuth: env.MDBASE_CONNECT_TAILSCALE_AUTH === "1", githubAuth: githubConfigured ? { clientId, clientSecret, allowedUserIds } : null, From ed4e463486d20491a0267c2219f7ef307bf7fb73 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 21 Aug 2026 07:05:47 +1000 Subject: [PATCH 2/3] Open verified public password signup --- .env.example | 6 +- README.md | 14 +- apps/portal/portal-model.test.mjs | 9 +- apps/portal/src/auth-view.tsx | 154 ++++++-- apps/portal/src/main.tsx | 5 +- apps/portal/src/portal-model.ts | 8 +- docs/account-authentication.md | 46 ++- docs/google-auth.md | 5 + docs/self-hosting.md | 12 +- services/server/src/app.ts | 7 +- services/server/src/entitlements.ts | 38 ++ .../src/features/auth/password-routes.ts | 229 +++++++++++- .../src/features/beta-access/routes.test.ts | 76 +--- .../server/src/features/beta-access/routes.ts | 100 +---- .../server/src/password-auth-routes.test.ts | 127 ++++++- services/server/src/password-auth.ts | 10 +- services/server/src/platform/error-handler.ts | 16 + .../server/src/public-signup-email.test.ts | 52 +++ services/server/src/public-signup-email.ts | 122 +++++++ services/server/src/public-signup.test.ts | 207 +++++++++++ services/server/src/public-signup.ts | 345 ++++++++++++++++++ services/server/src/runtime-config.test.ts | 7 +- services/server/src/runtime-config.ts | 5 - 23 files changed, 1345 insertions(+), 255 deletions(-) create mode 100644 services/server/src/public-signup-email.test.ts create mode 100644 services/server/src/public-signup-email.ts create mode 100644 services/server/src/public-signup.test.ts create mode 100644 services/server/src/public-signup.ts diff --git a/.env.example b/.env.example index 25a575e9..3a0f9ca1 100644 --- a/.env.example +++ b/.env.example @@ -25,10 +25,10 @@ MDBASE_CONNECT_REGISTRATION=closed # Password authentication can coexist with external providers or operate on its # own. This stable HMAC secret protects privacy-safe shared rate-limit keys. -# Configure both legal document URLs before enabling invited signup. +# Configure both legal document URLs before enabling invited or public signup. # MDBASE_CONNECT_AUTH_RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters -# Optional public beta-access form origin. Requests are accepted only from this -# exact browser origin and use the shared database-backed rate limiter. +# Optional legacy beta-access origin. The retired request endpoint returns 410 +# to this exact browser origin and never stores a new request. # MDBASE_CONNECT_BETA_ACCESS_ORIGIN=https://mdbase.dev # MDBASE_CONNECT_TERMS_URL=https://example.com/terms/ # MDBASE_CONNECT_PRIVACY_URL=https://example.com/privacy/ diff --git a/README.md b/README.md index 8a17e85f..4b0ebece 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # mdbase connect > [!IMPORTANT] -> **Invite-only beta:** The managed mdbase connect cloud service is currently -> available by invitation only. Access to `connect.mdbase.dev`, including -> hosted collections and the managed connection service, requires beta access. -> If you have been invited, follow the access instructions you received. +> **Beta:** The managed mdbase connect cloud service is transitioning from +> invite-only access to public signup. Existing invitation links remain valid. mdbase connect lets you use the applications you choose with the Markdown data you control. An application gets access to one collection only after you @@ -69,12 +67,10 @@ can be revoked with `mdbase connect hosted disconnect `. ## Getting started during the beta -There is no public signup for the managed cloud service while it is in -invite-only beta. +To use the managed cloud service, create an account from the Connect sign-in +page or follow an existing invitation link. Then: -If you have beta access: - -1. Follow your invitation to create or access your mdbase connect account. +1. Create or sign in to your mdbase connect account. 2. Install the desktop build provided for your platform. 3. Open the desktop app and pair your computer. 4. Add an existing mdbase collection or create a hosted collection. diff --git a/apps/portal/portal-model.test.mjs b/apps/portal/portal-model.test.mjs index acf9515b..c04e4b7d 100644 --- a/apps/portal/portal-model.test.mjs +++ b/apps/portal/portal-model.test.mjs @@ -6,7 +6,7 @@ test("captures one-time auth fragments before rendering and removes them from hi const replacements = []; const secrets = capturePortalBootstrapSecrets( { - hash: "#invitation=%20invite-secret%20&reset=reset-secret", + hash: "#invitation=%20invite-secret%20&verification=verify-secret&reset=reset-secret", pathname: "/signup", search: "?return_to=%2Fauthorize%2Frequest" }, @@ -20,6 +20,7 @@ test("captures one-time auth fragments before rendering and removes them from hi assert.deepEqual(secrets, { invitationToken: "invite-secret", + verificationToken: "verify-secret", resetToken: "reset-secret" }); assert.deepEqual(replacements, [{ @@ -37,7 +38,11 @@ test("does not rewrite unrelated fragments", () => { { state: null, replaceState() { replaced = true; } } ); - assert.deepEqual(secrets, { invitationToken: "", resetToken: "" }); + assert.deepEqual(secrets, { + invitationToken: "", + verificationToken: "", + resetToken: "" + }); assert.equal(replaced, false); }); diff --git a/apps/portal/src/auth-view.tsx b/apps/portal/src/auth-view.tsx index 4dc317be..45790ec9 100644 --- a/apps/portal/src/auth-view.tsx +++ b/apps/portal/src/auth-view.tsx @@ -97,12 +97,17 @@ export function Login() { } {config.registration !== "open" && (

- Don’t have an invite? Request beta access. + Don’t have an invite? Public signup is opening soon. {config.password_registration && ( <> Already invited? Use the one-time link in your invitation email to create your account with a password. After signing in, you can connect Google from your account settings and use it for future sign-ins. )}

)} + {config.registration === "open" && config.password_public_registration && ( +

+ New to mdbase Connect? Create an account. +

+ )} ); @@ -358,9 +363,17 @@ export function ResetPassword({ resetToken }: { resetToken: string }) { ); } -export function Signup({ invitationToken }: { invitationToken: string }) { +export function Signup({ + invitationToken, + verificationToken +}: { + invitationToken: string; + verificationToken: string; +}) { const [config, setConfig] = useState(null); - const [invitation, setInvitation] = useState(null); + const [verifiedEmail, setVerifiedEmail] = useState(""); + const [email, setEmail] = useState(""); + const [requestSubmitted, setRequestSubmitted] = useState(false); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState(""); @@ -377,19 +390,29 @@ export function Signup({ invitationToken }: { invitationToken: string }) { try { const authentication = await api("/v1/auth/config"); setConfig(authentication); - if ( - !invitationToken - || !authentication.password_registration - || !authentication.agreements - ) return; - const result = await api<{ invitation: InvitationPreview }>( - "/v1/auth/password/invitation", - { - method: "POST", - body: JSON.stringify({ invitation_token: invitationToken }) - } - ); - setInvitation(result.invitation); + if (!authentication.agreements) return; + if (invitationToken && authentication.password_invitation_registration) { + const result = await api<{ invitation: InvitationPreview }>( + "/v1/auth/password/invitation", + { + method: "POST", + body: JSON.stringify({ invitation_token: invitationToken }) + } + ); + setVerifiedEmail(result.invitation.email); + } else if ( + verificationToken + && authentication.password_public_registration + ) { + const result = await api<{ verification: VerificationPreview }>( + "/v1/auth/password/signup/verification", + { + method: "POST", + body: JSON.stringify({ verification_token: verificationToken }) + } + ); + setVerifiedEmail(result.verification.email); + } } catch (reason) { setError(message(reason)); } finally { @@ -397,11 +420,28 @@ export function Signup({ invitationToken }: { invitationToken: string }) { } } void prepare(); - }, [invitationToken]); + }, [invitationToken, verificationToken]); + + async function requestVerification(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + await api("/v1/auth/password/signup/request", { + method: "POST", + body: JSON.stringify({ email }) + }); + setRequestSubmitted(true); + } catch (reason) { + setError(message(reason)); + } finally { + setBusy(false); + } + } async function createAccount(event: React.FormEvent) { event.preventDefault(); - if (!config?.agreements || !invitation) return; + if (!config?.agreements || !verifiedEmail) return; if (password !== passwordConfirmation) { setError("Passwords do not match."); return; @@ -409,12 +449,17 @@ export function Signup({ invitationToken }: { invitationToken: string }) { setBusy(true); setError(""); try { + const publicSignup = Boolean(verificationToken && !invitationToken); const result = await api<{ onboarding?: { starter_collection?: "pending" } | null; - }>("/v1/auth/password/signup", { + }>(publicSignup + ? "/v1/auth/password/signup/public" + : "/v1/auth/password/signup", { method: "POST", body: JSON.stringify({ - invitation_token: invitationToken, + ...(publicSignup + ? { verification_token: verificationToken } + : { invitation_token: invitationToken }), name, password, terms_version: config.agreements.terms.version, @@ -433,24 +478,62 @@ export function Signup({ invitationToken }: { invitationToken: string }) { } if (loading || !config) return ; - const ready = Boolean( - invitation - && config.password_registration - && config.agreements + const isInvitation = Boolean(invitationToken); + const hasVerification = Boolean(verificationToken); + const ready = Boolean(verifiedEmail && config.agreements); + const canRequest = Boolean( + !isInvitation + && !hasVerification + && config.password_public_registration + ); + if (canRequest) return ( +
+ +
+

Create account

+

{requestSubmitted ? "Check your email." : "Create your account"}

+

+ {requestSubmitted + ? "If that address can be used, its one-time verification link is on the way." + : "Start with your email. We’ll verify the address before asking you to choose a password."} +

+ {error &&
{error}
} + {!requestSubmitted && ( +
void requestVerification(event)}> + + +
+ )} + Return to sign in +
+
); return (
-

Private preview / invitation

-

{ready ? "Create your account" : "This invitation can’t be opened"}

+

{isInvitation ? "Invitation" : "Email verified"}

+

{ready ? "Create your account" : "This account setup link can’t be opened"}

{ready - ? "Your email is already verified by this one-time invitation. Create a password, then we’ll open a small starter collection in the editor." - : invitationToken + ? `${isInvitation ? "Your invitation verified your email" : "Your email is verified"}. Choose a password and we’ll prepare a small starter collection.` + : isInvitation || hasVerification ? "The link is invalid, expired, already used, or account setup is temporarily unavailable." - : "Open the complete account setup link from your invitation email."}

+ : "Public account creation is temporarily unavailable."}

{error &&
{error}
} - {ready && invitation && config.agreements && ( + {ready && config.agreements && (
void createAccount(event)}>