diff --git a/.env.example b/.env.example index d048138d..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/ @@ -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/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/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/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..587bb384 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, return_to: returnTarget() }) + }); + 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)}>