@helloleo/plugins 0.2.0-beta.6 → 0.2.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,6 +87,7 @@
87
87
  "@types/react-dom": "^19.2.3",
88
88
  "@vitejs/plugin-react": "^6.0.3",
89
89
  "jsdom": "^29.1.1",
90
+ "oxfmt": "^0.57.0",
90
91
  "oxlint": "^1.72.0",
91
92
  "typescript": "^6.0.3",
92
93
  "vite": "^8.1.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.7",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { InspectHttpPlugin } from "./tools/inspect-http.ts";
2
2
  export { RemovePlugin } from "./tools/remove.ts";
3
3
  export { ScaffoldPlugin } from "./tools/scaffold.ts";
4
+ export { SetupAuthenticationPlugin } from "./tools/setup-authentication/index.ts";
4
5
  export { SetupNetsuitePlugin } from "./tools/setup-netsuite.ts";
5
- export { SetupPasswordAuthPlugin } from "./tools/setup-password-auth.ts";
6
6
  // export { CreatePlanPlugin } from "./tools/create-plan.ts";
7
7
  // export { ListPlansPlugin } from "./tools/list-plans.ts";
8
8
  // export { ModifyPlanPlugin } from "./tools/modify-plan.ts";
@@ -87,6 +87,7 @@
87
87
  "@types/react-dom": "^19.2.3",
88
88
  "@vitejs/plugin-react": "^6.0.3",
89
89
  "jsdom": "^29.1.1",
90
+ "oxfmt": "^0.57.0",
90
91
  "oxlint": "^1.72.0",
91
92
  "typescript": "^6.0.3",
92
93
  "vite": "^8.1.3",
@@ -106,6 +106,7 @@ export const scaffold = tool({
106
106
  "- Always fetch data with react-query (useQuery and useMutation).",
107
107
  "",
108
108
  "Next: read src/routes/index.tsx and continue with the user's request.",
109
+ "For authentication (password and/or OAuth), call setup_authentication.",
109
110
  "For NetSuite projects, call setup_netsuite.",
110
111
  ].join("\n");
111
112
  },
