@yosr-ai/template-next-fullstack 0.0.0-stage → 0.1.0-alpha.1
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/CHANGELOG.md +15 -0
- package/README.md +15 -2
- package/features/api-trpc/files/src/app/api/trpc/[trpc]/route.ts +15 -0
- package/features/api-trpc/files/src/app/trpc-provider.tsx +27 -0
- package/features/api-trpc/files/src/features/notes/components/note-form.tsx +48 -0
- package/features/api-trpc/files/src/server/api/context.ts +8 -0
- package/features/api-trpc/files/src/server/api/notes.test.ts +30 -0
- package/features/api-trpc/files/src/server/api/notes.ts +42 -0
- package/features/api-trpc/files/src/server/api/root.ts +8 -0
- package/features/api-trpc/files/src/server/api/trpc.ts +8 -0
- package/features/api-trpc/snippets/providers-imports.tsx +2 -0
- package/features/api-trpc/snippets/providers.tsx +1 -0
- package/features/auth-authjs/files/e2e/sign-in.spec.ts +10 -0
- package/features/auth-authjs/files/src/app/api/auth/[...nextauth]/route.ts +13 -0
- package/features/auth-authjs/files/src/app/auth-session-provider.tsx +8 -0
- package/features/auth-authjs/files/src/app/sign-in/page.tsx +19 -0
- package/features/auth-authjs/files/src/server/auth/config.ts +28 -0
- package/features/auth-authjs/files/src/server/auth/index.ts +15 -0
- package/features/auth-authjs/snippets/providers-imports.tsx +2 -0
- package/features/auth-authjs/snippets/providers.tsx +1 -0
- package/features/auth-authjs-drizzle/files/src/server/db/auth-tables.ts +86 -0
- package/features/auth-authjs-drizzle/snippets/adapter-db-imports.ts +8 -0
- package/features/auth-authjs-drizzle/snippets/adapter-imports.ts +1 -0
- package/features/auth-authjs-drizzle/snippets/adapter.ts +7 -0
- package/features/auth-authjs-drizzle/snippets/auth-tables.ts +7 -0
- package/features/auth-authjs-prisma/snippets/adapter-db-imports.ts +1 -0
- package/features/auth-authjs-prisma/snippets/adapter-imports.ts +1 -0
- package/features/auth-authjs-prisma/snippets/adapter.ts +1 -0
- package/features/auth-authjs-prisma/snippets/auth-models.prisma +58 -0
- package/features/auth-clerk/files/e2e/sign-in.spec.ts +15 -0
- package/features/auth-clerk/files/src/app/clerk-app-provider.tsx +13 -0
- package/features/auth-clerk/files/src/app/sign-in/[[...sign-in]]/page.tsx +18 -0
- package/features/auth-clerk/files/src/app/sign-up/[[...sign-up]]/page.tsx +18 -0
- package/features/auth-clerk/files/src/proxy.ts +24 -0
- package/features/auth-clerk/files/src/server/auth/index.ts +11 -0
- package/features/auth-clerk/snippets/providers-imports.tsx +2 -0
- package/features/auth-clerk/snippets/providers.tsx +1 -0
- package/features/deploy-docker/files/.dockerignore +6 -0
- package/features/deploy-docker/files/Dockerfile +26 -0
- package/features/deploy-docker/files/compose.yaml +6 -0
- package/features/deploy-docker-npm/files/Dockerfile +26 -0
- package/features/deploy-docker-postgres/snippets/postgres-service.yaml +4 -0
- package/features/deploy-vercel/snippets/readme-deploy.md +3 -0
- package/features/orm-drizzle/files/drizzle.config.ts +8 -0
- package/features/orm-drizzle/files/src/features/notes/notes-store.ts +21 -0
- package/features/orm-drizzle/files/src/server/db/index.ts +20 -0
- package/features/orm-drizzle/files/src/server/db/schema.ts +11 -0
- package/features/orm-drizzle/snippets/ci-e2e-setup.yaml +1 -0
- package/features/orm-drizzle/snippets/ci-job.yaml +11 -0
- package/features/orm-drizzle/snippets/readme-db.md +4 -0
- package/features/orm-prisma/files/prisma/schema.prisma +18 -0
- package/features/orm-prisma/files/prisma.config.ts +13 -0
- package/features/orm-prisma/files/src/features/notes/notes-store.ts +13 -0
- package/features/orm-prisma/files/src/server/db/index.ts +20 -0
- package/features/orm-prisma/snippets/ci-e2e-setup.yaml +1 -0
- package/features/orm-prisma/snippets/ci-job.yaml +11 -0
- package/features/orm-prisma/snippets/readme-db.md +4 -0
- package/features/payments-stripe/files/src/app/api/checkout/route.ts +27 -0
- package/features/payments-stripe/files/src/app/api/webhooks/stripe/route.ts +18 -0
- package/features/payments-stripe/files/src/server/billing/billing.test.ts +113 -0
- package/features/payments-stripe/files/src/server/billing/index.ts +99 -0
- package/features/payments-stripe/files/src/server/billing/stripe.ts +10 -0
- package/features/payments-stripe-drizzle/files/src/server/billing/store.ts +39 -0
- package/features/payments-stripe-drizzle/snippets/billing-tables.ts +13 -0
- package/features/payments-stripe-prisma/files/src/server/billing/store.ts +30 -0
- package/features/payments-stripe-prisma/snippets/billing-models.prisma +17 -0
- package/files/.env.example +1 -0
- package/files/.github/workflows/ci.yml +25 -0
- package/files/.prettierignore +7 -0
- package/files/.prettierrc.json +1 -0
- package/files/README.md +18 -0
- package/files/components.json +20 -0
- package/files/e2e/smoke.spec.ts +16 -0
- package/files/eslint.config.mjs +21 -0
- package/files/gitignore +25 -0
- package/files/next.config.ts +7 -0
- package/files/package.json +42 -0
- package/files/playwright.config.ts +20 -0
- package/files/pnpm-workspace.yaml +16 -0
- package/files/postcss.config.mjs +7 -0
- package/files/src/app/globals.css +68 -0
- package/files/src/app/layout.tsx +22 -0
- package/files/src/app/page.tsx +21 -0
- package/files/src/app/providers.tsx +15 -0
- package/files/src/components/ui/button.tsx +59 -0
- package/files/src/components/ui/input.tsx +21 -0
- package/files/src/features/notes/actions.ts +22 -0
- package/files/src/features/notes/components/note-form.tsx +32 -0
- package/files/src/features/notes/components/note-list.tsx +23 -0
- package/files/src/features/notes/create-note.test.ts +27 -0
- package/files/src/features/notes/create-note.ts +23 -0
- package/files/src/features/notes/note-schema.ts +13 -0
- package/files/src/features/notes/notes-store.ts +17 -0
- package/files/src/shared/utils.test.ts +13 -0
- package/files/src/shared/utils.ts +6 -0
- package/files/tsconfig.json +35 -0
- package/files/vitest.config.mts +9 -0
- package/package.json +26 -4
- package/rules/api-server-actions.md +1 -0
- package/rules/api-trpc.md +1 -0
- package/rules/architecture.md +11 -0
- package/rules/auth-authjs.md +1 -0
- package/rules/auth-clerk.md +1 -0
- package/rules/commands.md +5 -0
- package/rules/data-drizzle.md +1 -0
- package/rules/data-none.md +1 -0
- package/rules/data-prisma.md +1 -0
- package/rules/data-server-first.md +1 -0
- package/rules/errors-result.md +1 -0
- package/rules/errors-throw.md +1 -0
- package/rules/forms-zod.md +1 -0
- package/rules/layout-feature.md +5 -0
- package/rules/layout-flat.md +5 -0
- package/rules/layout-layer.md +6 -0
- package/rules/payments-stripe.md +1 -0
- package/rules/project-overview.md +5 -0
- package/rules/tests-colocated.md +1 -0
- package/rules/tests-folder.md +1 -0
- package/rules/tests-separate.md +1 -0
- package/rules/ui-shadcn.md +1 -0
- package/seed.template.json +1721 -0
- package/style/errors-result/files/src/features/notes/actions.ts +18 -0
- package/style/errors-result/files/src/features/notes/create-note.test.ts +30 -0
- package/style/errors-result/files/src/features/notes/create-note.ts +16 -0
- package/style/errors-result/files/src/shared/result.ts +12 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @yosr-ai/template-next-fullstack
|
|
2
|
+
|
|
3
|
+
## 0.1.0-alpha.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- An interactive Yosr Seed run closes with the next command when the gate is green, and points at `.seed/report.md` when the project was still written. On a terminal, the written folder, git, and the gate stay inside the prompt rail. The published Yosr Seed package rewrites every bundled `@yosr-ai/engine` import.
|
|
8
|
+
- Generated Yosr Seed projects record the choices made at setup and ask before departing from them. Flat aliases and unit-test paths match the layout, the home page follows the database choice, and standalone output is only for the Docker image build.
|
|
9
|
+
- Generated Yosr Seed projects pass every `ts.typeDefinitions` setting. Flat layout names `src/lib/server/` as the server-infrastructure area, with one level for `db/` and `auth/`.
|
|
10
|
+
|
|
11
|
+
## 0.1.0-alpha.0
|
|
12
|
+
|
|
13
|
+
### Major Changes
|
|
14
|
+
|
|
15
|
+
- First alpha of the Yosr Seed next-fullstack template.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@yosr-ai/template-next-fullstack`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Yosr Seed next-fullstack template. License: MIT. Node.js: `>=24`. CLI usage is in the [repository README](https://github.com/getyosr/yosr-seed#readme).
|
|
4
|
+
|
|
5
|
+
For template authors; not rendered. Layout follows the template format spec.
|
|
6
|
+
|
|
7
|
+
- `seed.template.json`: manifest. Started from `examples/next-fullstack/seed.template.json`; dependency versions pinned from the npm registry on 2026-10-19. `x-prototype.stubbedFeatures` is empty. The selftest stub is fixture-only.
|
|
8
|
+
- `files/`: the base. It is a real Next.js app that installs, lints, type-checks, builds and tests on its own (checked). Files sit at the **default** layout's concrete paths (`src/features`, `src/shared`, `src/server`); the renderer relocates them for other layouts. `gitignore` is renamed to `.gitignore` on render (npm strips `.gitignore` from published packages).
|
|
9
|
+
- `features/orm-drizzle/`: Postgres + Drizzle overlay (replaces the in-memory notes store). `database: none` is the base without this overlay.
|
|
10
|
+
- `features/orm-prisma/`: Postgres + Prisma overlay (same role as Drizzle: schema, client, notes store via `replaces`). `prisma generate` runs on install. The generated client is not committed.
|
|
11
|
+
- `features/deploy-vercel/`: README section only.
|
|
12
|
+
- `features/deploy-docker/`: Dockerfile, `.dockerignore`, and `compose.yaml` for `deploy: docker`. The `next-config` anchor turns on `output: "standalone"` only when `YOSR_STANDALONE=1`, which the Dockerfile sets for the image build. Local `next start` does not use standalone output. `features/deploy-docker-postgres/` adds a Postgres service to that compose file when `database` is `postgres`. The image build is a non-required gate step (`--image`).
|
|
13
|
+
- `style/errors-result/`: variant tree for `errors.pattern = result`.
|
|
14
|
+
- `rules/`: agent-rule fragments and the architecture note source.
|
|
15
|
+
|
|
16
|
+
Anchors (`@seed:anchor <name>`) must be alone on their line; inserted content takes the anchor line's indentation; anchors are removed from output.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
|
2
|
+
|
|
3
|
+
import { createContext } from "@server/api/context";
|
|
4
|
+
import { appRouter } from "@server/api/root";
|
|
5
|
+
|
|
6
|
+
function handler(req: Request) {
|
|
7
|
+
return fetchRequestHandler({
|
|
8
|
+
endpoint: "/api/trpc",
|
|
9
|
+
req,
|
|
10
|
+
router: appRouter,
|
|
11
|
+
createContext: () => createContext(),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { handler as GET, handler as POST };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { httpBatchLink } from "@trpc/client";
|
|
5
|
+
import { createTRPCReact } from "@trpc/react-query";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
|
|
9
|
+
import type { AppRouter } from "@server/api/root";
|
|
10
|
+
|
|
11
|
+
export const trpc = createTRPCReact<AppRouter>();
|
|
12
|
+
|
|
13
|
+
/** One registry entry: tRPC client inside React Query. */
|
|
14
|
+
export function TrpcProvider({ children }: { children: ReactNode }) {
|
|
15
|
+
const [queryClient] = useState(() => new QueryClient());
|
|
16
|
+
const [trpcClient] = useState(() =>
|
|
17
|
+
trpc.createClient({
|
|
18
|
+
links: [httpBatchLink({ url: "/api/trpc" })],
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
|
24
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
25
|
+
</trpc.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { trpc } from "@/app/trpc-provider";
|
|
7
|
+
import { Button } from "@ui/button";
|
|
8
|
+
import { Input } from "@ui/input";
|
|
9
|
+
|
|
10
|
+
export function NoteForm() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const [title, setTitle] = useState("");
|
|
13
|
+
const create = trpc.notes.create.useMutation({
|
|
14
|
+
onSuccess: () => {
|
|
15
|
+
setTitle("");
|
|
16
|
+
router.refresh();
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<form
|
|
22
|
+
className="flex flex-col gap-2"
|
|
23
|
+
onSubmit={(event) => {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
create.mutate({ title });
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<div className="flex gap-2">
|
|
29
|
+
<Input
|
|
30
|
+
name="title"
|
|
31
|
+
value={title}
|
|
32
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
33
|
+
placeholder="Write a note"
|
|
34
|
+
aria-label="Note title"
|
|
35
|
+
maxLength={120}
|
|
36
|
+
/>
|
|
37
|
+
<Button type="submit" disabled={create.isPending}>
|
|
38
|
+
Add
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
{create.error ? (
|
|
42
|
+
<p role="alert" className="text-sm text-destructive">
|
|
43
|
+
{create.error.message}
|
|
44
|
+
</p>
|
|
45
|
+
) : null}
|
|
46
|
+
</form>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { ApiContext } from "./context";
|
|
4
|
+
import { appRouter } from "./root";
|
|
5
|
+
|
|
6
|
+
describe("notes.create", () => {
|
|
7
|
+
it("stores a trimmed title through createNote", async () => {
|
|
8
|
+
const insertNote: ApiContext["insertNote"] = vi.fn(async (input) => ({
|
|
9
|
+
id: "1",
|
|
10
|
+
title: input.title,
|
|
11
|
+
createdAt: new Date(0),
|
|
12
|
+
}));
|
|
13
|
+
const caller = appRouter.createCaller({ insertNote });
|
|
14
|
+
|
|
15
|
+
const note = await caller.notes.create({ title: " Buy milk " });
|
|
16
|
+
|
|
17
|
+
expect(insertNote).toHaveBeenCalledWith({ title: "Buy milk" });
|
|
18
|
+
expect(note.title).toBe("Buy milk");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects an empty title without storing", async () => {
|
|
22
|
+
const insertNote: ApiContext["insertNote"] = vi.fn();
|
|
23
|
+
const caller = appRouter.createCaller({ insertNote });
|
|
24
|
+
|
|
25
|
+
await expect(caller.notes.create({ title: " " })).rejects.toThrow(
|
|
26
|
+
"Title is required",
|
|
27
|
+
);
|
|
28
|
+
expect(insertNote).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { createNote } from "@domain/notes/create-note";
|
|
5
|
+
import type { Note } from "@domain/notes/note-schema";
|
|
6
|
+
|
|
7
|
+
import { publicProcedure, router } from "./trpc";
|
|
8
|
+
|
|
9
|
+
function asNote(value: unknown): Note {
|
|
10
|
+
if (
|
|
11
|
+
typeof value === "object" &&
|
|
12
|
+
value !== null &&
|
|
13
|
+
"id" in value &&
|
|
14
|
+
typeof value.id === "string" &&
|
|
15
|
+
"title" in value &&
|
|
16
|
+
typeof value.title === "string" &&
|
|
17
|
+
"createdAt" in value &&
|
|
18
|
+
value.createdAt instanceof Date
|
|
19
|
+
) {
|
|
20
|
+
return { id: value.id, title: value.title, createdAt: value.createdAt };
|
|
21
|
+
}
|
|
22
|
+
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid note" });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const notesRouter = router({
|
|
26
|
+
create: publicProcedure.input(z.unknown()).mutation(async ({ ctx, input }) => {
|
|
27
|
+
const created = await createNote(input, ctx.insertNote);
|
|
28
|
+
if (typeof created === "object" && created !== null && "ok" in created) {
|
|
29
|
+
if (created.ok === false) {
|
|
30
|
+
const message =
|
|
31
|
+
"error" in created && typeof created.error === "string"
|
|
32
|
+
? created.error
|
|
33
|
+
: "Invalid note";
|
|
34
|
+
throw new TRPCError({ code: "BAD_REQUEST", message });
|
|
35
|
+
}
|
|
36
|
+
if ("value" in created) {
|
|
37
|
+
return asNote(created.value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return asNote(created);
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
TrpcProvider,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test("sign-in page lists GitHub and Google only", async ({ page }) => {
|
|
4
|
+
await page.goto("/sign-in");
|
|
5
|
+
await expect(page.getByRole("button", { name: "GitHub" })).toBeVisible();
|
|
6
|
+
await expect(page.getByRole("button", { name: "Google" })).toBeVisible();
|
|
7
|
+
await expect(page.getByLabel(/email/i)).toHaveCount(0);
|
|
8
|
+
await expect(page.getByRole("button", { name: /magic link/i })).toHaveCount(0);
|
|
9
|
+
await expect(page.getByText(/magic link/i)).toHaveCount(0);
|
|
10
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import NextAuth from "next-auth";
|
|
3
|
+
|
|
4
|
+
import { authOptions } from "@server/auth/config";
|
|
5
|
+
|
|
6
|
+
async function handler(
|
|
7
|
+
req: NextRequest,
|
|
8
|
+
context: { params: Promise<{ nextauth: string[] }> },
|
|
9
|
+
) {
|
|
10
|
+
return NextAuth(req, context, authOptions());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { handler as GET, handler as POST };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { signIn } from "next-auth/react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@ui/button";
|
|
6
|
+
|
|
7
|
+
export default function SignInPage() {
|
|
8
|
+
return (
|
|
9
|
+
<main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
|
|
10
|
+
<h1 className="text-xl font-semibold">Sign in</h1>
|
|
11
|
+
<Button type="button" onClick={() => void signIn("github")}>
|
|
12
|
+
GitHub
|
|
13
|
+
</Button>
|
|
14
|
+
<Button type="button" onClick={() => void signIn("google")}>
|
|
15
|
+
Google
|
|
16
|
+
</Button>
|
|
17
|
+
</main>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @seed:anchor adapter-imports
|
|
2
|
+
import type { NextAuthOptions } from "next-auth";
|
|
3
|
+
import GitHubProvider from "next-auth/providers/github";
|
|
4
|
+
import GoogleProvider from "next-auth/providers/google";
|
|
5
|
+
|
|
6
|
+
// @seed:anchor adapter-db-imports
|
|
7
|
+
|
|
8
|
+
/** Auth.js options. The adapter calls `db()`, so this runs per request, not at import. */
|
|
9
|
+
export function authOptions(): NextAuthOptions {
|
|
10
|
+
return {
|
|
11
|
+
// @seed:anchor adapter
|
|
12
|
+
...(process.env.AUTH_SECRET !== undefined
|
|
13
|
+
? { secret: process.env.AUTH_SECRET }
|
|
14
|
+
: {}),
|
|
15
|
+
session: { strategy: "database" },
|
|
16
|
+
pages: { signIn: "/sign-in" },
|
|
17
|
+
providers: [
|
|
18
|
+
GitHubProvider({
|
|
19
|
+
clientId: process.env.GITHUB_ID ?? "",
|
|
20
|
+
clientSecret: process.env.GITHUB_SECRET ?? "",
|
|
21
|
+
}),
|
|
22
|
+
GoogleProvider({
|
|
23
|
+
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
|
24
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
|
25
|
+
}),
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getServerSession } from "next-auth";
|
|
2
|
+
|
|
3
|
+
import { authOptions } from "./config";
|
|
4
|
+
|
|
5
|
+
/** Server session. Opens the database only when called. */
|
|
6
|
+
export function auth() {
|
|
7
|
+
return getServerSession(authOptions());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Signed-in email. Opens the database only when called. */
|
|
11
|
+
export async function sessionEmail(): Promise<string | null> {
|
|
12
|
+
const session = await auth();
|
|
13
|
+
const email = session?.user?.email;
|
|
14
|
+
return typeof email === "string" && email.length > 0 ? email : null;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AuthSessionProvider,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boolean,
|
|
3
|
+
integer,
|
|
4
|
+
pgTable,
|
|
5
|
+
primaryKey,
|
|
6
|
+
text,
|
|
7
|
+
timestamp,
|
|
8
|
+
} from "drizzle-orm/pg-core";
|
|
9
|
+
|
|
10
|
+
/** Columns from `@auth/drizzle-adapter` 1.11.3 `defineTables` (`src/lib/pg.ts`). */
|
|
11
|
+
export const users = pgTable("user", {
|
|
12
|
+
id: text("id")
|
|
13
|
+
.primaryKey()
|
|
14
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
15
|
+
name: text("name"),
|
|
16
|
+
email: text("email").unique(),
|
|
17
|
+
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
|
18
|
+
image: text("image"),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const accounts = pgTable(
|
|
22
|
+
"account",
|
|
23
|
+
{
|
|
24
|
+
userId: text("userId")
|
|
25
|
+
.notNull()
|
|
26
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
27
|
+
type: text("type").notNull(),
|
|
28
|
+
provider: text("provider").notNull(),
|
|
29
|
+
providerAccountId: text("providerAccountId").notNull(),
|
|
30
|
+
refresh_token: text("refresh_token"),
|
|
31
|
+
access_token: text("access_token"),
|
|
32
|
+
expires_at: integer("expires_at"),
|
|
33
|
+
token_type: text("token_type"),
|
|
34
|
+
scope: text("scope"),
|
|
35
|
+
id_token: text("id_token"),
|
|
36
|
+
session_state: text("session_state"),
|
|
37
|
+
},
|
|
38
|
+
(account) => ({
|
|
39
|
+
compositePk: primaryKey({
|
|
40
|
+
columns: [account.provider, account.providerAccountId],
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export const sessions = pgTable("session", {
|
|
46
|
+
sessionToken: text("sessionToken").primaryKey(),
|
|
47
|
+
userId: text("userId")
|
|
48
|
+
.notNull()
|
|
49
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
50
|
+
expires: timestamp("expires", { mode: "date" }).notNull(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const verificationTokens = pgTable(
|
|
54
|
+
"verificationToken",
|
|
55
|
+
{
|
|
56
|
+
identifier: text("identifier").notNull(),
|
|
57
|
+
token: text("token").notNull(),
|
|
58
|
+
expires: timestamp("expires", { mode: "date" }).notNull(),
|
|
59
|
+
},
|
|
60
|
+
(verificationToken) => ({
|
|
61
|
+
compositePk: primaryKey({
|
|
62
|
+
columns: [verificationToken.identifier, verificationToken.token],
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const authenticators = pgTable(
|
|
68
|
+
"authenticator",
|
|
69
|
+
{
|
|
70
|
+
credentialID: text("credentialID").notNull().unique(),
|
|
71
|
+
userId: text("userId")
|
|
72
|
+
.notNull()
|
|
73
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
74
|
+
providerAccountId: text("providerAccountId").notNull(),
|
|
75
|
+
credentialPublicKey: text("credentialPublicKey").notNull(),
|
|
76
|
+
counter: integer("counter").notNull(),
|
|
77
|
+
credentialDeviceType: text("credentialDeviceType").notNull(),
|
|
78
|
+
credentialBackedUp: boolean("credentialBackedUp").notNull(),
|
|
79
|
+
transports: text("transports"),
|
|
80
|
+
},
|
|
81
|
+
(authenticator) => ({
|
|
82
|
+
compositePK: primaryKey({
|
|
83
|
+
columns: [authenticator.userId, authenticator.credentialID],
|
|
84
|
+
}),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { db } from "@server/db";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
adapter: PrismaAdapter(db()),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
model User {
|
|
2
|
+
id String @id @default(cuid())
|
|
3
|
+
name String?
|
|
4
|
+
email String @unique
|
|
5
|
+
emailVerified DateTime?
|
|
6
|
+
image String?
|
|
7
|
+
accounts Account[]
|
|
8
|
+
sessions Session[]
|
|
9
|
+
Authenticator Authenticator[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
model Account {
|
|
13
|
+
userId String
|
|
14
|
+
type String
|
|
15
|
+
provider String
|
|
16
|
+
providerAccountId String
|
|
17
|
+
refresh_token String?
|
|
18
|
+
access_token String?
|
|
19
|
+
expires_at Int?
|
|
20
|
+
token_type String?
|
|
21
|
+
scope String?
|
|
22
|
+
id_token String?
|
|
23
|
+
session_state String?
|
|
24
|
+
|
|
25
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
26
|
+
|
|
27
|
+
@@id([provider, providerAccountId])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
model Session {
|
|
31
|
+
sessionToken String @unique
|
|
32
|
+
userId String
|
|
33
|
+
expires DateTime
|
|
34
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
model VerificationToken {
|
|
38
|
+
identifier String
|
|
39
|
+
token String
|
|
40
|
+
expires DateTime
|
|
41
|
+
|
|
42
|
+
@@id([identifier, token])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
model Authenticator {
|
|
46
|
+
credentialID String @unique
|
|
47
|
+
userId String
|
|
48
|
+
providerAccountId String
|
|
49
|
+
credentialPublicKey String
|
|
50
|
+
counter Int
|
|
51
|
+
credentialDeviceType String
|
|
52
|
+
credentialBackedUp Boolean
|
|
53
|
+
transports String?
|
|
54
|
+
|
|
55
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
56
|
+
|
|
57
|
+
@@id([userId, credentialID])
|
|
58
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test("sign-in page renders offline", async ({ page }) => {
|
|
4
|
+
const clerkCalls: string[] = [];
|
|
5
|
+
page.on("request", (request) => {
|
|
6
|
+
const url = request.url();
|
|
7
|
+
if (/clerk\.(com|dev)|accounts\.dev|clerk\.accounts|clerk-telemetry/i.test(url)) {
|
|
8
|
+
clerkCalls.push(url);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
await page.goto("/sign-in");
|
|
13
|
+
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
|
|
14
|
+
expect(clerkCalls).toEqual([]);
|
|
15
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ClerkProvider } from "@clerk/nextjs";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/** Mounts Clerk only when a publishable key is set, so the gate does not call Clerk. */
|
|
7
|
+
export function ClerkAppProvider({ children }: { children: ReactNode }) {
|
|
8
|
+
if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
|
|
9
|
+
return children;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return <ClerkProvider>{children}</ClerkProvider>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default async function SignInPage() {
|
|
2
|
+
if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
|
|
3
|
+
return (
|
|
4
|
+
<main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
|
|
5
|
+
<h1 className="text-xl font-semibold">Sign in</h1>
|
|
6
|
+
</main>
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { SignIn } = await import("@clerk/nextjs");
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
|
|
14
|
+
<h1 className="text-xl font-semibold">Sign in</h1>
|
|
15
|
+
<SignIn />
|
|
16
|
+
</main>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default async function SignUpPage() {
|
|
2
|
+
if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
|
|
3
|
+
return (
|
|
4
|
+
<main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
|
|
5
|
+
<h1 className="text-xl font-semibold">Sign up</h1>
|
|
6
|
+
</main>
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { SignUp } = await import("@clerk/nextjs");
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
|
|
14
|
+
<h1 className="text-xl font-semibold">Sign up</h1>
|
|
15
|
+
<SignUp />
|
|
16
|
+
</main>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { clerkMiddleware } from "@clerk/nextjs/server";
|
|
2
|
+
import type { NextFetchEvent, NextRequest } from "next/server";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
|
|
5
|
+
const gate = clerkMiddleware();
|
|
6
|
+
|
|
7
|
+
/** Next.js 16 proxy. `clerkMiddleware` runs only when both Clerk keys are set. */
|
|
8
|
+
export function proxy(request: NextRequest, event: NextFetchEvent) {
|
|
9
|
+
if (
|
|
10
|
+
!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ||
|
|
11
|
+
!process.env.CLERK_SECRET_KEY
|
|
12
|
+
) {
|
|
13
|
+
return NextResponse.next();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return gate(request, event);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const config = {
|
|
20
|
+
matcher: [
|
|
21
|
+
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
|
22
|
+
"/(api|trpc)(.*)",
|
|
23
|
+
],
|
|
24
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { currentUser } from "@clerk/nextjs/server";
|
|
2
|
+
|
|
3
|
+
/** Signed-in email. Calls Clerk only when this function runs. */
|
|
4
|
+
export async function sessionEmail(): Promise<string | null> {
|
|
5
|
+
const user = await currentUser();
|
|
6
|
+
if (user === null) return null;
|
|
7
|
+
const fromPrimary = user.primaryEmailAddress?.emailAddress;
|
|
8
|
+
if (typeof fromPrimary === "string" && fromPrimary.length > 0) return fromPrimary;
|
|
9
|
+
const first = user.emailAddresses[0]?.emailAddress;
|
|
10
|
+
return typeof first === "string" && first.length > 0 ? first : null;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ClerkAppProvider,
|