@percepta/create 4.1.12 → 4.1.14
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.
- package/dist/index.js +158 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/monorepo/auth/package.json +1 -0
- package/templates/monorepo/auth/src/auth.ts +73 -0
- package/templates/webapp/AGENTS.md +1 -1
- package/templates/webapp/README.md +40 -7
- package/templates/webapp/env.example.template +11 -0
- package/templates/webapp/scripts/seed.ts +61 -24
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +102 -54
- package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +2 -2
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +28 -2
- package/templates/webapp/src/lib/auth/app-auth-mode.ts +20 -0
- package/templates/webapp/src/lib/auth/index.ts +3 -2
- package/templates/webapp/src/lib/auth-client.ts +4 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
168
|
-
# Creates app-admin@example.com
|
|
169
|
-
# Creates app-user@example.com
|
|
170
|
-
# Creates non-user@example.com
|
|
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
|
+
interface 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})
|
|
112
|
+
`Seed user "${seededUser.email}" already exists (id: ${existing.id}).`,
|
|
90
113
|
);
|
|
91
114
|
} else {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
<
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
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);
|