create-stackrjs 1.4.0

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.
Files changed (113) hide show
  1. package/README.md +154 -0
  2. package/dist/index.js +1135 -0
  3. package/package.json +62 -0
  4. package/template/architectures/microservices/_gitignore +26 -0
  5. package/template/architectures/microservices/apps/web/Dockerfile +11 -0
  6. package/template/architectures/microservices/apps/web/next-env.d.ts +5 -0
  7. package/template/architectures/microservices/apps/web/next.config.mjs +4 -0
  8. package/template/architectures/microservices/apps/web/package.json +25 -0
  9. package/template/architectures/microservices/apps/web/src/app/account/page.tsx +56 -0
  10. package/template/architectures/microservices/apps/web/src/app/globals.css +3 -0
  11. package/template/architectures/microservices/apps/web/src/app/layout.tsx +16 -0
  12. package/template/architectures/microservices/apps/web/src/app/login/page.tsx +70 -0
  13. package/template/architectures/microservices/apps/web/src/app/page.tsx +33 -0
  14. package/template/architectures/microservices/apps/web/src/app/register/page.tsx +71 -0
  15. package/template/architectures/microservices/apps/web/src/lib/api.ts +15 -0
  16. package/template/architectures/microservices/apps/web/tsconfig.json +23 -0
  17. package/template/architectures/microservices/docker-compose.yml +45 -0
  18. package/template/architectures/microservices/gateway/nginx.conf +20 -0
  19. package/template/architectures/microservices/package.json +21 -0
  20. package/template/architectures/microservices/services/api/Dockerfile +11 -0
  21. package/template/architectures/microservices/services/api/package.json +34 -0
  22. package/template/architectures/microservices/services/api/prisma/schema.prisma +17 -0
  23. package/template/architectures/microservices/services/api/prisma.config.ts +17 -0
  24. package/template/architectures/microservices/services/api/src/auth.ts +22 -0
  25. package/template/architectures/microservices/services/api/src/db.ts +12 -0
  26. package/template/architectures/microservices/services/api/src/index.ts +22 -0
  27. package/template/architectures/microservices/services/api/src/routes/auth.ts +73 -0
  28. package/template/architectures/microservices/services/api/tsconfig.json +16 -0
  29. package/template/base/_eslintrc.json +3 -0
  30. package/template/base/_gitignore +24 -0
  31. package/template/base/_prettierrc.json +6 -0
  32. package/template/base/next-env.d.ts +5 -0
  33. package/template/base/next.config.mjs +4 -0
  34. package/template/base/package.json +27 -0
  35. package/template/base/src/app/globals.css +14 -0
  36. package/template/base/src/app/layout.tsx +16 -0
  37. package/template/base/src/app/page.tsx +10 -0
  38. package/template/base/tsconfig.json +23 -0
  39. package/template/extras/account-better-auth/src/server/account.ts +52 -0
  40. package/template/extras/account-clerk/src/server/account.ts +64 -0
  41. package/template/extras/account-nextauth/src/server/account.ts +59 -0
  42. package/template/extras/account-supabase/src/lib/supabase/admin.ts +14 -0
  43. package/template/extras/account-supabase/src/server/account.ts +65 -0
  44. package/template/extras/admin/src/app/admin/page.tsx +55 -0
  45. package/template/extras/auth-pages/src/app/account/page.tsx +37 -0
  46. package/template/extras/auth-pages/src/app/forgot-password/page.tsx +33 -0
  47. package/template/extras/auth-pages/src/app/login/page.tsx +32 -0
  48. package/template/extras/auth-pages/src/app/page.tsx +46 -0
  49. package/template/extras/auth-pages/src/app/register/page.tsx +36 -0
  50. package/template/extras/auth-pages/src/app/reset-password/page.tsx +49 -0
  51. package/template/extras/auth-pages/src/components/auth/ui.tsx +57 -0
  52. package/template/extras/auth-pages-drizzle/src/server/actions.ts +122 -0
  53. package/template/extras/auth-pages-mongoose/src/server/actions.ts +121 -0
  54. package/template/extras/auth-pages-prisma/src/server/actions.ts +117 -0
  55. package/template/extras/better-auth/prisma/schema.prisma +70 -0
  56. package/template/extras/better-auth/src/app/account/page.tsx +42 -0
  57. package/template/extras/better-auth/src/app/api/auth/[...all]/route.ts +5 -0
  58. package/template/extras/better-auth/src/app/login/page.tsx +42 -0
  59. package/template/extras/better-auth/src/app/register/page.tsx +44 -0
  60. package/template/extras/better-auth/src/components/auth/ui.tsx +58 -0
  61. package/template/extras/better-auth/src/lib/auth-client.ts +7 -0
  62. package/template/extras/better-auth/src/server/auth.ts +13 -0
  63. package/template/extras/clerk/middleware.ts +14 -0
  64. package/template/extras/clerk/src/app/account/page.tsx +30 -0
  65. package/template/extras/clerk/src/app/layout.tsx +19 -0
  66. package/template/extras/clerk/src/app/page.tsx +40 -0
  67. package/template/extras/clerk/src/app/sign-in/[[...sign-in]]/page.tsx +9 -0
  68. package/template/extras/clerk/src/app/sign-up/[[...sign-up]]/page.tsx +9 -0
  69. package/template/extras/dashboard/src/app/dashboard/page.tsx +48 -0
  70. package/template/extras/databases/mongodb/docker-compose.yml +13 -0
  71. package/template/extras/databases/mysql/docker-compose.yml +14 -0
  72. package/template/extras/databases/postgres/docker-compose.yml +15 -0
  73. package/template/extras/drizzle/mysql/drizzle.config.ts +9 -0
  74. package/template/extras/drizzle/mysql/src/server/db.ts +11 -0
  75. package/template/extras/drizzle/mysql/src/server/schema.ts +17 -0
  76. package/template/extras/drizzle/postgres/drizzle.config.ts +9 -0
  77. package/template/extras/drizzle/postgres/src/server/db.ts +11 -0
  78. package/template/extras/drizzle/postgres/src/server/schema.ts +20 -0
  79. package/template/extras/landing/src/app/page.tsx +53 -0
  80. package/template/extras/mongoose/src/server/db.ts +12 -0
  81. package/template/extras/mongoose/src/server/models.ts +25 -0
  82. package/template/extras/nextauth/middleware.ts +11 -0
  83. package/template/extras/nextauth/src/app/api/auth/[...nextauth]/route.ts +3 -0
  84. package/template/extras/nextauth/src/server/auth.config.ts +39 -0
  85. package/template/extras/nextauth/src/server/roles.ts +5 -0
  86. package/template/extras/nextauth/src/types/next-auth.d.ts +22 -0
  87. package/template/extras/nextauth-drizzle/src/server/auth.ts +44 -0
  88. package/template/extras/nextauth-mongoose/src/server/auth.ts +42 -0
  89. package/template/extras/nextauth-prisma/src/server/auth.ts +43 -0
  90. package/template/extras/prisma/base/prisma/schema.prisma +67 -0
  91. package/template/extras/prisma/base/prisma.config.ts +10 -0
  92. package/template/extras/prisma/mysql/src/server/db.ts +25 -0
  93. package/template/extras/prisma/postgres/src/server/db.ts +18 -0
  94. package/template/extras/shadcn/components.json +20 -0
  95. package/template/extras/shadcn/postcss.config.mjs +7 -0
  96. package/template/extras/shadcn/src/app/globals.css +59 -0
  97. package/template/extras/shadcn/src/components/ui/button.tsx +42 -0
  98. package/template/extras/shadcn/src/components/ui/card.tsx +54 -0
  99. package/template/extras/shadcn/src/components/ui/input.tsx +19 -0
  100. package/template/extras/shadcn/src/lib/utils.ts +7 -0
  101. package/template/extras/shadcn/tailwind.config.ts +71 -0
  102. package/template/extras/supabase/middleware.ts +13 -0
  103. package/template/extras/supabase/src/app/account/page.tsx +32 -0
  104. package/template/extras/supabase/src/app/login/page.tsx +47 -0
  105. package/template/extras/supabase/src/app/register/page.tsx +52 -0
  106. package/template/extras/supabase/src/components/auth/sign-out-button.tsx +25 -0
  107. package/template/extras/supabase/src/components/auth/ui.tsx +58 -0
  108. package/template/extras/supabase/src/lib/supabase/client.ts +9 -0
  109. package/template/extras/supabase/src/lib/supabase/middleware.ts +34 -0
  110. package/template/extras/supabase/src/lib/supabase/server.ts +29 -0
  111. package/template/extras/tailwind/postcss.config.mjs +7 -0
  112. package/template/extras/tailwind/src/app/globals.css +3 -0
  113. package/template/extras/tailwind/tailwind.config.ts +9 -0
