@percepta/create 4.1.11 → 4.1.13

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.
@@ -175,9 +175,13 @@ spec:
175
175
  {{ end }}
176
176
  {{ if ne (input "azure_postgresql_private_dns_zone_id") "" }}
177
177
  private_dns_zone_id: '{{ input "azure_postgresql_private_dns_zone_id" }}'
178
+ {{ else }}
179
+ private_dns_zone_id: '{{ (blueprintInstallation "mosaic").outputs.azure_postgresql_private_dns_zone_id }}'
178
180
  {{ end }}
179
181
  {{ if ne (input "azure_postgresql_subnet_id") "" }}
180
182
  subnet_id: '{{ input "azure_postgresql_subnet_id" }}'
183
+ {{ else }}
184
+ subnet_id: '{{ (blueprintInstallation "mosaic").outputs.azure_postgresql_subnet_id }}'
181
185
  {{ end }}
182
186
  {{ if ne (input "azure_postgresql_subnet_address_prefix") "" }}
183
187
  subnet_address_prefix: '{{ input "azure_postgresql_subnet_address_prefix" }}'
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@percepta/auth": "^0.1.4",
20
+ "better-auth": "^1.6.4",
20
21
  "drizzle-orm": "^0.45.2"
21
22
  },
22
23
  "devDependencies": {
@@ -1,12 +1,30 @@
1
1
  import { createLazyAuth, createPerceptaAuth } from "@percepta/auth/better-auth";
2
+ import type { BetterAuthOptions } from "better-auth";
3
+ import { admin } from "better-auth/plugins";
4
+ import { genericOAuth, okta } from "better-auth/plugins/generic-oauth";
2
5
  import { db } from "./drizzle/db";
3
6
  import { accounts } from "./drizzle/schema/auth/accounts";
4
7
  import { sessions } from "./drizzle/schema/auth/sessions";
5
8
  import { verifications } from "./drizzle/schema/auth/verifications";
6
9
  import { users } from "./drizzle/schema/users";
7
10
 
11
+ type AuthMode = "username-password" | "google" | "okta";
12
+
13
+ const DEFAULT_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
8
14
  const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
9
15
 
16
+ function isAuthMode(value: string | undefined): value is AuthMode {
17
+ return (
18
+ value === "username-password" || value === "google" || value === "okta"
19
+ );
20
+ }
21
+
22
+ function getAuthMode(): AuthMode {
23
+ return isAuthMode(process.env.AUTH_MODE)
24
+ ? process.env.AUTH_MODE
25
+ : DEFAULT_AUTH_MODE;
26
+ }
27
+
10
28
  function requiredEnv(name: string): string {
11
29
  const value = process.env[name];
12
30
  if (value == null || value.length === 0) {
@@ -15,6 +33,11 @@ function requiredEnv(name: string): string {
15
33
  return value;
16
34
  }
17
35
 
36
+ function optionalEnv(name: string): string | undefined {
37
+ const value = process.env[name];
38
+ return value == null || value.length === 0 ? undefined : value;
39
+ }
40
+
18
41
  function getSecret(): string {
19
42
  if (isBuildPhase) {
20
43
  return "build-placeholder-not-used-at-runtime";
@@ -31,10 +54,59 @@ function getBaseUrl(): string {
31
54
  );
32
55
  }
33
56
 
57
+ function getSocialProviders(
58
+ authMode: AuthMode,
59
+ ): BetterAuthOptions["socialProviders"] {
60
+ if (authMode !== "google") return undefined;
61
+
62
+ const clientId = optionalEnv("GOOGLE_CLIENT_ID");
63
+ const clientSecret = optionalEnv("GOOGLE_CLIENT_SECRET");
64
+ if (clientId == null || clientSecret == null) return undefined;
65
+
66
+ const hostedDomain = optionalEnv("GOOGLE_HOSTED_DOMAIN");
67
+ return {
68
+ google: {
69
+ clientId,
70
+ clientSecret,
71
+ ...(hostedDomain == null ? {} : { hd: hostedDomain }),
72
+ },
73
+ };
74
+ }
75
+
76
+ function getPlugins(authMode: AuthMode): BetterAuthOptions["plugins"] {
77
+ if (authMode !== "okta") return undefined;
78
+
79
+ const clientId = optionalEnv("OKTA_CLIENT_ID");
80
+ const clientSecret = optionalEnv("OKTA_CLIENT_SECRET");
81
+ const issuer = optionalEnv("OKTA_ISSUER");
82
+ if (clientId == null || clientSecret == null || issuer == null) {
83
+ return undefined;
84
+ }
85
+
86
+ return [
87
+ admin(),
88
+ genericOAuth({
89
+ config: [
90
+ okta({
91
+ clientId,
92
+ clientSecret,
93
+ issuer,
94
+ }),
95
+ ],
96
+ }),
97
+ ];
98
+ }
99
+
34
100
  function createAuth() {
101
+ const authMode = getAuthMode();
102
+
35
103
  return createPerceptaAuth({
36
104
  baseURL: getBaseUrl(),
37
105
  database: db,
106
+ emailAndPassword: {
107
+ enabled: authMode === "username-password",
108
+ },
109
+ plugins: getPlugins(authMode),
38
110
  schema: {
39
111
  user: users,
40
112
  session: sessions,
@@ -42,6 +114,7 @@ function createAuth() {
42
114
  verification: verifications,
43
115
  },
44
116
  secret: getSecret(),
117
+ socialProviders: getSocialProviders(authMode),
45
118
  });
46
119
  }
47
120
 
@@ -16,7 +16,7 @@ Next.js 16 full-stack application scaffolded from the Mosaic webapp template via
16
16
  - `pnpm db:generate` — generate Drizzle migrations
17
17
  - `pnpm db:migrate` — apply migrations
18
18
  - `pnpm db:studio` — run migrations, then open Drizzle Studio
19
- - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas (all use password)
19
+ - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas; username/password scaffolds set the dev password to `password`
20
20
 
21
21
  **Package manager**: Always use `pnpm`, never `npm` or `yarn`.
22
22
 
@@ -7,7 +7,7 @@ Design theme: `__MOSAIC_DESIGN_THEME__`
7
7
  ## Features
8
8
 
9
9
  - **Next.js 16** with App Router
10
- - **Authentication** via Better Auth with email/password credentials
10
+ - **Authentication** via Better Auth (__AUTH_MODE_LABEL__)
11
11
  - **Database** with PostgreSQL, Drizzle ORM, and migrations
12
12
  - **Access Control** with SpiceDB schema authoring and manifest validation
13
13
  - **Logging** with Pino and structured safe/unsafe data separation
@@ -147,27 +147,60 @@ logger.error({ safe: { documentId } }, "Processing failed", error);
147
147
 
148
148
  This app consumes the customer monorepo's shared [Better Auth](https://better-auth.com) package, `@__REPO_NAME__/auth`. The app still serves local development auth routes, but the users, sessions, accounts, groups, and group memberships live in the shared customer auth database. Deployed apps should receive that shared database through `AUTH_DATABASE_URL` from the monorepo auth Secret; `DATABASE_URL` is reserved for this app's own database.
149
149
 
150
- Required auth environment variables:
150
+ This app was scaffolded with `__AUTH_MODE_LABEL__` as its auth setup. New apps
151
+ inherit the customer monorepo's workspace auth setup from
152
+ `.mosaic-workspace.json` by default, and individual apps can override that
153
+ default with `AUTH_MODE`.
154
+
155
+ Every app requires:
151
156
 
152
157
  ```bash
158
+ AUTH_MODE=__AUTH_MODE__
153
159
  BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
154
160
  ```
155
161
 
156
162
  Auth uses `BETTER_AUTH_URL`, `APP_BASE_URL`, or `http://localhost:3000`, in
157
163
  that order, for its base URL.
158
164
 
165
+ For username/password auth, local setup creates credential users and app access
166
+ grants. Google OAuth sign-in requires `GOOGLE_CLIENT_ID` and
167
+ `GOOGLE_CLIENT_SECRET`; optionally set `GOOGLE_HOSTED_DOMAIN`. Register these
168
+ redirect URIs in the Google OAuth client:
169
+
170
+ ```text
171
+ http://localhost:3000/api/auth/callback/google
172
+ https://<app-host>/api/auth/callback/google
173
+ ```
174
+
175
+ Okta OIDC sign-in requires `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, and
176
+ `OKTA_ISSUER`. The issuer usually looks like
177
+ `https://dev-xxxxx.okta.com/oauth2/default`. Register these redirect URIs in
178
+ the Okta app integration:
179
+
180
+ ```text
181
+ http://localhost:3000/api/auth/oauth2/callback/okta
182
+ https://<app-host>/api/auth/oauth2/callback/okta
183
+ ```
184
+
185
+ OAuth users still pass through the app's SpiceDB access gate after sign-in. In
186
+ local development, `pnpm db:seed` creates access principals for the example
187
+ emails; provider sign-ins with the same verified email can link to those users.
188
+ For other emails, grant app access from the settings UI or seed/apply an access
189
+ grant before expecting the protected app pages to load.
190
+
159
191
  Remote deployments should also set `AUTH_DATABASE_URL` from the shared auth
160
192
  database Secret. Local development can omit it and use the root-created local
161
193
  `auth` database.
162
194
 
163
- To create dev users:
195
+ To create dev users and local app access grants:
164
196
 
165
197
  ```bash
166
198
  pnpm db:seed
167
- # Creates customer-admin@example.com / password as a customer admin
168
- # Creates app-admin@example.com / password as an app admin
169
- # Creates app-user@example.com / password as an app user
170
- # Creates non-user@example.com / password with no app access
199
+ # Creates customer-admin@example.com as a customer admin
200
+ # Creates app-admin@example.com as an app admin
201
+ # Creates app-user@example.com as an app user
202
+ # Creates non-user@example.com with no app access
203
+ # Username/password scaffolds also set each password to: password
171
204
  ```
172
205
 
173
206
  ## Access Control
@@ -7,7 +7,18 @@ APP_BASE_URL=http://localhost:3000
7
7
  DATABASE_URL=postgresql://postgres:postgres@localhost:5434/__DB_NAME__
8
8
 
9
9
  # Authentication (Better Auth)
10
+ # App auth setup selected by @percepta/create: __AUTH_MODE_LABEL__
11
+ # Defaults to the workspace auth setup unless this app was scaffolded with an override.
12
+ AUTH_MODE=__AUTH_MODE__
10
13
  BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
14
+ # Google OAuth apps should fill these:
15
+ # GOOGLE_CLIENT_ID=
16
+ # GOOGLE_CLIENT_SECRET=
17
+ # GOOGLE_HOSTED_DOMAIN=
18
+ # Okta OIDC apps should fill these:
19
+ # OKTA_CLIENT_ID=
20
+ # OKTA_CLIENT_SECRET=
21
+ # OKTA_ISSUER=
11
22
 
12
23
  # Shared Auth Database
13
24
  # Deployed apps should set this from the customer monorepo auth database Secret.
@@ -47,8 +47,35 @@ const SEEDED_USERS = [
47
47
  },
48
48
  ] as const;
49
49
 
50
+ type AuthMode = "username-password" | "google" | "okta";
51
+ type AdminCreateUserApi = {
52
+ createUser(input: {
53
+ body: {
54
+ email: string;
55
+ name: string;
56
+ password?: string;
57
+ };
58
+ }): Promise<{ user: { id: string } }>;
59
+ };
60
+
61
+ const DEFAULT_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
62
+
63
+ function isAuthMode(value: string | undefined): value is AuthMode {
64
+ return (
65
+ value === "username-password" || value === "google" || value === "okta"
66
+ );
67
+ }
68
+
69
+ function getAuthMode(): AuthMode {
70
+ return isAuthMode(process.env.AUTH_MODE)
71
+ ? process.env.AUTH_MODE
72
+ : DEFAULT_AUTH_MODE;
73
+ }
74
+
50
75
  async function main(): Promise<void> {
51
76
  nextEnv.loadEnvConfig(process.cwd());
77
+ const authMode = getAuthMode();
78
+ process.env.AUTH_MODE = authMode;
52
79
  // oxlint-disable-next-line typescript/no-explicit-any
53
80
  (globalThis as any).AsyncLocalStorage = AsyncLocalStorage;
54
81
 
@@ -80,36 +107,46 @@ async function main(): Promise<void> {
80
107
 
81
108
  let userId: string;
82
109
  if (existing != null) {
83
- await authDb
84
- .update(users)
85
- .set({ role: seededUser.role })
86
- .where(eq(users.id, existing.id));
87
110
  userId = existing.id;
88
111
  console.log(
89
- `Seed user "${seededUser.email}" already exists (id: ${existing.id}). Ensured ${seededUser.role} role.`,
112
+ `Seed user "${seededUser.email}" already exists (id: ${existing.id}).`,
90
113
  );
91
114
  } else {
92
- // Use Better Auth's signUpEmail API to create the user with a hashed password
93
- const res = await auth.api.signUpEmail({
94
- body: {
95
- email: seededUser.email,
96
- name: seededUser.name,
97
- password: seededUser.password,
98
- },
99
- });
100
-
101
- await authDb
102
- .update(users)
103
- .set({ role: seededUser.role })
104
- .where(eq(users.id, res.user.id));
105
-
106
- userId = res.user.id;
107
- console.log(
108
- `Seed user created: ${seededUser.email} (id: ${res.user.id})`,
109
- );
110
- console.log(` Password: ${seededUser.password}`);
115
+ if (authMode === "username-password") {
116
+ // Use Better Auth's signUpEmail API to create the user with a hashed password.
117
+ const res = await auth.api.signUpEmail({
118
+ body: {
119
+ email: seededUser.email,
120
+ name: seededUser.name,
121
+ password: seededUser.password,
122
+ },
123
+ });
124
+ userId = res.user.id;
125
+ } else {
126
+ // The admin plugin can create local access principals when this app
127
+ // starts with an external OAuth provider instead of credentials.
128
+ const adminApi = auth.api as typeof auth.api & AdminCreateUserApi;
129
+ const res = await adminApi.createUser({
130
+ body: {
131
+ email: seededUser.email,
132
+ name: seededUser.name,
133
+ },
134
+ });
135
+ userId = res.user.id;
136
+ }
137
+
138
+ console.log(`Seed user created: ${seededUser.email} (id: ${userId})`);
139
+ if (authMode === "username-password") {
140
+ console.log(` Password: ${seededUser.password}`);
141
+ }
111
142
  }
112
143
 
144
+ await authDb
145
+ .update(users)
146
+ .set({ role: seededUser.role })
147
+ .where(eq(users.id, userId));
148
+ console.log(` Ensured role: ${seededUser.role}`);
149
+
113
150
  const subject = toUserSubject(userId);
114
151
  switch (seededUser.access) {
115
152
  case "customer_admin":
@@ -5,7 +5,7 @@ import { Button, Input } from "@percepta/design";
5
5
  import { ArrowRight } from "lucide-react";
6
6
  import Link from "next/link";
7
7
  import { useRouter, useSearchParams } from "next/navigation";
8
- import React, { useCallback } from "react";
8
+ import React, { useCallback, useState } from "react";
9
9
  import { Controller, useForm } from "react-hook-form";
10
10
  import { toast } from "sonner";
11
11
  import z from "zod";
@@ -19,6 +19,7 @@ const CREDENTIALS_SCHEMA = z.object({
19
19
  });
20
20
 
21
21
  type Credentials = z.infer<typeof CREDENTIALS_SCHEMA>;
22
+ type AuthMode = "username-password" | "google" | "okta";
22
23
 
23
24
  // Defaults are empty in production so deployed instances don't suggest seeded
24
25
  // credentials. In dev we prefill with the seeded user from `pnpm db:seed` so a
@@ -27,9 +28,10 @@ const DEFAULTS: Credentials = IS_DEV
27
28
  ? { email: "app-admin@example.com", password: "password" }
28
29
  : { email: "", password: "" };
29
30
 
30
- export function CredentialsSignInForm() {
31
+ export function CredentialsSignInForm({ authMode }: { authMode: AuthMode }) {
31
32
  const router = useRouter();
32
33
  const searchParams = useSearchParams();
34
+ const [isProviderSubmitting, setIsProviderSubmitting] = useState(false);
33
35
 
34
36
  const {
35
37
  control,
@@ -41,6 +43,8 @@ export function CredentialsSignInForm() {
41
43
  });
42
44
 
43
45
  const callbackUrl = searchParams.get("callbackUrl") ?? "/";
46
+ const usesCredentials = authMode === "username-password";
47
+ const providerName = authMode === "google" ? "Google" : "Okta";
44
48
 
45
49
  const submit = useCallback(
46
50
  async ({ email, password }: Credentials): Promise<void> => {
@@ -64,6 +68,28 @@ export function CredentialsSignInForm() {
64
68
  [callbackUrl, router],
65
69
  );
66
70
 
71
+ const signInWithProvider = useCallback(async (): Promise<void> => {
72
+ setIsProviderSubmitting(true);
73
+ try {
74
+ const { error } =
75
+ authMode === "google"
76
+ ? await authClient.signIn.social({
77
+ provider: "google",
78
+ callbackURL: callbackUrl,
79
+ })
80
+ : await authClient.signIn.oauth2({
81
+ providerId: "okta",
82
+ callbackURL: callbackUrl,
83
+ });
84
+
85
+ if (error != null) {
86
+ toast.error(error.message ?? `Unable to sign in with ${providerName}.`);
87
+ }
88
+ } finally {
89
+ setIsProviderSubmitting(false);
90
+ }
91
+ }, [authMode, callbackUrl, providerName]);
92
+
67
93
  return (
68
94
  <div className="grid gap-8">
69
95
  <div className="grid gap-3">
@@ -72,60 +98,82 @@ export function CredentialsSignInForm() {
72
98
  <span>Sign in</span>
73
99
  </p>
74
100
  <h1 className="app-auth-title">Welcome back.</h1>
75
- <p className="app-auth-copy text-sm">
76
- Need an account?{" "}
77
- <Link
78
- className="app-auth-link"
79
- href={`/auth/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
80
- >
81
- Create one
82
- </Link>
83
- </p>
101
+ {usesCredentials ? (
102
+ <p className="app-auth-copy text-sm">
103
+ Need an account?{" "}
104
+ <Link
105
+ className="app-auth-link"
106
+ href={`/auth/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
107
+ >
108
+ Create one
109
+ </Link>
110
+ </p>
111
+ ) : (
112
+ <p className="app-auth-copy text-sm">
113
+ Use your {providerName} account to continue.
114
+ </p>
115
+ )}
84
116
  </div>
85
- <form className="app-auth-form" onSubmit={handleSubmit(submit)}>
86
- <div className="app-auth-fields">
87
- <Controller
88
- control={control}
89
- name="email"
90
- render={({ field, fieldState }) => (
91
- <FormItem
92
- className="app-auth-field"
93
- label="Email"
94
- fieldState={fieldState}
95
- >
96
- <Input {...field} type="email" autoComplete="email" />
97
- </FormItem>
98
- )}
99
- />
100
- <Controller
101
- control={control}
102
- name="password"
103
- render={({ field, fieldState }) => (
104
- <FormItem
105
- className="app-auth-field"
106
- label="Password"
107
- fieldState={fieldState}
108
- >
109
- <Input
110
- {...field}
111
- type="password"
112
- autoComplete="current-password"
113
- />
114
- </FormItem>
115
- )}
116
- />
117
- </div>
118
- <div className="flex justify-end">
119
- <Button
120
- className="app-auth-submit"
121
- type="submit"
122
- loading={isSubmitting}
123
- >
124
- <span>Sign In</span>
125
- <ArrowRight aria-hidden={true} />
126
- </Button>
117
+ {usesCredentials ? (
118
+ <form className="app-auth-form" onSubmit={handleSubmit(submit)}>
119
+ <div className="app-auth-fields">
120
+ <Controller
121
+ control={control}
122
+ name="email"
123
+ render={({ field, fieldState }) => (
124
+ <FormItem
125
+ className="app-auth-field"
126
+ label="Email"
127
+ fieldState={fieldState}
128
+ >
129
+ <Input {...field} type="email" autoComplete="email" />
130
+ </FormItem>
131
+ )}
132
+ />
133
+ <Controller
134
+ control={control}
135
+ name="password"
136
+ render={({ field, fieldState }) => (
137
+ <FormItem
138
+ className="app-auth-field"
139
+ label="Password"
140
+ fieldState={fieldState}
141
+ >
142
+ <Input
143
+ {...field}
144
+ type="password"
145
+ autoComplete="current-password"
146
+ />
147
+ </FormItem>
148
+ )}
149
+ />
150
+ </div>
151
+ <div className="flex justify-end">
152
+ <Button
153
+ className="app-auth-submit"
154
+ type="submit"
155
+ loading={isSubmitting}
156
+ >
157
+ <span>Sign In</span>
158
+ <ArrowRight aria-hidden={true} />
159
+ </Button>
160
+ </div>
161
+ </form>
162
+ ) : (
163
+ <div className="app-auth-form">
164
+ <div className="flex justify-end">
165
+ <Button
166
+ className="app-auth-submit"
167
+ type="button"
168
+ loading={isProviderSubmitting}
169
+ onClick={signInWithProvider}
170
+ >
171
+ <span>Continue with {providerName}</span>
172
+ <ArrowRight aria-hidden={true} />
173
+ </Button>
174
+ </div>
127
175
  </div>
128
- </form>
176
+ )}
129
177
  </div>
130
178
  );
131
179
  }
@@ -1,7 +1,7 @@
1
1
  import type { Metadata } from "next";
2
2
  import { redirect } from "next/navigation";
3
3
  import { Suspense } from "react";
4
- import { getServerSession } from "../../../../lib/auth";
4
+ import { AUTH_MODE, getServerSession } from "../../../../lib/auth";
5
5
  import { CredentialsSignInForm } from "./CredentialsSignInForm";
6
6
 
7
7
  export const metadata: Metadata = {
@@ -21,7 +21,7 @@ export default async function SignInPage() {
21
21
  <p className="text-center text-sm text-muted-foreground">Loading…</p>
22
22
  }
23
23
  >
24
- <CredentialsSignInForm />
24
+ <CredentialsSignInForm authMode={AUTH_MODE} />
25
25
  </Suspense>
26
26
  );
27
27
  }
@@ -1,20 +1,46 @@
1
1
  import type { Metadata } from "next";
2
2
  import { redirect } from "next/navigation";
3
3
  import { Suspense } from "react";
4
- import { getServerSession } from "../../../../lib/auth";
4
+ import { AUTH_MODE, getServerSession } from "../../../../lib/auth";
5
5
  import { CredentialsSignUpForm } from "./CredentialsSignUpForm";
6
6
 
7
+ type SearchParamValue = string | string[] | undefined;
8
+
7
9
  export const metadata: Metadata = {
8
10
  title: "Create Account — __APP_TITLE__",
9
11
  };
10
12
 
11
- export default async function SignUpPage() {
13
+ function getFirstSearchParam(value: SearchParamValue): string | undefined {
14
+ return Array.isArray(value) ? value[0] : value;
15
+ }
16
+
17
+ function getSignInPath(searchParams: Record<string, SearchParamValue>): string {
18
+ const callbackUrl = getFirstSearchParam(searchParams.callbackUrl);
19
+ if (callbackUrl == null || callbackUrl.length === 0) {
20
+ return "/auth/signin";
21
+ }
22
+
23
+ return `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`;
24
+ }
25
+
26
+ export default async function SignUpPage({
27
+ searchParams,
28
+ }: {
29
+ searchParams?:
30
+ | Promise<Record<string, SearchParamValue>>
31
+ | Record<string, SearchParamValue>;
32
+ } = {}) {
33
+ const resolvedSearchParams = (await searchParams) ?? {};
12
34
  const session = await getServerSession();
13
35
 
14
36
  if (session?.user != null) {
15
37
  redirect("/");
16
38
  }
17
39
 
40
+ if (AUTH_MODE !== "username-password") {
41
+ redirect(getSignInPath(resolvedSearchParams));
42
+ }
43
+
18
44
  return (
19
45
  <Suspense
20
46
  fallback={
@@ -0,0 +1,20 @@
1
+ export type AuthMode = "username-password" | "google" | "okta";
2
+
3
+ const APP_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
4
+
5
+ function isAuthMode(value: string | undefined): value is AuthMode {
6
+ return (
7
+ value === "username-password" || value === "google" || value === "okta"
8
+ );
9
+ }
10
+
11
+ export function ensureAppAuthModeEnv(): AuthMode {
12
+ if (isAuthMode(process.env.AUTH_MODE)) {
13
+ return process.env.AUTH_MODE;
14
+ }
15
+
16
+ process.env.AUTH_MODE = APP_AUTH_MODE;
17
+ return APP_AUTH_MODE;
18
+ }
19
+
20
+ export const AUTH_MODE = ensureAppAuthModeEnv();
@@ -1,7 +1,8 @@
1
- import { auth } from "@__REPO_NAME__/auth";
1
+ import { auth, type BetterAuthSession } from "@__REPO_NAME__/auth";
2
2
  import { headers } from "next/headers";
3
+ import { AUTH_MODE, type AuthMode } from "./app-auth-mode";
3
4
 
4
- export { auth, type BetterAuthSession } from "@__REPO_NAME__/auth";
5
+ export { AUTH_MODE, auth, type AuthMode, type BetterAuthSession };
5
6
 
6
7
  export async function getServerSession() {
7
8
  return auth.api.getSession({
@@ -1,10 +1,12 @@
1
1
  import type { BetterAuthClientOptions } from "better-auth";
2
- import { adminClient } from "better-auth/client/plugins";
2
+ import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
3
3
  import { createAuthClient } from "better-auth/react";
4
4
 
5
5
  const adminPlugin: ReturnType<typeof adminClient> = adminClient();
6
+ const genericOAuthPlugin: ReturnType<typeof genericOAuthClient> =
7
+ genericOAuthClient();
6
8
  const options = {
7
- plugins: [adminPlugin],
9
+ plugins: [adminPlugin, genericOAuthPlugin],
8
10
  } satisfies BetterAuthClientOptions;
9
11
  export const authClient: ReturnType<typeof createAuthClient<typeof options>> =
10
12
  createAuthClient(options);