@@ -0,0 +1,132 @@
1
+ import type { OAuthProvider, Provider } from "./constants.ts";
2
+
3
+ /**
4
+ * getAuth() builds the betterAuth instance lazily, on first call from
5
+ * INSIDE a request -- never a module-scope `export const auth =
6
+ * betterAuth(...)` like better-auth's own docs show. `db` (the app's #/db)
7
+ * is a lazy Proxy over the D1 binding, and env.* Worker secrets don't exist
8
+ * until a request is in flight; touching either while workerd boots the
9
+ * isolate throws. `instance`'s type is inferred from `buildAuth`'s own
10
+ * return type (not a hand-written `BetterAuthOptions` annotation) -- with
11
+ * secret/clientId/clientSecret resolved to plain `string` at runtime via
12
+ * requireEnv, an explicit `ReturnType<typeof betterAuth>` annotation
13
+ * mismatches on optionality and fails to typecheck.
14
+ */
15
+ export function authLibTs(providers: Provider[]): string {
16
+ const hasPassword = providers.includes("password");
17
+ const oauth = providers.filter((p): p is OAuthProvider => p !== "password");
18
+
19
+ const envFields = [
20
+ "BETTER_AUTH_SECRET?: string",
21
+ "BETTER_AUTH_URL?: string",
22
+ ...oauth.flatMap((p) => {
23
+ const prefix = p.toUpperCase();
24
+ return [
25
+ `${prefix}_CLIENT_ID?: string`,
26
+ `${prefix}_CLIENT_SECRET?: string`,
27
+ ];
28
+ }),
29
+ ].join("\n ");
30
+
31
+ const socialProvidersBlock = oauth.length
32
+ ? `
33
+ socialProviders: {
34
+ ${oauth
35
+ .map((p) => {
36
+ const prefix = p.toUpperCase();
37
+ return ` ${p}: { clientId: requireEnv(e.${prefix}_CLIENT_ID, '${prefix}_CLIENT_ID'), clientSecret: requireEnv(e.${prefix}_CLIENT_SECRET, '${prefix}_CLIENT_SECRET') },`;
38
+ })
39
+ .join("\n")}
40
+ },`
41
+ : "";
42
+
43
+ const requireEnvFn = oauth.length
44
+ ? `
45
+ function requireEnv(value: string | undefined, name: string): string {
46
+ if (!value) {
47
+ throw new Error(\`\${name} is not set. Add it to .env (dev) or run \\\`wrangler secret put \${name}\\\` (prod).\`)
48
+ }
49
+ return value
50
+ }
51
+ `
52
+ : "";
53
+
54
+ return `import { betterAuth } from 'better-auth'
55
+ import { drizzleAdapter } from '@better-auth/drizzle-adapter'
56
+ import { tanstackStartCookies } from 'better-auth/tanstack-start'
57
+ import { db } from '#/db'
58
+ import * as schema from '#/db/schema'
59
+ import { env } from '@helloleo/runtime'
60
+
61
+ /*
62
+ * buildAuth() only runs from INSIDE a request (see getAuth() below) --
63
+ * never call it at module scope. \`db\` is a lazy Proxy over the D1 binding
64
+ * and env.* Worker secrets don't exist until a request is in flight;
65
+ * touching either while workerd boots the isolate throws.
66
+ */
67
+ ${requireEnvFn}
68
+ function buildAuth() {
69
+ const e = env as {
70
+ ${envFields}
71
+ }
72
+
73
+ return betterAuth({
74
+ secret: e.BETTER_AUTH_SECRET,
75
+ baseURL: e.BETTER_AUTH_URL,
76
+ database: drizzleAdapter(db, { provider: 'sqlite', schema }),
77
+ emailAndPassword: {
78
+ enabled: ${hasPassword},
79
+ },${socialProvidersBlock}
80
+ plugins: [tanstackStartCookies()],
81
+ })
82
+ }
83
+
84
+ let instance: ReturnType<typeof buildAuth> | undefined
85
+
86
+ export function getAuth() {
87
+ if (!instance) instance = buildAuth()
88
+ return instance
89
+ }
90
+ `;
91
+ }
92
+
93
+ export function authClientTs(): string {
94
+ return `import { createAuthClient } from 'better-auth/react'
95
+
96
+ export const authClient = createAuthClient()
97
+ `;
98
+ }
99
+
100
+ export function authServerTs(): string {
101
+ return `import { createServerFn } from '@tanstack/react-start'
102
+ import { getRequestHeaders } from '@tanstack/react-start/server'
103
+ import { getAuth } from '#/lib/auth'
104
+
105
+ export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
106
+ const headers = getRequestHeaders()
107
+ return getAuth().api.getSession({ headers })
108
+ })
109
+
110
+ export const ensureSession = createServerFn({ method: 'GET' }).handler(async () => {
111
+ const headers = getRequestHeaders()
112
+ const session = await getAuth().api.getSession({ headers })
113
+ if (!session) throw new Error('Unauthorized')
114
+ return session
115
+ })
116
+ `;
117
+ }
118
+
119
+ export function authRouteTs(): string {
120
+ return `import { createFileRoute } from '@tanstack/react-router'
121
+ import { getAuth } from '#/lib/auth'
122
+
123
+ export const Route = createFileRoute('/api/auth/$')({
124
+ server: {
125
+ handlers: {
126
+ GET: ({ request }: { request: Request }) => getAuth().handler(request),
127
+ POST: ({ request }: { request: Request }) => getAuth().handler(request),
128
+ },
129
+ },
130
+ })
131
+ `;
132
+ }
@@ -0,0 +1,19 @@
1
+ export const PROVIDERS = [
2
+ "password",
3
+ "google",
4
+ "github",
5
+ "discord",
6
+ "microsoft",
7
+ ] as const;
8
+
9
+ export type Provider = (typeof PROVIDERS)[number];
10
+ export type OAuthProvider = Exclude<Provider, "password">;
11
+
12
+ export const PROVIDER_LABELS: Record<OAuthProvider, string> = {
13
+ google: "Google",
14
+ github: "GitHub",
15
+ discord: "Discord",
16
+ microsoft: "Microsoft",
17
+ };
18
+
19
+ export const BETTER_AUTH_VERSION = "^1.6.23";
@@ -0,0 +1,96 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * user/session/account/verification, hand-written to match better-auth's
6
+ * documented drizzle/sqlite schema exactly (text ids, no relations --
7
+ * experimental.joins is not enabled). Appended into src/db/schema.ts --
8
+ * drizzle.config.ts hardcodes that file as the one schema source, and this
9
+ * template's dev/prod pipeline is drizzle-kit push / wrangler d1
10
+ * migrations, not better-auth's own Kysely-based migration runner.
11
+ */
12
+ const SCHEMA_MARKER = "sqliteTable('user'";
13
+
14
+ function authTablesBlock(): string {
15
+ return `
16
+ export const user = sqliteTable('user', {
17
+ id: text('id').primaryKey(),
18
+ name: text('name').notNull(),
19
+ email: text('email').notNull().unique(),
20
+ emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
21
+ image: text('image'),
22
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
23
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
24
+ .notNull(),
25
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
26
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
27
+ .$onUpdate(() => new Date())
28
+ .notNull(),
29
+ })
30
+
31
+ export const session = sqliteTable('session', {
32
+ id: text('id').primaryKey(),
33
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
34
+ token: text('token').notNull().unique(),
35
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
36
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
37
+ .notNull(),
38
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
39
+ .$onUpdate(() => new Date())
40
+ .notNull(),
41
+ ipAddress: text('ip_address'),
42
+ userAgent: text('user_agent'),
43
+ userId: text('user_id')
44
+ .notNull()
45
+ .references(() => user.id, { onDelete: 'cascade' }),
46
+ })
47
+
48
+ export const account = sqliteTable('account', {
49
+ id: text('id').primaryKey(),
50
+ accountId: text('account_id').notNull(),
51
+ providerId: text('provider_id').notNull(),
52
+ userId: text('user_id')
53
+ .notNull()
54
+ .references(() => user.id, { onDelete: 'cascade' }),
55
+ accessToken: text('access_token'),
56
+ refreshToken: text('refresh_token'),
57
+ idToken: text('id_token'),
58
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
59
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
60
+ scope: text('scope'),
61
+ password: text('password'),
62
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
63
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
64
+ .notNull(),
65
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
66
+ .$onUpdate(() => new Date())
67
+ .notNull(),
68
+ })
69
+
70
+ export const verification = sqliteTable('verification', {
71
+ id: text('id').primaryKey(),
72
+ identifier: text('identifier').notNull(),
73
+ value: text('value').notNull(),
74
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
75
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
76
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
77
+ .notNull(),
78
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
79
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
80
+ .$onUpdate(() => new Date())
81
+ .notNull(),
82
+ })
83
+ `;
84
+ }
85
+
86
+ /** Appends the auth tables to src/db/schema.ts. Idempotent; returns whether it wrote anything. */
87
+ export async function appendAuthTables(dir: string): Promise<boolean> {
88
+ const schemaPath = path.join(dir, "src", "db", "schema.ts");
89
+ const current = await readFile(schemaPath, "utf8").catch(() => null);
90
+ if (current === null) {
91
+ throw new Error("src/db/schema.ts not found -- run scaffold first.");
92
+ }
93
+ if (current.includes(SCHEMA_MARKER)) return false;
94
+ await Bun.write(schemaPath, `${current.trimEnd()}\n${authTablesBlock()}`);
95
+ return true;
96
+ }
@@ -0,0 +1,119 @@
1
+ import path from "node:path";
2
+ import type { Plugin } from "@opencode-ai/plugin";
3
+ import { tool } from "@opencode-ai/plugin";
4
+ import { z } from "zod/v4";
5
+ import {
6
+ authClientTs,
7
+ authLibTs,
8
+ authRouteTs,
9
+ authServerTs,
10
+ } from "./auth-files.ts";
11
+ import type { OAuthProvider } from "./constants.ts";
12
+ import { PROVIDER_LABELS, PROVIDERS } from "./constants.ts";
13
+ import { appendAuthTables } from "./db-schema.ts";
14
+ import { addAuthDependencies } from "./package-json.ts";
15
+ import {
16
+ authedLayoutTsx,
17
+ dashboardPageTsx,
18
+ loginPageTsx,
19
+ signupPageTsx,
20
+ } from "./pages.ts";
21
+
22
+ /**
23
+ * Adds authentication on top of an existing scaffold via better-auth:
24
+ * email/password and/or social sign-in (google/github/discord/microsoft),
25
+ * a drizzle-backed session store, and login/signup routes.
26
+ *
27
+ * Only additive files are written, except `src/db/schema.ts` (appended,
28
+ * never replaced) and `package.json` (two deps added if missing).
29
+ *
30
+ * Mutually exclusive with setup_netsuite: both own src/routes/login.tsx and
31
+ * the app's one auth strategy. Called AFTER `scaffold`, never instead of it.
32
+ */
33
+ export const setupAuthentication = tool({
34
+ description:
35
+ "Add authentication to an existing project via better-auth. Call scaffold first, " +
36
+ "then this. providers: 'password' for email/password, plus any of " +
37
+ "'google' | 'github' | 'discord' | 'microsoft' for social sign-in -- pass any " +
38
+ "combination. Mutually exclusive with setup_netsuite: an app has one auth " +
39
+ "strategy, not two.",
40
+ args: {
41
+ providers: z
42
+ .array(z.enum(PROVIDERS))
43
+ .min(1)
44
+ .describe("Auth methods to enable, e.g. ['password', 'google']"),
45
+ },
46
+ async execute({ providers }, context) {
47
+ const dir = context.directory;
48
+ const hasPassword = providers.includes("password");
49
+ const oauth = providers.filter((p): p is OAuthProvider => p !== "password");
50
+
51
+ const files: [string, string][] = [
52
+ [path.join("src", "lib", "auth.ts"), authLibTs(providers)],
53
+ [path.join("src", "lib", "auth-client.ts"), authClientTs()],
54
+ [path.join("src", "lib", "auth-server.ts"), authServerTs()],
55
+ [path.join("src", "routes", "api", "auth", "$.ts"), authRouteTs()],
56
+ [path.join("src", "routes", "_authed.tsx"), authedLayoutTsx()],
57
+ [
58
+ path.join("src", "routes", "_authed", "dashboard.tsx"),
59
+ dashboardPageTsx(),
60
+ ],
61
+ [
62
+ path.join("src", "routes", "login.tsx"),
63
+ loginPageTsx(hasPassword, oauth),
64
+ ],
65
+ ];
66
+ if (hasPassword) {
67
+ files.push([path.join("src", "routes", "signup.tsx"), signupPageTsx()]);
68
+ }
69
+
70
+ for (const [filePath, content] of files) {
71
+ await Bun.write(path.join(dir, filePath), content);
72
+ }
73
+
74
+ const schemaAppended = await appendAuthTables(dir);
75
+ const depsAdded = await addAuthDependencies(dir);
76
+
77
+ const envLines = [
78
+ " - dev (.env): BETTER_AUTH_SECRET=<random>, BETTER_AUTH_URL=http://localhost:3000",
79
+ " - prod: `wrangler secret put BETTER_AUTH_SECRET`, and BETTER_AUTH_URL as a var in wrangler.jsonc",
80
+ ];
81
+ for (const p of oauth) {
82
+ const prefix = p.toUpperCase();
83
+ envLines.push(
84
+ ` - ${PROVIDER_LABELS[p]}: ${prefix}_CLIENT_ID / ${prefix}_CLIENT_SECRET (dev: .env, prod: wrangler secret put)`,
85
+ );
86
+ }
87
+
88
+ return [
89
+ `Wrote authentication (better-auth, providers: ${providers.join(", ")}):`,
90
+ " - src/lib/auth.ts (lazy betterAuth instance -- read the comment on why it's not a module-scope export)",
91
+ " - src/lib/auth-client.ts (React client -- authClient.signIn.email/social, signUp.email, signOut)",
92
+ " - src/lib/auth-server.ts (getSession/ensureSession server functions, for SSR route guards)",
93
+ " - src/routes/api/auth/$.ts (catch-all handler)",
94
+ " - src/routes/_authed.tsx (pathless guard for protected routes)",
95
+ " - src/routes/_authed/dashboard.tsx (example protected page -- _authed needs at least one child route or it collides with index.tsx on '/')",
96
+ ` - src/routes/login.tsx${hasPassword ? ", src/routes/signup.tsx" : ""}`,
97
+ ` - src/db/schema.ts ${schemaAppended ? "(appended user/session/account/verification tables)" : "(already had a `user` table, left as-is)"}`,
98
+ ` - package.json ${depsAdded ? "(added better-auth, @better-auth/drizzle-adapter)" : "(dependencies already present)"}`,
99
+ "",
100
+ "Run `bun install`, then `bun run db:push` (or just `bun dev`, which runs it first) to create the new tables.",
101
+ "",
102
+ "Required env vars/secrets:",
103
+ ...envLines,
104
+ "",
105
+ "Add more protected pages under src/routes/_authed/ (e.g. _authed/settings.tsx -> /settings); they inherit the guard.",
106
+ "",
107
+ "Verify before shipping: password hashing defaults to scrypt via Node's crypto, exposed through the " +
108
+ "nodejs_compat flag (already on for this template) -- confirm a real sign-up/login round-trip works in `bun dev`.",
109
+ "",
110
+ "Mutually exclusive with setup_netsuite -- an app has one auth strategy, not two.",
111
+ ].join("\n");
112
+ },
113
+ });
114
+
115
+ export const SetupAuthenticationPlugin: Plugin = async () => {
116
+ return {
117
+ tool: { setup_authentication: setupAuthentication },
118
+ };
119
+ };
@@ -0,0 +1,17 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { BETTER_AUTH_VERSION } from "./constants.ts";
4
+
5
+ /** Adds better-auth + its drizzle adapter to the project's package.json. Idempotent. */
6
+ export async function addAuthDependencies(dir: string): Promise<boolean> {
7
+ const pkgPath = path.join(dir, "package.json");
8
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
9
+ dependencies?: Record<string, string>;
10
+ };
11
+ pkg.dependencies ??= {};
12
+ if (pkg.dependencies["better-auth"]) return false;
13
+ pkg.dependencies["better-auth"] = BETTER_AUTH_VERSION;
14
+ pkg.dependencies["@better-auth/drizzle-adapter"] = BETTER_AUTH_VERSION;
15
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
16
+ return true;
17
+ }
@@ -0,0 +1,241 @@
1
+ import type { OAuthProvider } from "./constants.ts";
2
+ import { PROVIDER_LABELS } from "./constants.ts";
3
+
4
+ function socialButtonsJsx(oauth: OAuthProvider[]): string {
5
+ return oauth
6
+ .map(
7
+ (p) => ` <Button
8
+ type="button"
9
+ variant="outline"
10
+ className="w-full"
11
+ onClick={() => authClient.signIn.social({ provider: '${p}', callbackURL: '/' })}
12
+ >
13
+ Continue with ${PROVIDER_LABELS[p]}
14
+ </Button>`,
15
+ )
16
+ .join("\n");
17
+ }
18
+
19
+ export function authedLayoutTsx(): string {
20
+ return `import { createFileRoute, redirect } from '@tanstack/react-router'
21
+ import { getSession } from '#/lib/auth-server'
22
+
23
+ // Pathless guard -- no URL segment of its own. Put protected pages under
24
+ // src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard) and they
25
+ // inherit this beforeLoad. See src/routes/README.md, "Authenticated routes".
26
+ export const Route = createFileRoute('/_authed')({
27
+ beforeLoad: async () => {
28
+ const session = await getSession()
29
+ if (!session) throw redirect({ to: '/login' })
30
+ return { user: session.user }
31
+ },
32
+ })
33
+ `;
34
+ }
35
+
36
+ export function loginPageTsx(
37
+ hasPassword: boolean,
38
+ oauth: OAuthProvider[],
39
+ ): string {
40
+ const social = oauth.length ? `\n${socialButtonsJsx(oauth)}\n` : "";
41
+
42
+ const divider =
43
+ oauth.length && hasPassword
44
+ ? `
45
+ <div className="relative">
46
+ <div className="absolute inset-0 flex items-center">
47
+ <span className="w-full border-t" />
48
+ </div>
49
+ <div className="relative flex justify-center text-xs uppercase">
50
+ <span className="bg-card px-2 text-muted-foreground">Or continue with email</span>
51
+ </div>
52
+ </div>
53
+ `
54
+ : "";
55
+
56
+ const passwordForm = hasPassword
57
+ ? `
58
+ <form onSubmit={handleSubmit} className="space-y-4">
59
+ <div className="space-y-2">
60
+ <Label htmlFor="email">Email</Label>
61
+ <Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
62
+ </div>
63
+ <div className="space-y-2">
64
+ <Label htmlFor="password">Password</Label>
65
+ <Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
66
+ </div>
67
+ {error && <p className="text-sm text-destructive">{error}</p>}
68
+ <Button type="submit" className="w-full" disabled={loading}>
69
+ {loading ? 'Logging in…' : 'Log in'}
70
+ </Button>
71
+ <p className="text-sm text-center text-muted-foreground">
72
+ No account? <Link to="/signup" className="underline">Sign up</Link>
73
+ </p>
74
+ </form>
75
+ `
76
+ : "";
77
+
78
+ const stateAndHandler = hasPassword
79
+ ? `
80
+ const router = useRouter()
81
+ const [email, setEmail] = useState('')
82
+ const [password, setPassword] = useState('')
83
+ const [error, setError] = useState('')
84
+ const [loading, setLoading] = useState(false)
85
+
86
+ async function handleSubmit(e: React.FormEvent) {
87
+ e.preventDefault()
88
+ setError('')
89
+ setLoading(true)
90
+ const { error } = await authClient.signIn.email({ email, password })
91
+ setLoading(false)
92
+ if (error) {
93
+ setError(error.message ?? 'Something went wrong.')
94
+ return
95
+ }
96
+ router.navigate({ to: '/' })
97
+ }
98
+ `
99
+ : "";
100
+
101
+ const imports = [
102
+ hasPassword
103
+ ? "import { createFileRoute, Link, useRouter } from '@tanstack/react-router'"
104
+ : "import { createFileRoute } from '@tanstack/react-router'",
105
+ hasPassword ? "import { useState } from 'react'" : "",
106
+ "import { authClient } from '#/lib/auth-client'",
107
+ "import { Button } from '#/components/ui/button'",
108
+ hasPassword ? "import { Input } from '#/components/ui/input'" : "",
109
+ hasPassword ? "import { Label } from '#/components/ui/label'" : "",
110
+ "import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'",
111
+ ]
112
+ .filter(Boolean)
113
+ .join("\n");
114
+
115
+ return `${imports}
116
+
117
+ export const Route = createFileRoute('/login')({
118
+ component: LoginPage,
119
+ })
120
+
121
+ function LoginPage() {${stateAndHandler}
122
+ return (
123
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
124
+ <Card className="w-full max-w-sm">
125
+ <CardHeader>
126
+ <CardTitle>Log in</CardTitle>
127
+ </CardHeader>
128
+ <CardContent className="space-y-4">${social}${divider}${passwordForm}
129
+ </CardContent>
130
+ </Card>
131
+ </div>
132
+ )
133
+ }
134
+ `;
135
+ }
136
+
137
+ export function signupPageTsx(): string {
138
+ return `import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
139
+ import { useState } from 'react'
140
+ import { authClient } from '#/lib/auth-client'
141
+ import { Button } from '#/components/ui/button'
142
+ import { Input } from '#/components/ui/input'
143
+ import { Label } from '#/components/ui/label'
144
+ import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
145
+
146
+ export const Route = createFileRoute('/signup')({
147
+ component: SignupPage,
148
+ })
149
+
150
+ function SignupPage() {
151
+ const router = useRouter()
152
+ const [name, setName] = useState('')
153
+ const [email, setEmail] = useState('')
154
+ const [password, setPassword] = useState('')
155
+ const [error, setError] = useState('')
156
+ const [loading, setLoading] = useState(false)
157
+
158
+ async function handleSubmit(e: React.FormEvent) {
159
+ e.preventDefault()
160
+ setError('')
161
+ setLoading(true)
162
+ const { error } = await authClient.signUp.email({ name, email, password })
163
+ setLoading(false)
164
+ if (error) {
165
+ setError(error.message ?? 'Something went wrong.')
166
+ return
167
+ }
168
+ router.navigate({ to: '/' })
169
+ }
170
+
171
+ return (
172
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
173
+ <Card className="w-full max-w-sm">
174
+ <CardHeader>
175
+ <CardTitle>Sign up</CardTitle>
176
+ </CardHeader>
177
+ <CardContent>
178
+ <form onSubmit={handleSubmit} className="space-y-4">
179
+ <div className="space-y-2">
180
+ <Label htmlFor="name">Name</Label>
181
+ <Input id="name" type="text" value={name} onChange={e => setName(e.target.value)} required autoComplete="name" />
182
+ </div>
183
+ <div className="space-y-2">
184
+ <Label htmlFor="email">Email</Label>
185
+ <Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
186
+ </div>
187
+ <div className="space-y-2">
188
+ <Label htmlFor="password">Password</Label>
189
+ <Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
190
+ </div>
191
+ {error && <p className="text-sm text-destructive">{error}</p>}
192
+ <Button type="submit" className="w-full" disabled={loading}>
193
+ {loading ? 'Signing up…' : 'Sign up'}
194
+ </Button>
195
+ <p className="text-sm text-center text-muted-foreground">
196
+ Already have an account? <Link to="/login" className="underline">Log in</Link>
197
+ </p>
198
+ </form>
199
+ </CardContent>
200
+ </Card>
201
+ </div>
202
+ )
203
+ }
204
+ `;
205
+ }
206
+
207
+ export function dashboardPageTsx(): string {
208
+ return `import { createFileRoute, useRouter } from '@tanstack/react-router'
209
+ import { authClient } from '#/lib/auth-client'
210
+ import { Button } from '#/components/ui/button'
211
+
212
+ // Example protected page -- _authed.tsx needs at least one child route or
213
+ // it collides with index.tsx on path "/" and \`tsr generate\` fails. Replace
214
+ // this, or add more pages alongside it under src/routes/_authed/.
215
+ export const Route = createFileRoute('/_authed/dashboard')({
216
+ component: DashboardPage,
217
+ })
218
+
219
+ function DashboardPage() {
220
+ const router = useRouter()
221
+ const { data: session } = authClient.useSession()
222
+
223
+ return (
224
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
225
+ <div className="text-center space-y-4">
226
+ <p className="text-lg">Signed in as {session?.user.email}</p>
227
+ <Button
228
+ onClick={() =>
229
+ authClient.signOut({
230
+ fetchOptions: { onSuccess: () => router.navigate({ to: '/login' }) },
231
+ })
232
+ }
233
+ >
234
+ Sign out
235
+ </Button>
236
+ </div>
237
+ </div>
238
+ )
239
+ }
240
+ `;
241
+ }