@@ -0,0 +1,73 @@
1
+ import { Hono, type Context } from "hono";
2
+ import { getCookie, setCookie, deleteCookie } from "hono/cookie";
3
+ import bcrypt from "bcryptjs";
4
+ import { z } from "zod";
5
+ import { prisma } from "../db.js";
6
+ import { signSession, verifySession } from "../auth.js";
7
+
8
+ const credentials = z.object({
9
+ email: z.string().email(),
10
+ password: z.string().min(8),
11
+ });
12
+
13
+ const SESSION_COOKIE = "session";
14
+ const WEEK_SECONDS = 60 * 60 * 24 * 7;
15
+
16
+ function issueSession(c: Context, token: string) {
17
+ setCookie(c, SESSION_COOKIE, token, {
18
+ httpOnly: true,
19
+ sameSite: "Lax",
20
+ path: "/",
21
+ maxAge: WEEK_SECONDS,
22
+ });
23
+ }
24
+
25
+ export const authRoutes = new Hono();
26
+
27
+ authRoutes.post("/register", async (c) => {
28
+ const parsed = credentials.safeParse(await c.req.json().catch(() => null));
29
+ if (!parsed.success) {
30
+ return c.json({ error: "Invalid email or password (min 8 characters)." }, 400);
31
+ }
32
+
33
+ const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } });
34
+ if (existing) return c.json({ error: "Email already registered." }, 409);
35
+
36
+ const passwordHash = await bcrypt.hash(parsed.data.password, 10);
37
+ const user = await prisma.user.create({ data: { email: parsed.data.email, passwordHash } });
38
+
39
+ issueSession(c, await signSession(user.id));
40
+ return c.json({ id: user.id, email: user.email });
41
+ });
42
+
43
+ authRoutes.post("/login", async (c) => {
44
+ const parsed = credentials.safeParse(await c.req.json().catch(() => null));
45
+ if (!parsed.success) return c.json({ error: "Invalid credentials." }, 400);
46
+
47
+ const user = await prisma.user.findUnique({ where: { email: parsed.data.email } });
48
+ if (!user || !(await bcrypt.compare(parsed.data.password, user.passwordHash))) {
49
+ return c.json({ error: "Invalid credentials." }, 401);
50
+ }
51
+
52
+ issueSession(c, await signSession(user.id));
53
+ return c.json({ id: user.id, email: user.email });
54
+ });
55
+
56
+ authRoutes.post("/logout", (c) => {
57
+ deleteCookie(c, SESSION_COOKIE, { path: "/" });
58
+ return c.json({ ok: true });
59
+ });
60
+
61
+ authRoutes.get("/me", async (c) => {
62
+ const token = getCookie(c, SESSION_COOKIE);
63
+ const userId = token ? await verifySession(token) : null;
64
+ if (!userId) return c.json({ error: "Unauthenticated." }, 401);
65
+
66
+ const user = await prisma.user.findUnique({
67
+ where: { id: userId },
68
+ select: { id: true, email: true },
69
+ });
70
+ if (!user) return c.json({ error: "Unauthenticated." }, 401);
71
+
72
+ return c.json(user);
73
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "outDir": "dist",
13
+ "rootDir": "src"
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "next/core-web-vitals"
3
+ }
@@ -0,0 +1,24 @@
1
+ node_modules
2
+ .pnp
3
+ .pnp.js
4
+
5
+ # next.js
6
+ .next/
7
+ out/
8
+ build/
9
+
10
+ # misc
11
+ .DS_Store
12
+ *.pem
13
+ *.log
14
+
15
+ # env files
16
+ .env
17
+ .env*.local
18
+
19
+ # typescript
20
+ next-env.d.ts
21
+ *.tsbuildinfo
22
+
23
+ # generated prisma client (Prisma 7 generates into the project)
24
+ /src/generated
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": false,
4
+ "trailingComma": "all",
5
+ "printWidth": 100
6
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,4 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {};
3
+
4
+ export default nextConfig;
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint",
10
+ "format": "prettier --write .",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "next": "^15.1.3",
15
+ "react": "^18.3.1",
16
+ "react-dom": "^18.3.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.14.0",
20
+ "@types/react": "^18.3.12",
21
+ "@types/react-dom": "^18.3.1",
22
+ "eslint": "^8.57.1",
23
+ "eslint-config-next": "^15.1.3",
24
+ "prettier": "^3.4.2",
25
+ "typescript": "^5.7.2"
26
+ }
27
+ }
@@ -0,0 +1,14 @@
1
+ /* Base styles. Replaced by the Tailwind installer when Tailwind is selected. */
2
+ * {
3
+ box-sizing: border-box;
4
+ }
5
+
6
+ html,
7
+ body {
8
+ margin: 0;
9
+ padding: 0;
10
+ font-family:
11
+ system-ui,
12
+ -apple-system,
13
+ sans-serif;
14
+ }
@@ -0,0 +1,16 @@
1
+ import "./globals.css";
2
+ import type { Metadata } from "next";
3
+ import type { ReactNode } from "react";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Stackr app",
7
+ description: "Generated by Stackr",
8
+ };
9
+
10
+ export default function RootLayout({ children }: { children: ReactNode }) {
11
+ return (
12
+ <html lang="en">
13
+ <body className="min-h-screen bg-gray-50 text-gray-900 antialiased">{children}</body>
14
+ </html>
15
+ );
16
+ }
@@ -0,0 +1,10 @@
1
+ export default function Home() {
2
+ return (
3
+ <main className="mx-auto flex min-h-screen max-w-2xl flex-col items-center justify-center gap-6 p-8 text-center">
4
+ <h1 className="text-4xl font-bold tracking-tight">It works 🎉</h1>
5
+ <p className="text-gray-600">
6
+ Your Stackr-generated app is running. Edit <code className="rounded bg-gray-200 px-1">src/app/page.tsx</code> to get started.
7
+ </p>
8
+ </main>
9
+ );
10
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,52 @@
1
+ import { headers } from "next/headers";
2
+ import { auth } from "@/server/auth";
3
+ import { db } from "@/server/db";
4
+
5
+ /**
6
+ * Account adapter for Better Auth on Prisma. Provides the session + user-data
7
+ * primitives consumed by the dashboard / admin interfaces.
8
+ *
9
+ * Better Auth's core schema has no role column, so admin access is granted via
10
+ * the `ADMIN_EMAILS` allowlist (comma-separated emails).
11
+ */
12
+ export const SIGN_IN_PATH = "/login";
13
+
14
+ export type AccountUser = {
15
+ id: string;
16
+ name: string | null;
17
+ email: string | null;
18
+ role: "USER" | "ADMIN";
19
+ };
20
+
21
+ const adminEmails = (process.env.ADMIN_EMAILS ?? "")
22
+ .split(",")
23
+ .map((e) => e.trim().toLowerCase())
24
+ .filter(Boolean);
25
+
26
+ function resolveRole(email: string | null): "USER" | "ADMIN" {
27
+ return email && adminEmails.includes(email.toLowerCase()) ? "ADMIN" : "USER";
28
+ }
29
+
30
+ function toAccountUser(u: { id: string; name?: string | null; email?: string | null }): AccountUser {
31
+ const email = u.email ?? null;
32
+ return { id: u.id, name: u.name ?? null, email, role: resolveRole(email) };
33
+ }
34
+
35
+ export async function getSessionUser(): Promise<AccountUser | null> {
36
+ const session = await auth.api.getSession({ headers: await headers() });
37
+ return session?.user ? toAccountUser(session.user) : null;
38
+ }
39
+
40
+ export async function getUserStats(): Promise<{ users: number; sessions: number | null }> {
41
+ const [users, sessions] = await Promise.all([db.user.count(), db.session.count()]);
42
+ return { users, sessions };
43
+ }
44
+
45
+ export async function listRecentUsers(limit: number): Promise<AccountUser[]> {
46
+ const users = await db.user.findMany({
47
+ orderBy: { createdAt: "desc" },
48
+ take: limit,
49
+ select: { id: true, name: true, email: true },
50
+ });
51
+ return users.map(toAccountUser);
52
+ }
@@ -0,0 +1,64 @@
1
+ import { currentUser, clerkClient } from "@clerk/nextjs/server";
2
+
3
+ /**
4
+ * Account adapter for Clerk. Clerk manages users itself, so the dashboard / admin
5
+ * interfaces read from the Clerk API rather than the app database.
6
+ *
7
+ * Admin access uses Clerk's `publicMetadata.role === "admin"`, falling back to the
8
+ * `ADMIN_EMAILS` allowlist. (Active session counts aren't exposed, so the
9
+ * dashboard shows "—" for sessions.)
10
+ */
11
+ export const SIGN_IN_PATH = "/sign-in";
12
+
13
+ export type AccountUser = {
14
+ id: string;
15
+ name: string | null;
16
+ email: string | null;
17
+ role: "USER" | "ADMIN";
18
+ };
19
+
20
+ type ClerkLike = {
21
+ id: string;
22
+ firstName: string | null;
23
+ lastName: string | null;
24
+ emailAddresses: { emailAddress: string }[];
25
+ publicMetadata: unknown;
26
+ };
27
+
28
+ const adminEmails = (process.env.ADMIN_EMAILS ?? "")
29
+ .split(",")
30
+ .map((e) => e.trim().toLowerCase())
31
+ .filter(Boolean);
32
+
33
+ function nameOf(u: ClerkLike): string | null {
34
+ return [u.firstName, u.lastName].filter(Boolean).join(" ") || null;
35
+ }
36
+
37
+ function resolveRole(email: string | null, metadata: unknown): "USER" | "ADMIN" {
38
+ if ((metadata as { role?: string } | null)?.role === "admin") return "ADMIN";
39
+ if (email && adminEmails.includes(email.toLowerCase())) return "ADMIN";
40
+ return "USER";
41
+ }
42
+
43
+ function toAccountUser(u: ClerkLike): AccountUser {
44
+ const email = u.emailAddresses[0]?.emailAddress ?? null;
45
+ return { id: u.id, name: nameOf(u), email, role: resolveRole(email, u.publicMetadata) };
46
+ }
47
+
48
+ export async function getSessionUser(): Promise<AccountUser | null> {
49
+ const user = await currentUser();
50
+ if (!user) return null;
51
+ return toAccountUser(user as unknown as ClerkLike);
52
+ }
53
+
54
+ export async function getUserStats(): Promise<{ users: number; sessions: number | null }> {
55
+ const client = await clerkClient();
56
+ const users = await client.users.getCount();
57
+ return { users, sessions: null };
58
+ }
59
+
60
+ export async function listRecentUsers(limit: number): Promise<AccountUser[]> {
61
+ const client = await clerkClient();
62
+ const { data } = await client.users.getUserList({ limit, orderBy: "-created_at" });
63
+ return data.map((u) => toAccountUser(u as unknown as ClerkLike));
64
+ }
@@ -0,0 +1,59 @@
1
+ import { auth } from "@/server/auth";
2
+ import { db } from "@/server/db";
3
+
4
+ /**
5
+ * Account adapter for NextAuth (Auth.js) on Prisma. Provides the session +
6
+ * user-data primitives consumed by the dashboard / admin interfaces, so those
7
+ * pages stay identical across every auth provider.
8
+ *
9
+ * Roles use the native Prisma `role` column, falling back to the `ADMIN_EMAILS`
10
+ * allowlist so you can grant admin access without touching the database.
11
+ */
12
+ export const SIGN_IN_PATH = "/login";
13
+
14
+ export type AccountUser = {
15
+ id: string;
16
+ name: string | null;
17
+ email: string | null;
18
+ role: "USER" | "ADMIN";
19
+ };
20
+
21
+ const adminEmails = (process.env.ADMIN_EMAILS ?? "")
22
+ .split(",")
23
+ .map((e) => e.trim().toLowerCase())
24
+ .filter(Boolean);
25
+
26
+ function resolveRole(email: string | null, nativeRole?: string | null): "USER" | "ADMIN" {
27
+ if (nativeRole === "ADMIN") return "ADMIN";
28
+ if (email && adminEmails.includes(email.toLowerCase())) return "ADMIN";
29
+ return "USER";
30
+ }
31
+
32
+ function toAccountUser(u: {
33
+ id?: string;
34
+ name?: string | null;
35
+ email?: string | null;
36
+ role?: string | null;
37
+ }): AccountUser {
38
+ const email = u.email ?? null;
39
+ return { id: u.id ?? "", name: u.name ?? null, email, role: resolveRole(email, u.role) };
40
+ }
41
+
42
+ export async function getSessionUser(): Promise<AccountUser | null> {
43
+ const session = await auth();
44
+ return session?.user ? toAccountUser(session.user) : null;
45
+ }
46
+
47
+ export async function getUserStats(): Promise<{ users: number; sessions: number | null }> {
48
+ const [users, sessions] = await Promise.all([db.user.count(), db.session.count()]);
49
+ return { users, sessions };
50
+ }
51
+
52
+ export async function listRecentUsers(limit: number): Promise<AccountUser[]> {
53
+ const users = await db.user.findMany({
54
+ orderBy: { createdAt: "desc" },
55
+ take: limit,
56
+ select: { id: true, name: true, email: true, role: true },
57
+ });
58
+ return users.map(toAccountUser);
59
+ }
@@ -0,0 +1,14 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+
3
+ /**
4
+ * Service-role Supabase client for privileged, server-only operations (e.g.
5
+ * listing users for the admin page). NEVER import this from client components —
6
+ * the service-role key bypasses Row Level Security.
7
+ */
8
+ export function createAdminClient() {
9
+ return createClient(
10
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
11
+ process.env.SUPABASE_SERVICE_ROLE_KEY!,
12
+ { auth: { autoRefreshToken: false, persistSession: false } },
13
+ );
14
+ }
@@ -0,0 +1,65 @@
1
+ import { createClient } from "@/lib/supabase/server";
2
+ import { createAdminClient } from "@/lib/supabase/admin";
3
+
4
+ /**
5
+ * Account adapter for Supabase Auth. The current session is read with the
6
+ * request-scoped client; global stats / the user list use a service-role admin
7
+ * client (`SUPABASE_SERVICE_ROLE_KEY`).
8
+ *
9
+ * Supabase has no role column by default, so admin access uses
10
+ * `app_metadata.role === "admin"` with the `ADMIN_EMAILS` allowlist as fallback.
11
+ */
12
+ export const SIGN_IN_PATH = "/login";
13
+
14
+ export type AccountUser = {
15
+ id: string;
16
+ name: string | null;
17
+ email: string | null;
18
+ role: "USER" | "ADMIN";
19
+ };
20
+
21
+ const adminEmails = (process.env.ADMIN_EMAILS ?? "")
22
+ .split(",")
23
+ .map((e) => e.trim().toLowerCase())
24
+ .filter(Boolean);
25
+
26
+ function resolveRole(email: string | null, appRole: unknown): "USER" | "ADMIN" {
27
+ if (appRole === "admin") return "ADMIN";
28
+ if (email && adminEmails.includes(email.toLowerCase())) return "ADMIN";
29
+ return "USER";
30
+ }
31
+
32
+ function toAccountUser(u: {
33
+ id: string;
34
+ email?: string | null;
35
+ user_metadata?: Record<string, unknown> | null;
36
+ app_metadata?: Record<string, unknown> | null;
37
+ }): AccountUser {
38
+ const email = u.email ?? null;
39
+ return {
40
+ id: u.id,
41
+ name: (u.user_metadata?.name as string | undefined) ?? null,
42
+ email,
43
+ role: resolveRole(email, u.app_metadata?.role),
44
+ };
45
+ }
46
+
47
+ export async function getSessionUser(): Promise<AccountUser | null> {
48
+ const supabase = await createClient();
49
+ const {
50
+ data: { user },
51
+ } = await supabase.auth.getUser();
52
+ return user ? toAccountUser(user) : null;
53
+ }
54
+
55
+ export async function getUserStats(): Promise<{ users: number; sessions: number | null }> {
56
+ const admin = createAdminClient();
57
+ const { data } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 });
58
+ return { users: data.users.length, sessions: null };
59
+ }
60
+
61
+ export async function listRecentUsers(limit: number): Promise<AccountUser[]> {
62
+ const admin = createAdminClient();
63
+ const { data } = await admin.auth.admin.listUsers({ page: 1, perPage: limit });
64
+ return data.users.map(toAccountUser);
65
+ }
@@ -0,0 +1,55 @@
1
+ import { redirect } from "next/navigation";
2
+ import { getSessionUser, listRecentUsers, SIGN_IN_PATH } from "@/server/account";
3
+
4
+ /**
5
+ * Admin / back-office. Protected and role-gated: only ADMIN users see the user
6
+ * list. Roles come from the provider's native role where available, otherwise the
7
+ * `ADMIN_EMAILS` allowlist (see `@/server/account`). Data + session are provided
8
+ * by the per-provider account adapter, so this page is the same for every auth.
9
+ */
10
+ export default async function AdminPage() {
11
+ const user = await getSessionUser();
12
+ if (!user) redirect(SIGN_IN_PATH);
13
+
14
+ if (user.role !== "ADMIN") {
15
+ return (
16
+ <main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-3 p-8 text-center">
17
+ <h1 className="text-2xl font-semibold tracking-tight">Admins only</h1>
18
+ <p className="text-sm text-gray-500">
19
+ Your account isn’t an admin. Add your email to <code>ADMIN_EMAILS</code> (or set your
20
+ provider role to <code>admin</code>) to access this page.
21
+ </p>
22
+ </main>
23
+ );
24
+ }
25
+
26
+ const users = await listRecentUsers(50);
27
+
28
+ return (
29
+ <main className="mx-auto max-w-4xl p-8">
30
+ <h1 className="mb-1 text-2xl font-semibold tracking-tight">Admin</h1>
31
+ <p className="mb-6 text-sm text-gray-500">{users.length} most recent users</p>
32
+
33
+ <div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
34
+ <table className="w-full text-left text-sm">
35
+ <thead className="border-b border-gray-200 bg-gray-50 text-gray-500">
36
+ <tr>
37
+ <th className="px-4 py-2 font-medium">Name</th>
38
+ <th className="px-4 py-2 font-medium">Email</th>
39
+ <th className="px-4 py-2 font-medium">Role</th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ {users.map((u) => (
44
+ <tr key={u.id} className="border-b border-gray-100 last:border-0">
45
+ <td className="px-4 py-2">{u.name ?? "—"}</td>
46
+ <td className="px-4 py-2">{u.email ?? "—"}</td>
47
+ <td className="px-4 py-2">{u.role}</td>
48
+ </tr>
49
+ ))}
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+ </main>
54
+ );
55
+ }
@@ -0,0 +1,37 @@
1
+ import { redirect } from "next/navigation";
2
+ import { auth } from "@/server/auth";
3
+ import { signOutAction } from "@/server/actions";
4
+ import { SubmitButton } from "@/components/auth/ui";
5
+
6
+ // Protected by middleware.ts; we re-check here so the page is safe on its own.
7
+ export default async function AccountPage() {
8
+ const session = await auth();
9
+ if (!session?.user) redirect("/login");
10
+
11
+ return (
12
+ <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 p-8">
13
+ <div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
14
+ <h1 className="mb-4 text-2xl font-semibold tracking-tight">Your account</h1>
15
+ <dl className="space-y-2 text-sm">
16
+ <div className="flex justify-between">
17
+ <dt className="text-gray-500">Name</dt>
18
+ <dd className="font-medium">{session.user.name ?? "—"}</dd>
19
+ </div>
20
+ <div className="flex justify-between">
21
+ <dt className="text-gray-500">Email</dt>
22
+ <dd className="font-medium">{session.user.email}</dd>
23
+ </div>
24
+ <div className="flex justify-between">
25
+ <dt className="text-gray-500">Role</dt>
26
+ <dd className="font-medium">{session.user.role ?? "USER"}</dd>
27
+ </div>
28
+ </dl>
29
+ <div className="mt-6">
30
+ <form action={signOutAction}>
31
+ <SubmitButton>Sign out</SubmitButton>
32
+ </form>
33
+ </div>
34
+ </div>
35
+ </main>
36
+ );
37
+ }
@@ -0,0 +1,33 @@
1
+ import { requestPasswordResetAction } from "@/server/actions";
2
+ import { Alert, AuthShell, Field, FooterLinks, SubmitButton, TextLink } from "@/components/auth/ui";
3
+
4
+ export default async function ForgotPasswordPage({
5
+ searchParams,
6
+ }: {
7
+ searchParams: Promise<{ error?: string; sent?: string }>;
8
+ }) {
9
+ const sp = await searchParams;
10
+
11
+ return (
12
+ <AuthShell title="Reset password">
13
+ {sp.sent && (
14
+ <Alert kind="success">
15
+ If an account exists for that email, a reset link has been sent. In dev mode the link is
16
+ printed to the server console.
17
+ </Alert>
18
+ )}
19
+ {sp.error && <Alert kind="error">{sp.error}</Alert>}
20
+
21
+ <form action={requestPasswordResetAction}>
22
+ <Field label="Email" name="email" type="email" autoComplete="email" required />
23
+ <SubmitButton>Send reset link</SubmitButton>
24
+ </form>
25
+
26
+ <FooterLinks>
27
+ <p>
28
+ <TextLink href="/login">Back to sign in</TextLink>
29
+ </p>
30
+ </FooterLinks>
31
+ </AuthShell>
32
+ );
33
+ }
@@ -0,0 +1,32 @@
1
+ import { loginAction } from "@/server/actions";
2
+ import { Alert, AuthShell, Field, FooterLinks, SubmitButton, TextLink } from "@/components/auth/ui";
3
+
4
+ export default async function LoginPage({
5
+ searchParams,
6
+ }: {
7
+ searchParams: Promise<{ error?: string; reset?: string }>;
8
+ }) {
9
+ const sp = await searchParams;
10
+
11
+ return (
12
+ <AuthShell title="Sign in">
13
+ {sp.reset && <Alert kind="success">Password updated. Sign in with your new password.</Alert>}
14
+ {sp.error && <Alert kind="error">{sp.error}</Alert>}
15
+
16
+ <form action={loginAction}>
17
+ <Field label="Email" name="email" type="email" autoComplete="email" required />
18
+ <Field label="Password" name="password" type="password" autoComplete="current-password" required />
19
+ <SubmitButton>Sign in</SubmitButton>
20
+ </form>
21
+
22
+ <FooterLinks>
23
+ <p>
24
+ No account? <TextLink href="/register">Create one</TextLink>
25
+ </p>
26
+ <p>
27
+ <TextLink href="/forgot-password">Forgot your password?</TextLink>
28
+ </p>
29
+ </FooterLinks>
30
+ </AuthShell>
31
+ );
32
+ }