diff --git a/.changeset/seamless-add-web-frameworks.md b/.changeset/seamless-add-web-frameworks.md new file mode 100644 index 0000000..716d404 --- /dev/null +++ b/.changeset/seamless-add-web-frameworks.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +`seamless add` now wires Angular, Vue and SvelteKit web apps on the `@seamless-auth/angular`, `@seamless-auth/vue` and `@seamless-auth/svelte` bindings, alongside React. It installs the binding (and `vue-router` or `@angular/router` when the app has no router), writes `VITE_API_URL` for Vue and SvelteKit, and prints the code for each file: the provider or plugin, the bundled sign-in screens as routes, and guards for signed-in and signed-out pages. Angular, which does not read `.env`, gets the backend URL in the printed config. Nuxt and Svelte without SvelteKit are reported rather than wired. diff --git a/README.md b/README.md index 95f23eb..928ebfb 100644 --- a/README.md +++ b/README.md @@ -288,18 +288,18 @@ seamless add --local --email=you@example.com # an auth server on this machine seamless add --app # or a managed application (needs seamless login) ``` -It finds a backend and a React web app, at the project root or in `api/`, `server/`, `backend/`, `web/`, `client/`, `frontend/`, `apps/*` or `packages/*`, then: +It finds a backend and a web app, at the project root or in `api/`, `server/`, `backend/`, `web/`, `client/`, `frontend/`, `apps/*` or `packages/*`, then: -The backend can be Express or Fastify, Go (net/http, Gin, chi or Echo), Rust (Axum), or Python (FastAPI or Django). +The backend can be Express or Fastify, Go (net/http, Gin, chi or Echo), Rust (Axum), or Python (FastAPI or Django). The web app can be React, Angular, Vue or SvelteKit. - connects an auth server. `--local` writes `seamless/docker-compose.yml` (the auth server and its Postgres) with its secrets in `seamless/.env`, which is gitignored. `--app` connects a managed application instead. -- writes the backend's `.env` (auth server URL, service token, signing key id, and a cookie secret if you have none) and the web app's API URL in `.env.local`. It keeps every value you already have and every comment. -- installs the adapter and the React SDK with your own tool: npm, pnpm, yarn or bun, `go get`, `cargo add`, `uv add` or `poetry add`. A plain pip project gets the `pip install` printed, since only you know which environment it belongs in. `--skip-install` prints every command instead. -- prints the lines to add to your server and to your React root, in your backend's language (TypeScript or JavaScript to match a Node project). It never edits your source files. +- writes the backend's `.env` (auth server URL, service token, signing key id, and a cookie secret if you have none) and the web app's API URL in `.env.local` (Angular, which does not read `.env`, gets it in the printed code). It keeps every value you already have and every comment. +- installs the adapter and the web binding (with `vue-router` or `@angular/router` when the app has no router yet) with your own tool: npm, pnpm, yarn or bun, `go get`, `cargo add`, `uv add` or `poetry add`. A plain pip project gets the `pip install` printed, since only you know which environment it belongs in. `--skip-install` prints every command instead. +- prints the lines to add to your server and to your web app, file by file: the provider or plugin, the bundled sign-in screens as routes, and guards for signed-in and signed-out pages. Each comes in the project's own language, TypeScript or JavaScript to match it. It never edits your source files. Then start the auth server (`docker compose -f seamless/docker-compose.yml up -d`), add the printed lines, and sign in. In development the one-time codes print in your backend's console. `seamless add --help` lists every option. -Angular, Vue and Svelte web apps are tracked in [#248](https://github.com/fells-code/seamless-cli/issues/248). +A Svelte app without SvelteKit needs a navigator for its own router, so it is reported rather than wired; see the [Svelte bindings](https://github.com/fells-code/seamless-auth-react/tree/main/packages/svelte#without-sveltekit). ## Running your project diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index 7467ad1..01d642e 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -305,3 +305,41 @@ describe("seamless add for a Go or Python backend", () => { expect(out).toContain("add it to requirements.txt"); }); }); + +describe("seamless add for an Angular, Vue or SvelteKit web app", () => { + it("writes no .env for Angular, serves it on 4200, and prints the URL into its config", async () => { + write("web/package.json", { dependencies: { "@angular/core": "^22", "@angular/router": "^22" } }); + write("web/src/app/app.config.ts", ""); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" }); + + expect(fs.existsSync(path.join(root, "web", ".env.local"))).toBe(false); + expect(parseEnv(path.join(root, "api", ".env")).UI_ORIGINS).toBe("http://localhost:4200"); + expect(vi.mocked(runCommand).mock.calls.at(-1)?.[1]).toEqual(["install", "@seamless-auth/angular"]); + const out = logs.join("\n"); + expect(out).toContain("Web: Angular in web"); + expect(out).toContain(`Provide Seamless Auth in your Angular app (${path.join("web", "src/app/app.config.ts")})`); + expect(out).toContain('provideSeamlessAuth({ apiHost: "http://localhost:4000" })'); + expect(out).toContain(`Add the sign-in screens and guard your routes (${path.join("web", "src/app/app.routes.ts")})`); + }); + + it("installs vue-router alongside the Vue binding when the app has none", async () => { + write("web/package.json", { dependencies: { vue: "^3.5" }, devDependencies: { vite: "^7" } }); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" }); + + expect(parseEnv(path.join(root, "web", ".env.local")).VITE_API_URL).toBe("http://localhost:4000/"); + expect(vi.mocked(runCommand).mock.calls.at(-1)?.[1]).toEqual(["install", "@seamless-auth/vue", "vue-router"]); + expect(logs.join("\n")).toContain("Create a router with the sign-in screens, and install Seamless Auth"); + }); + + it("wires SvelteKit through its own steps", async () => { + write("web/package.json", { devDependencies: { "@sveltejs/kit": "^3.0.0", svelte: "^5", vite: "^7" } }); + write("web/tsconfig.json", "{}"); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + + const out = logs.join("\n"); + expect(out).toContain("Web: SvelteKit in web"); + expect(out).toContain(`Create the session (${path.join("web", "src/lib/auth.ts")})`); + expect(out).toContain("Render in the browser and share the session\n"); + expect(out).toContain("cd web && npm install @seamless-auth/svelte"); + }); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts index ef46b96..d533151 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -31,10 +31,14 @@ import { envLoadingHint, frameworkName, type PackageInstall, - WEB_PACKAGES, - webApiUrlVariable, - webSnippet, } from "../core/snippets.js"; +import { + webApiUrlVariable, + webFrameworkName, + webPackages, + webStepFile, + webSteps, +} from "../core/webSnippets.js"; import { requireInteractive } from "../core/tty.js"; import { AUTH_STACK_DIR, generateAuthStack } from "../generators/docker/authStack.js"; import { LOCAL_AUTH_ISSUER } from "../generators/docker/docker.js"; @@ -167,8 +171,8 @@ export async function addSeamlessAuth(opts: AddOptions) { ); } - if (web) { - const variable = webApiUrlVariable(web); + const variable = web && webApiUrlVariable(web); + if (web && variable) { const envFile = path.join(web.dir, ".env.local"); const result = mergeEnvFile(path.join(root, envFile), { defaults: { [variable]: `${apiOrigin}/` }, @@ -196,6 +200,7 @@ export async function addSeamlessAuth(opts: AddOptions) { mode, backend, web, + apiOrigin, webOrigin, serveConsole, installs: installs.filter((i) => opts.install === false || !i.run).map((i) => i.display), @@ -217,13 +222,13 @@ function reportDetection(project: DetectedProject) { } if (web) { console.log( - ` Web: React in ${web.dir}` + kleur.dim(web.entry ? ` (${web.entry})` : ""), + ` Web: ${webFrameworkName(web.framework)} in ${web.dir}` + kleur.dim(web.entry ? ` (${web.entry})` : ""), ); } if (unsupported.length > 0) { console.log( kleur.dim( - ` Also found ${unsupported.join(", ")}, which seamless add does not wire yet (fells-code/seamless-cli#248).`, + ` Also found ${unsupported.join(", ")}, which seamless add does not wire yet.`, ), ); } @@ -356,7 +361,9 @@ function backendPort(root: string, backend: DetectedBackend): string { } function defaultWebOrigin(web: DetectedWeb | undefined): string { - return web?.bundler === "react-scripts" ? "http://localhost:3001" : "http://localhost:5173"; + if (web?.bundler === "react-scripts") return "http://localhost:3001"; + if (web?.framework === "angular") return "http://localhost:4200"; + return "http://localhost:5173"; } // A rough answer is enough here: it only decides whether to warn. @@ -394,7 +401,7 @@ function installCommands( }); const out = backendInstalls(backend).map((i) => located(backend.dir, i)); if (web) { - const { command, args } = addPackagesCommand(web.packageManager, WEB_PACKAGES); + const { command, args } = addPackagesCommand(web.packageManager, webPackages(web)); out.push(located(web.dir, { command, args, display: `${command} ${args.join(" ")}`, run: true })); } return out; @@ -404,6 +411,7 @@ function printNextSteps(ctx: { mode: "local" | "managed"; backend: DetectedBackend; web: DetectedWeb | undefined; + apiOrigin: string; webOrigin: string; serveConsole: boolean; installs: string[]; @@ -437,8 +445,11 @@ function printNextSteps(ctx: { console.log(indent(backendSnippet(backend, { webOrigin: ctx.webOrigin, serveConsole: ctx.serveConsole && mode === "local" }))); if (web) { - heading("Wrap your React app" + (web.entry ? ` (${path.join(web.dir, web.entry)})` : "")); - console.log(indent(webSnippet(web))); + for (const webStep of webSteps(web, { apiOrigin: ctx.apiOrigin })) { + const file = webStepFile(web, webStep); + heading(webStep.heading + (file ? ` (${file})` : "")); + console.log(indent(webStep.code)); + } } heading("Sign in"); diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index f0ea1fa..45a10a8 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -114,13 +114,13 @@ NON-INTERACTIVE body: `Add Seamless Auth to an existing project Finds a backend (Express, Fastify, Go net/http, Gin, chi, Echo, Axum, FastAPI or -Django) and a React web app (at the project root, or in api/, server/, backend/, -web/, client/, frontend/, apps/* or packages/*), then: +Django) and a web app (React, Angular, Vue or SvelteKit), at the project root or in +api/, server/, backend/, web/, client/, frontend/, apps/* or packages/*, then: • Connects an auth server: a local one in Docker, written to seamless/, or a managed application • Writes the backend's .env (auth server URL, service token, signing key id, a cookie secret), keeping every value you already have, and the web app's API - URL in .env.local + URL in .env.local (Angular gets it in the printed code) • Installs the adapter and SDK with the project's own tool (npm, pnpm, yarn, bun, go get, cargo add, uv or poetry; a pip install is printed instead) • Prints the lines of code to add. It never edits your source files @@ -140,7 +140,7 @@ web/, client/, frontend/, apps/* or packages/*), then: --api-url=, --web-url= • Your backend and web app origins, when they are not on the defaults (http://localhost: and - http://localhost:5173) + http://localhost:5173, or 4200 for Angular and 3001 for Create React App) --admin= • Whether your backend serves the admin dashboard at /console (default: api) diff --git a/src/core/detect.test.ts b/src/core/detect.test.ts index d942301..5ac79da 100644 --- a/src/core/detect.test.ts +++ b/src/core/detect.test.ts @@ -188,3 +188,67 @@ describe("detectProject, Go, Rust and Python", () => { expect(detectProject(root).backend).toBeUndefined(); }); }); + +describe("detectProject, Angular, Vue and SvelteKit", () => { + it("finds a standalone Angular app and its config", () => { + write("web/package.json", { dependencies: { "@angular/core": "^22", "@angular/router": "^22" } }); + write("web/src/app/app.config.ts", ""); + expect(detectProject(root).web).toMatchObject({ + framework: "angular", + bundler: "angular", + dir: "web", + typescript: true, + entry: "src/app/app.config.ts", + missingPeers: [], + }); + }); + + it("falls back to an NgModule app, and notes a missing router", () => { + write("client/package.json", { dependencies: { "@angular/core": "^20" } }); + write("client/src/app/app.module.ts", ""); + expect(detectProject(root).web).toMatchObject({ + entry: "src/app/app.module.ts", + missingPeers: ["@angular/router"], + }); + }); + + it("finds a Vue app and the file its router is created in", () => { + write("frontend/package.json", { dependencies: { vue: "^3.5", "vue-router": "^4" }, devDependencies: { vite: "^7" } }); + write("frontend/src/main.ts", ""); + write("frontend/src/router/index.ts", ""); + expect(detectProject(root).web).toMatchObject({ + framework: "vue", + bundler: "vite", + entry: "src/main.ts", + router: "src/router/index.ts", + missingPeers: [], + }); + }); + + it("notes a Vue app without vue-router, and leaves Nuxt alone", () => { + write("web/package.json", { dependencies: { vue: "^3.5" } }); + expect(detectProject(root).web).toMatchObject({ bundler: "other", missingPeers: ["vue-router"] }); + write("web/package.json", { dependencies: { vue: "^3.5", nuxt: "^4" } }); + expect(detectProject(root)).toEqual({ unsupported: ["Nuxt"] }); + }); + + it("finds SvelteKit, with the lib alias of its major version", () => { + write("web/package.json", { devDependencies: { "@sveltejs/kit": "^3.0.0", svelte: "^5" } }); + write("web/src/routes/+layout.svelte", ""); + expect(detectProject(root).web).toMatchObject({ + framework: "sveltekit", + bundler: "vite", + entry: "src/routes/+layout.svelte", + libAlias: "#lib", + }); + write("web/package.json", { devDependencies: { "@sveltejs/kit": "^2.26.0", svelte: "^5" } }); + expect(detectProject(root).web?.libAlias).toBe("$lib"); + write("web/package.json", { devDependencies: { "@sveltejs/kit": "latest", svelte: "^5" } }); + expect(detectProject(root).web?.libAlias).toBe("$lib"); + }); + + it("reports Svelte without SvelteKit, which needs a router of its own", () => { + write("web/package.json", { devDependencies: { svelte: "^5" } }); + expect(detectProject(root)).toEqual({ unsupported: ["Svelte without SvelteKit"] }); + }); +}); diff --git a/src/core/detect.ts b/src/core/detect.ts index 0a144b4..dda0f5d 100644 --- a/src/core/detect.ts +++ b/src/core/detect.ts @@ -28,12 +28,20 @@ export interface DetectedBackend { packageManager: BackendPackageManager; } +export type WebFramework = "react" | "angular" | "vue" | "sveltekit"; + export interface DetectedWeb { - framework: "react"; - bundler: "vite" | "react-scripts" | "other"; + framework: WebFramework; + bundler: "vite" | "react-scripts" | "angular" | "other"; dir: string; typescript: boolean; entry?: string; + // Vue: the file the router is created in, when it is not the entry. + router?: string; + // SvelteKit: how the app imports from src/lib. SvelteKit 3 renamed $lib to #lib. + libAlias?: "$lib" | "#lib"; + // Peer dependencies of the binding the app does not have yet. + missingPeers: string[]; packageManager: JsPackageManager; } @@ -168,8 +176,6 @@ export function detectProject(root: string): DetectedProject { for (const dir of candidateDirs(root)) { const rel = path.relative(root, dir) || "."; - if (fs.existsSync(path.join(dir, "angular.json"))) note("Angular"); - // Every folder is read, so a stack that is not wired yet is reported even // after a backend has been found; the first backend found is the one used. const native = detectNativeBackend(dir, rel, note); @@ -193,24 +199,68 @@ export function detectProject(root: string): DetectedProject { } if ("next" in deps) note("Next.js"); - if ("vue" in deps) note("Vue"); - if ("svelte" in deps) note("Svelte"); - if ("@angular/core" in deps) note("Angular"); + if ("nuxt" in deps) note("Nuxt"); + if ("svelte" in deps && !("@sveltejs/kit" in deps)) note("Svelte without SvelteKit"); - if (!result.web && "react" in deps && !("next" in deps) && !("react-native" in deps)) { - result.web = { - framework: "react", - bundler: "vite" in deps ? "vite" : "react-scripts" in deps ? "react-scripts" : "other", - dir: rel, - typescript, - entry: findEntry(dir, pkg, WEB_ENTRIES), - packageManager: detectJsPackageManager(dir, root), - }; - } + if (!result.web) result.web = detectWeb(dir, rel, root, pkg, typescript); } return result; } +function detectWeb( + dir: string, + rel: string, + root: string, + pkg: PackageJson, + typescript: boolean, +): DetectedWeb | undefined { + const deps = dependencies(pkg); + const base = { dir: rel, typescript, packageManager: detectJsPackageManager(dir, root) }; + const missing = (...names: string[]) => names.filter((name) => !(name in deps)); + + if ("@angular/core" in deps) { + return { + ...base, + framework: "angular", + bundler: "angular", + typescript: true, + entry: firstExisting(dir, ["src/app/app.config.ts", "src/app/app.module.ts", "src/main.ts"]), + missingPeers: missing("@angular/router"), + }; + } + if ("@sveltejs/kit" in deps) { + const kit = deps["@sveltejs/kit"].match(/\d+/)?.[0]; + return { + ...base, + framework: "sveltekit", + bundler: "vite", + entry: firstExisting(dir, ["src/routes/+layout.svelte"]), + libAlias: kit !== undefined && Number(kit) >= 3 ? "#lib" : "$lib", + missingPeers: [], + }; + } + if ("vue" in deps && !("nuxt" in deps)) { + return { + ...base, + framework: "vue", + bundler: "vite" in deps ? "vite" : "other", + entry: firstExisting(dir, ["src/main.ts", "src/main.js"]), + router: firstExisting(dir, ["src/router/index.ts", "src/router/index.js", "src/router.ts", "src/router.js"]), + missingPeers: missing("vue-router"), + }; + } + if ("react" in deps && !("next" in deps) && !("react-native" in deps)) { + return { + ...base, + framework: "react", + bundler: "vite" in deps ? "vite" : "react-scripts" in deps ? "react-scripts" : "other", + entry: findEntry(dir, pkg, WEB_ENTRIES), + missingPeers: [], + }; + } + return undefined; +} + function readText(file: string): string | undefined { try { return fs.readFileSync(file, "utf-8"); diff --git a/src/core/snippets.test.ts b/src/core/snippets.test.ts index e924e12..a8db34e 100644 --- a/src/core/snippets.test.ts +++ b/src/core/snippets.test.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { DetectedBackend, DetectedWeb } from "./detect.js"; +import type { DetectedBackend } from "./detect.js"; import { backendInstalls, backendPackages, backendSnippet, envLoadingHint, frameworkName, - webApiUrlVariable, - webSnippet, } from "./snippets.js"; const backend = (framework: "express" | "fastify", typescript: boolean): DetectedBackend => ({ @@ -49,23 +47,6 @@ describe("backendSnippet", () => { }); }); -describe("web", () => { - const web = (bundler: DetectedWeb["bundler"]): DetectedWeb => ({ - framework: "react", - bundler, - dir: "web", - typescript: true, - packageManager: "npm", - }); - - it("reads the API URL the way the bundler exposes it", () => { - expect(webApiUrlVariable(web("vite"))).toBe("VITE_API_URL"); - expect(webSnippet(web("vite"))).toContain("import.meta.env.VITE_API_URL"); - expect(webApiUrlVariable(web("react-scripts"))).toBe("REACT_APP_API_URL"); - expect(webSnippet(web("react-scripts"))).toContain("process.env.REACT_APP_API_URL"); - }); -}); - describe("backendPackages", () => { it("installs the adapter and what the snippet imports", () => { expect(backendPackages(backend("express", true))).toEqual({ diff --git a/src/core/snippets.ts b/src/core/snippets.ts index 2fd3ebd..ae4ca0f 100644 --- a/src/core/snippets.ts +++ b/src/core/snippets.ts @@ -1,4 +1,4 @@ -import { addPackagesCommand, type DetectedBackend, type DetectedWeb } from "./detect.js"; +import { addPackagesCommand, type DetectedBackend } from "./detect.js"; import { nativeBackendSnippet } from "./nativeSnippets.js"; // What `seamless add` prints for the adopter to paste. It never edits their files: @@ -121,28 +121,6 @@ const signedIn = requireAuth({ app.get("/api/me", { preHandler: signedIn }, async (req) => ({ id: req.user${ts ? "!" : ""}.id }));`; } -/** The environment variable a React build reads the backend URL from. */ -export function webApiUrlVariable(web: DetectedWeb): string { - return web.bundler === "react-scripts" ? "REACT_APP_API_URL" : "VITE_API_URL"; -} - -export function webSnippet(web: DetectedWeb): string { - const variable = webApiUrlVariable(web); - const read = - web.bundler === "react-scripts" - ? `process.env.${variable}` - : `import.meta.env.${variable}`; - return `import { AuthProvider, useAuth } from "@seamless-auth/react"; - -// Wrap your app once, at the root. It talks to your backend's /auth routes. - - - - -// Anywhere below it: -const { isAuthenticated, user, logout } = useAuth();`; -} - const FRAMEWORK_NAMES: Record = { express: "Express", fastify: "Fastify", @@ -224,5 +202,3 @@ export function backendPackages(backend: DetectedBackend): { deps: string[]; dev } return { deps: ["@seamless-auth/fastify", "@fastify/cors"], dev: [] }; } - -export const WEB_PACKAGES = ["@seamless-auth/react"]; diff --git a/src/core/webSnippets.test.ts b/src/core/webSnippets.test.ts new file mode 100644 index 0000000..c895fd0 --- /dev/null +++ b/src/core/webSnippets.test.ts @@ -0,0 +1,125 @@ +import path from "path"; +import { describe, expect, it } from "vitest"; + +import type { DetectedWeb } from "./detect.js"; +import { + webApiUrlVariable, + webFrameworkName, + webPackages, + webStepFile, + webSteps, +} from "./webSnippets.js"; + +const web = (overrides: Partial): DetectedWeb => ({ + framework: "react", + bundler: "vite", + dir: "web", + typescript: true, + missingPeers: [], + packageManager: "npm", + ...overrides, +}); +const api = { apiOrigin: "http://localhost:3000" }; +const code = (w: DetectedWeb) => webSteps(w, api).map((s) => s.code).join("\n"); + +describe("React", () => { + it("reads the API URL the way the bundler exposes it", () => { + expect(webApiUrlVariable(web({ bundler: "vite" }))).toBe("VITE_API_URL"); + expect(code(web({ bundler: "vite" }))).toContain("import.meta.env.VITE_API_URL"); + expect(webApiUrlVariable(web({ bundler: "react-scripts" }))).toBe("REACT_APP_API_URL"); + expect(code(web({ bundler: "react-scripts" }))).toContain("process.env.REACT_APP_API_URL"); + }); + + it("is one step at the app's entry", () => { + const steps = webSteps(web({ entry: "src/main.tsx" }), api); + expect(steps.map((s) => s.heading)).toEqual(["Wrap your React app"]); + expect(webStepFile(web({}), steps[0])).toBe(path.join("web", "src/main.tsx")); + expect(webPackages(web({}))).toEqual(["@seamless-auth/react"]); + }); +}); + +describe("Angular", () => { + const angular = web({ framework: "angular", bundler: "angular", entry: "src/app/app.config.ts" }); + + it("has no .env variable, so the backend URL goes in the code", () => { + expect(webApiUrlVariable(angular)).toBeUndefined(); + expect(code(angular)).toContain('provideSeamlessAuth({ apiHost: "http://localhost:3000" })'); + }); + + it("provides the binding and its interceptor, then guards the routes", () => { + const steps = webSteps(angular, api); + expect(steps.map((s) => s.file)).toEqual(["src/app/app.config.ts", "src/app/app.routes.ts"]); + expect(steps[0].code).toContain("provideHttpClient(withInterceptors([seamlessAuthInterceptor]))"); + expect(steps[1].code).toContain('from "@seamless-auth/angular/routes"'); + expect(steps[1].code).toContain("canActivate: [guestGuard]"); + expect(steps[1].code).toContain("canActivate: [authGuard]"); + }); + + it("installs the binding and the router when the app has none", () => { + expect(webPackages({ ...angular, missingPeers: ["@angular/router"] })).toEqual([ + "@seamless-auth/angular", + "@angular/router", + ]); + }); +}); + +describe("Vue", () => { + const vue = web({ framework: "vue", entry: "src/main.ts", router: "src/router/index.ts" }); + + it("adds routes in the router file and the plugin in the entry", () => { + const steps = webSteps(vue, api); + expect(steps.map((s) => s.file)).toEqual(["src/router/index.ts", "src/main.ts"]); + expect(steps[0].code).toContain("...createSeamlessAuthRoutes().map("); + expect(steps[0].code).not.toContain("createRouter({"); + expect(steps[1].code).toContain("app.use(createSeamlessAuth({ apiHost: import.meta.env.VITE_API_URL }));"); + expect(webApiUrlVariable(vue)).toBe("VITE_API_URL"); + }); + + it("uses the entry for the routes when the router is created there", () => { + expect(webSteps({ ...vue, router: undefined }, api)[0].file).toBe("src/main.ts"); + }); + + it("prints a whole router, and installs vue-router, for an app without one", () => { + const bare = { ...vue, router: undefined, missingPeers: ["vue-router"] }; + const steps = webSteps(bare, api); + expect(steps).toHaveLength(1); + expect(steps[0].code).toContain('import { createRouter, createWebHistory } from "vue-router";'); + expect(steps[0].code).toContain(".use(createSeamlessAuth({ apiHost: import.meta.env.VITE_API_URL }))"); + expect(steps[0].code).toContain(""); + expect(webPackages(bare)).toEqual(["@seamless-auth/vue", "vue-router"]); + }); +}); + +describe("SvelteKit", () => { + const kit = web({ framework: "sveltekit", libAlias: "#lib" }); + + it("imports the session the way the Kit version maps src/lib", () => { + expect(code(kit)).toContain('import { auth } from "#lib/auth.js";'); + expect(code({ ...kit, libAlias: "$lib" })).toContain('import { auth } from "$lib/auth";'); + }); + + it("creates the session, renders in the browser, and mounts each screen", () => { + const steps = webSteps(kit, api); + expect(steps.map((s) => s.file)).toEqual(["src/lib/auth.ts", undefined, undefined]); + expect(steps[0].code).toContain("createSeamlessAuth({ apiHost: import.meta.env.VITE_API_URL })"); + expect(steps[1].code).toContain("export const ssr = false;"); + expect(steps[1].code).toContain("setAuthNavigator(createKitNavigator(auth));"); + expect(steps[2].code).toContain(""); + expect(steps[2].code).toContain('verify-email-otp/+page.svelte '); + expect(steps[2].code).toContain("export const load = requireAuth(auth);"); + expect(steps[2].code).toContain("export const load = requireGuest(auth);"); + expect(webPackages(kit)).toEqual(["@seamless-auth/svelte"]); + }); + + it("prints JavaScript for a JavaScript app", () => { + const js = { ...kit, typescript: false }; + expect(webSteps(js, api)[0].file).toBe("src/lib/auth.js"); + expect(code(js)).not.toContain('lang="ts"'); + expect(code(js)).toContain("+page.js or +layout.js"); + }); +}); + +it("names each framework", () => { + expect(webFrameworkName("sveltekit")).toBe("SvelteKit"); + expect(webFrameworkName("vue")).toBe("Vue"); +}); diff --git a/src/core/webSnippets.ts b/src/core/webSnippets.ts new file mode 100644 index 0000000..6e98c82 --- /dev/null +++ b/src/core/webSnippets.ts @@ -0,0 +1,260 @@ +import path from "path"; + +import type { DetectedWeb, WebFramework } from "./detect.js"; + +// What `seamless add` prints for the web app, one step per file to change. Each +// follows the binding's README and the verify app built on it (verify/*-app). + +export interface WebStep { + heading: string; + // The file the step changes, relative to the web app's folder, when known. + file?: string; + code: string; +} + +const NAMES: Record = { + react: "React", + angular: "Angular", + vue: "Vue", + sveltekit: "SvelteKit", +}; + +export function webFrameworkName(framework: WebFramework): string { + return NAMES[framework]; +} + +/** + * The environment variable the web build reads the backend URL from, or none + * for Angular, whose CLI does not read .env files. + */ +export function webApiUrlVariable(web: DetectedWeb): string | undefined { + if (web.framework === "angular") return undefined; + return web.bundler === "react-scripts" ? "REACT_APP_API_URL" : "VITE_API_URL"; +} + +/** The binding, and any of its peer dependencies the app does not have yet. */ +export function webPackages(web: DetectedWeb): string[] { + const binding = web.framework === "sveltekit" ? "svelte" : web.framework; + return [`@seamless-auth/${binding}`, ...web.missingPeers]; +} + +const ENTRY_SCREENS_COMMENT = + "// The screens that start a sign-in are for signed-out visitors only. The others\n// finish one, so the session can already exist when they render."; + +function reactSteps(web: DetectedWeb): WebStep[] { + const variable = webApiUrlVariable(web); + const read = web.bundler === "react-scripts" ? `process.env.${variable}` : `import.meta.env.${variable}`; + return [ + { + heading: "Wrap your React app", + file: web.entry, + code: `import { AuthProvider, useAuth } from "@seamless-auth/react"; + +// Wrap your app once, at the root. It talks to your backend's /auth routes. + + + + +// Anywhere below it: +const { isAuthenticated, user, logout } = useAuth();`, + }, + ]; +} + +function angularSteps(web: DetectedWeb, apiOrigin: string): WebStep[] { + return [ + { + heading: "Provide Seamless Auth in your Angular app", + file: web.entry, + code: `import { provideHttpClient, withInterceptors } from "@angular/common/http"; +import { provideSeamlessAuth, seamlessAuthInterceptor } from "@seamless-auth/angular"; + +// In your application's providers. Angular does not read .env, so the backend's +// URL is set here: read it from your own configuration for other environments. +provideSeamlessAuth({ apiHost: "${apiOrigin}" }), +// Sends the session cookies with HttpClient calls to your backend, and nowhere else. +// If you already call provideHttpClient, add the interceptor to that call. +provideHttpClient(withInterceptors([seamlessAuthInterceptor])),`, + }, + { + heading: "Add the sign-in screens and guard your routes", + file: "src/app/app.routes.ts", + code: `import { authGuard, guestGuard } from "@seamless-auth/angular"; +import { authRoutePaths, seamlessAuthRoutes } from "@seamless-auth/angular/routes"; + +${ENTRY_SCREENS_COMMENT} +const entryScreens = new Set([ + authRoutePaths.login, + authRoutePaths.passkeyLogin, + authRoutePaths.magicLinkSent, +]); + +export const routes: Routes = [ + // Your routes, with canActivate: [authGuard] on the ones that need a signed-in user: + { path: "", component: Home, canActivate: [authGuard] }, + ...seamlessAuthRoutes.map((route) => + entryScreens.has(route.path ?? "") ? { ...route, canActivate: [guestGuard] } : route, + ), +]; + +// In a component: const auth = inject(SeamlessAuth); then auth.user() and auth.logout().`, + }, + ]; +} + +function vueSteps(web: DetectedWeb): WebStep[] { + const imports = `import { authGuard, createSeamlessAuthRoutes, guestGuard } from "@seamless-auth/vue/router";`; + const entryScreens = `${ENTRY_SCREENS_COMMENT} +const entryScreens = new Set(["/login", "/passkey-login", "/magic-link-sent"]);`; + const routes = ` // Your routes, with beforeEnter: authGuard on the ones that need a signed-in user: + { path: "/", component: HomePage, beforeEnter: authGuard }, + ...createSeamlessAuthRoutes().map((route) => + entryScreens.has(route.path) ? { ...route, beforeEnter: guestGuard } : route, + ),`; + const plugin = `.use(createSeamlessAuth({ apiHost: import.meta.env.VITE_API_URL }))`; + + // An app without vue-router gets one now (it is a peer of the binding), so + // print the whole router rather than the lines to add to one. + if (web.missingPeers.includes("vue-router")) { + return [ + { + heading: "Create a router with the sign-in screens, and install Seamless Auth", + file: web.entry, + code: `import { createRouter, createWebHistory } from "vue-router"; +import { createSeamlessAuth } from "@seamless-auth/vue"; +${imports} + +${entryScreens} + +const router = createRouter({ + history: createWebHistory(), + routes: [ +${routes.replace(/^/gm, " ")} + ], +}); + +createApp(App) + .use(router) + ${plugin} + .mount("#app"); + +// And render the pages: put in App.vue's template. +// In a component: const auth = useSeamlessAuth(); then auth.user.value and auth.logout().`, + }, + ]; + } + return [ + { + heading: "Add the sign-in screens and guard your routes", + file: web.router ?? web.entry, + code: `${imports} + +${entryScreens} + +// In the routes you pass to createRouter: +routes: [ +${routes} +],`, + }, + { + heading: "Install Seamless Auth after your router", + file: web.entry, + code: `import { createSeamlessAuth } from "@seamless-auth/vue"; + +// After the app uses your router, and before it mounts: +app.use(createSeamlessAuth({ apiHost: import.meta.env.VITE_API_URL })); + +// In a component: const auth = useSeamlessAuth(); then auth.user.value and auth.logout().`, + }, + ]; +} + +const SVELTE_SCREENS: [string, string][] = [ + ["passkey-login", ""], + ["verify-email-otp", ''], + ["verify-phone-otp", ''], + ["magic-link-sent", ""], + ["verify-magiclink", ""], + ["oauth/callback", ""], + ["register-passkey", ""], +]; + +function svelteKitSteps(web: DetectedWeb): WebStep[] { + // SvelteKit 3 maps #lib/* to src/lib/* as written, so it needs the extension. + const authModule = web.libAlias === "#lib" ? "#lib/auth.js" : "$lib/auth"; + const ext = web.typescript ? "ts" : "js"; + const lang = web.typescript ? ' lang="ts"' : ""; + const width = Math.max(...SVELTE_SCREENS.map(([route]) => route.length)) + "/+page.svelte".length; + return [ + { + heading: "Create the session", + file: `src/lib/auth.${ext}`, + code: `import { createSeamlessAuth } from "@seamless-auth/svelte"; + +export const auth = createSeamlessAuth({ apiHost: import.meta.env.VITE_API_URL });`, + }, + { + heading: "Render in the browser and share the session", + code: `// src/routes/+layout.${ext}: the session lives in the browser, so render there. +export const ssr = false; + + + + import { setAuthNavigator, setSeamlessAuth } from "@seamless-auth/svelte"; + import { createKitNavigator } from "@seamless-auth/svelte/kit"; + import { auth } from "${authModule}"; + + let { children } = $props(); + + setSeamlessAuth(auth); + setAuthNavigator(createKitNavigator(auth)); + + +{@render children()}`, + }, + { + heading: "Mount the sign-in screens and guard your routes", + code: ` + + import { SaLogin } from "@seamless-auth/svelte"; + + + + + + +// In the +page.${ext} or +layout.${ext} of each route that needs a signed-in user: +import { requireAuth } from "@seamless-auth/svelte/kit"; +import { auth } from "${authModule}"; + +export const load = requireAuth(auth); + +// login, passkey-login and magic-link-sent are for signed-out visitors: give each +// a +page.${ext} with requireGuest, imported from the same place. +export const load = requireGuest(auth); + +// In a component: const auth = getSeamlessAuth(); then auth.user and auth.logout().`, + }, + ]; +} + +/** The steps that wire the web app, each with the code to add. */ +export function webSteps(web: DetectedWeb, opts: { apiOrigin: string }): WebStep[] { + switch (web.framework) { + case "angular": + return angularSteps(web, opts.apiOrigin); + case "vue": + return vueSteps(web); + case "sveltekit": + return svelteKitSteps(web); + default: + return reactSteps(web); + } +} + +/** Where a step's file is, from the repository root. */ +export function webStepFile(web: DetectedWeb, step: WebStep): string | undefined { + return step.file ? path.join(web.dir, step.file) : undefined; +}