@clubedge/create-clubedge-app 0.3.0 → 0.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.
- package/CHANGELOG.md +8 -0
- package/README.md +29 -13
- package/dist/cli/args.js +5 -0
- package/dist/cli/ui.js +2 -0
- package/dist/core/customize.js +40 -1
- package/dist/core/manifest.js +41 -3
- package/dist/core/plan.js +68 -3
- package/dist/index.js +42 -7
- package/dist/utils/fs.js +3 -0
- package/package.json +5 -4
- package/template/.dockerignore +3 -0
- package/template/.github/workflows/ci.yml +51 -4
- package/template/.prettierignore +1 -0
- package/template/AGENTS.md +3 -2
- package/template/CONTRIBUTING.md +2 -2
- package/template/README.md +18 -5
- package/template/SETUP.md +23 -1
- package/template/_gitignore +4 -0
- package/template/apps/start/.env.example +27 -0
- package/template/apps/start/AGENTS.md +11 -0
- package/template/apps/start/Dockerfile +38 -0
- package/template/apps/start/package.json +37 -0
- package/template/apps/start/public/.well-known/logo.svg +1 -0
- package/template/apps/start/public/favicon.ico +0 -0
- package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
- package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
- package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
- package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
- package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
- package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
- package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
- package/template/apps/start/src/config/site.json +11 -0
- package/template/apps/start/src/config/site.ts +20 -0
- package/template/apps/start/src/env/server.test.ts +48 -0
- package/template/apps/start/src/env/server.ts +67 -0
- package/template/apps/start/src/lib/login-url.test.ts +16 -0
- package/template/apps/start/src/lib/login-url.ts +16 -0
- package/template/apps/start/src/routeTree.gen.ts +220 -0
- package/template/apps/start/src/router.tsx +27 -0
- package/template/apps/start/src/routes/__root.tsx +59 -0
- package/template/apps/start/src/routes/api/health.ts +15 -0
- package/template/apps/start/src/routes/auth/callback.ts +10 -0
- package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
- package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
- package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
- package/template/apps/start/src/routes/dashboard.tsx +351 -0
- package/template/apps/start/src/routes/index.tsx +233 -0
- package/template/apps/start/src/routes/login.tsx +164 -0
- package/template/apps/start/src/server/auth-forms.test.ts +169 -0
- package/template/apps/start/src/server/auth-forms.ts +118 -0
- package/template/apps/start/src/server/auth.functions.ts +27 -0
- package/template/apps/start/src/server/auth.ts +51 -0
- package/template/apps/start/src/server/cache.ts +38 -0
- package/template/apps/start/src/server/cookies.test.ts +41 -0
- package/template/apps/start/src/server/cookies.ts +31 -0
- package/template/apps/start/src/server/db.ts +16 -0
- package/template/apps/start/src/server/storage.ts +39 -0
- package/template/apps/start/src/server/users.ts +17 -0
- package/template/apps/start/src/start.ts +40 -0
- package/template/apps/start/src/styles.css +2 -0
- package/template/apps/start/tsconfig.json +19 -0
- package/template/apps/start/vite.config.ts +27 -0
- package/template/apps/web/package.json +2 -2
- package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
- package/template/clubedge.template.json +17 -1
- package/template/e2e/app.ts +12 -0
- package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -2
- package/template/eslint.config.mjs +4 -0
- package/template/package.json +3 -3
- package/template/packages/auth/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/playwright.config.ts +6 -2
- package/template/pnpm-lock.yaml +1382 -19
- package/template/turbo.json +1 -1
- package/template/vitest.config.ts +2 -0
- package/template.lock.json +50 -5
- /package/template/{apps/web/e2e → e2e}/home.spec.ts +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { CircleAlert, Info, MailCheck } from "lucide-react";
|
|
2
|
+
import { Link, createFileRoute } from "@tanstack/react-router";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AuthForm,
|
|
7
|
+
EmailField,
|
|
8
|
+
PasswordField,
|
|
9
|
+
SubmitButton,
|
|
10
|
+
} from "@/components/auth/auth-form-controls";
|
|
11
|
+
import { ClubedgeMark } from "@/components/branding/clubedge-mark";
|
|
12
|
+
import { siteConfig } from "@/config/site";
|
|
13
|
+
import { safeRedirectPath } from "@clubedge/core";
|
|
14
|
+
import { loginUrl } from "@/lib/login-url";
|
|
15
|
+
import { getAuthStatus } from "@/server/auth.functions";
|
|
16
|
+
import { Card, CardContent } from "@clubedge/ui/components/card";
|
|
17
|
+
|
|
18
|
+
type LoginSearch = {
|
|
19
|
+
error?: string;
|
|
20
|
+
mode?: string;
|
|
21
|
+
"check-email"?: string;
|
|
22
|
+
next?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const asString = (value: unknown) => (typeof value === "string" ? value : undefined);
|
|
26
|
+
|
|
27
|
+
export const Route = createFileRoute("/login")({
|
|
28
|
+
validateSearch: (search: Record<string, unknown>): LoginSearch => ({
|
|
29
|
+
error: asString(search.error),
|
|
30
|
+
mode: asString(search.mode),
|
|
31
|
+
"check-email": asString(search["check-email"]),
|
|
32
|
+
next: asString(search.next),
|
|
33
|
+
}),
|
|
34
|
+
loader: () => getAuthStatus(),
|
|
35
|
+
component: LoginPage,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const errorMessages: Record<string, string> = {
|
|
39
|
+
"invalid-input": "Enter a valid email and a password with at least 8 characters.",
|
|
40
|
+
"invalid-credentials": "Those credentials could not be verified. Check your email and password.",
|
|
41
|
+
"signup-failed": "We could not create your account. Please check your details and try again.",
|
|
42
|
+
"auth-callback": "That sign-in link could not be verified. Request a new one.",
|
|
43
|
+
"rate-limited": "Too many attempts. Wait a minute and try again.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type NoticeTone = "error" | "info" | "warning";
|
|
47
|
+
|
|
48
|
+
const noticeStyles: Record<NoticeTone, string> = {
|
|
49
|
+
error: "border-destructive/30 bg-destructive/5 text-destructive",
|
|
50
|
+
info: "border-primary/30 bg-primary/5 text-foreground",
|
|
51
|
+
warning: "border-amber-500/30 bg-amber-500/5 text-foreground",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function Notice({
|
|
55
|
+
children,
|
|
56
|
+
icon,
|
|
57
|
+
role,
|
|
58
|
+
tone,
|
|
59
|
+
}: {
|
|
60
|
+
children: ReactNode;
|
|
61
|
+
icon: ReactNode;
|
|
62
|
+
role: "alert" | "status";
|
|
63
|
+
tone: NoticeTone;
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={`flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm leading-5 ${noticeStyles[tone]}`}
|
|
68
|
+
role={role}
|
|
69
|
+
>
|
|
70
|
+
<span aria-hidden="true" className="mt-0.5 shrink-0 [&>svg]:size-4">
|
|
71
|
+
{icon}
|
|
72
|
+
</span>
|
|
73
|
+
<div className="min-w-0">{children}</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function LoginPage() {
|
|
79
|
+
const params = Route.useSearch();
|
|
80
|
+
const { configured: hasSupabaseAuthConfig } = Route.useLoaderData();
|
|
81
|
+
const isSignUp = params.mode === "signup";
|
|
82
|
+
const error = params.error ? errorMessages[params.error] : undefined;
|
|
83
|
+
const checkEmail = Boolean(params["check-email"]);
|
|
84
|
+
const next = safeRedirectPath(params.next);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<main className="flex min-h-svh flex-col items-center bg-background px-4 py-10 sm:justify-center">
|
|
88
|
+
<div className="w-full max-w-[26rem]">
|
|
89
|
+
<header className="mb-8 flex flex-col items-center gap-4 text-center">
|
|
90
|
+
<Link
|
|
91
|
+
aria-label={`${siteConfig.name} home`}
|
|
92
|
+
className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
93
|
+
to="/"
|
|
94
|
+
>
|
|
95
|
+
<ClubedgeMark className="size-12 rounded-xl" />
|
|
96
|
+
</Link>
|
|
97
|
+
<div className="grid gap-1.5">
|
|
98
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
99
|
+
{isSignUp ? "Create your account" : `Sign in to ${siteConfig.shortName}`}
|
|
100
|
+
</h1>
|
|
101
|
+
<p className="text-sm text-muted-foreground">
|
|
102
|
+
{isSignUp
|
|
103
|
+
? "Enter your email and choose a password."
|
|
104
|
+
: "Welcome back. Enter your details to continue."}
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
</header>
|
|
108
|
+
|
|
109
|
+
<Card className="rounded-xl border-border/70 p-0 shadow-sm">
|
|
110
|
+
<CardContent className="grid gap-5 p-6 sm:p-8">
|
|
111
|
+
{!hasSupabaseAuthConfig && (
|
|
112
|
+
<Notice icon={<Info />} role="status" tone="warning">
|
|
113
|
+
Authentication is not configured yet. Add your Supabase URL and publishable key to{" "}
|
|
114
|
+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
|
|
115
|
+
apps/web/.env.local
|
|
116
|
+
</code>
|
|
117
|
+
.
|
|
118
|
+
</Notice>
|
|
119
|
+
)}
|
|
120
|
+
{error && (
|
|
121
|
+
<Notice icon={<CircleAlert />} role="alert" tone="error">
|
|
122
|
+
{error}
|
|
123
|
+
</Notice>
|
|
124
|
+
)}
|
|
125
|
+
{checkEmail && (
|
|
126
|
+
<Notice icon={<MailCheck />} role="status" tone="info">
|
|
127
|
+
Account created. Check your inbox for the confirmation link to finish signing up.
|
|
128
|
+
</Notice>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<AuthForm action={isSignUp ? "/auth/sign-up" : "/auth/sign-in"}>
|
|
132
|
+
<input name="next" type="hidden" value={next} />
|
|
133
|
+
<EmailField />
|
|
134
|
+
<PasswordField isSignUp={isSignUp} />
|
|
135
|
+
<SubmitButton disabled={!hasSupabaseAuthConfig}>
|
|
136
|
+
{isSignUp ? "Create account" : "Sign in"}
|
|
137
|
+
</SubmitButton>
|
|
138
|
+
</AuthForm>
|
|
139
|
+
</CardContent>
|
|
140
|
+
</Card>
|
|
141
|
+
|
|
142
|
+
<p className="mt-6 text-center text-sm text-muted-foreground">
|
|
143
|
+
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
|
|
144
|
+
<a
|
|
145
|
+
className="font-medium text-foreground underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
146
|
+
href={loginUrl({ mode: isSignUp ? "signin" : "signup", next })}
|
|
147
|
+
>
|
|
148
|
+
{isSignUp ? "Sign in" : "Create one"}
|
|
149
|
+
</a>
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<footer className="mt-10 text-center text-xs text-muted-foreground sm:absolute sm:bottom-6">
|
|
154
|
+
<Link className="underline-offset-4 hover:underline" to="/">
|
|
155
|
+
Back to home
|
|
156
|
+
</Link>
|
|
157
|
+
<span aria-hidden="true" className="mx-2">
|
|
158
|
+
|
|
|
159
|
+
</span>
|
|
160
|
+
{siteConfig.name}, Apache-2.0
|
|
161
|
+
</footer>
|
|
162
|
+
</main>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { err, ok } from "@clubedge/core";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const auth = vi.hoisted(() => ({
|
|
5
|
+
signInWithPassword: vi.fn(),
|
|
6
|
+
signUp: vi.fn(),
|
|
7
|
+
signOut: vi.fn(),
|
|
8
|
+
exchangeCodeForSession: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
const state = vi.hoisted(() => ({ configured: true }));
|
|
11
|
+
const limit = vi.hoisted(() => vi.fn());
|
|
12
|
+
const ensureUserProfile = vi.hoisted(() => vi.fn());
|
|
13
|
+
|
|
14
|
+
vi.mock("./auth", () => ({
|
|
15
|
+
getAuth: () => auth,
|
|
16
|
+
isAuthConfigured: () => state.configured,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("./cache", () => ({ createRateLimiter: () => ({ limit }) }));
|
|
19
|
+
vi.mock("./users", () => ({ ensureUserProfile }));
|
|
20
|
+
vi.mock("@/env/server", () => ({ getAppUrl: () => "https://app.example" }));
|
|
21
|
+
|
|
22
|
+
const { handleAuthCallback, handleFormEndpointVisit, handleSignIn, handleSignOut, handleSignUp } =
|
|
23
|
+
await import("./auth-forms");
|
|
24
|
+
|
|
25
|
+
const user = { id: "user-1", email: "ada@example.com" };
|
|
26
|
+
|
|
27
|
+
function formPost(path: string, fields: Record<string, string>) {
|
|
28
|
+
return new Request(`https://app.example${path}`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: new URLSearchParams(fields),
|
|
31
|
+
headers: { "x-forwarded-for": "203.0.113.9" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const credentials = { email: "ada@example.com", password: "correct horse" };
|
|
36
|
+
|
|
37
|
+
function expectRedirect(response: Response, location: string) {
|
|
38
|
+
expect(response.status).toBe(303);
|
|
39
|
+
expect(response.headers.get("Location")).toBe(location);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
state.configured = true;
|
|
45
|
+
limit.mockResolvedValue({ success: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("handleSignIn", () => {
|
|
49
|
+
it("signs in, syncs the profile, and returns to the requested page", async () => {
|
|
50
|
+
auth.signInWithPassword.mockResolvedValue(ok(user));
|
|
51
|
+
const response = await handleSignIn(
|
|
52
|
+
formPost("/auth/sign-in", { ...credentials, next: "/projects?tab=a" }),
|
|
53
|
+
);
|
|
54
|
+
expectRedirect(response, "/projects?tab=a");
|
|
55
|
+
expect(auth.signInWithPassword).toHaveBeenCalledWith(credentials);
|
|
56
|
+
expect(ensureUserProfile).toHaveBeenCalledWith(user);
|
|
57
|
+
expect(limit).toHaveBeenCalledWith("auth:sign-in:203.0.113.9");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("never redirects off-site", async () => {
|
|
61
|
+
auth.signInWithPassword.mockResolvedValue(ok(user));
|
|
62
|
+
const response = await handleSignIn(
|
|
63
|
+
formPost("/auth/sign-in", { ...credentials, next: "https://evil.example" }),
|
|
64
|
+
);
|
|
65
|
+
expectRedirect(response, "/dashboard");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports invalid input and invalid credentials", async () => {
|
|
69
|
+
expectRedirect(
|
|
70
|
+
await handleSignIn(formPost("/auth/sign-in", { email: "nope", password: "short" })),
|
|
71
|
+
"/login?error=invalid-input",
|
|
72
|
+
);
|
|
73
|
+
auth.signInWithPassword.mockResolvedValue(err("invalid-credentials"));
|
|
74
|
+
expectRedirect(
|
|
75
|
+
await handleSignIn(formPost("/auth/sign-in", credentials)),
|
|
76
|
+
"/login?error=invalid-credentials",
|
|
77
|
+
);
|
|
78
|
+
expect(ensureUserProfile).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("stops at the rate limit before contacting the provider", async () => {
|
|
82
|
+
limit.mockResolvedValue({ success: false });
|
|
83
|
+
const response = await handleSignIn(formPost("/auth/sign-in", { ...credentials, next: "/x" }));
|
|
84
|
+
expectRedirect(response, "/login?error=rate-limited&next=%2Fx");
|
|
85
|
+
expect(auth.signInWithPassword).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("fails open when the rate limiter is unavailable", async () => {
|
|
89
|
+
limit.mockRejectedValue(new Error("redis down"));
|
|
90
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
91
|
+
auth.signInWithPassword.mockResolvedValue(ok(user));
|
|
92
|
+
expectRedirect(await handleSignIn(formPost("/auth/sign-in", credentials)), "/dashboard");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns to the login page while authentication is not configured", async () => {
|
|
96
|
+
state.configured = false;
|
|
97
|
+
const response = await handleSignIn(formPost("/auth/sign-in", { ...credentials, next: "/x" }));
|
|
98
|
+
expectRedirect(response, "/login?next=%2Fx");
|
|
99
|
+
expect(auth.signInWithPassword).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("handleSignUp", () => {
|
|
104
|
+
it("sends a confirmation link that returns through the callback", async () => {
|
|
105
|
+
auth.signUp.mockResolvedValue(ok({ user, needsConfirmation: true }));
|
|
106
|
+
const response = await handleSignUp(
|
|
107
|
+
formPost("/auth/sign-up", { ...credentials, next: "/projects" }),
|
|
108
|
+
);
|
|
109
|
+
expectRedirect(response, "/login?mode=signup&check-email=1&next=%2Fprojects");
|
|
110
|
+
expect(auth.signUp).toHaveBeenCalledWith({
|
|
111
|
+
...credentials,
|
|
112
|
+
emailRedirectTo: "https://app.example/auth/callback?next=%2Fprojects",
|
|
113
|
+
});
|
|
114
|
+
expect(ensureUserProfile).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("signs the user in straight away when no confirmation is required", async () => {
|
|
118
|
+
auth.signUp.mockResolvedValue(ok({ user, needsConfirmation: false }));
|
|
119
|
+
expectRedirect(await handleSignUp(formPost("/auth/sign-up", credentials)), "/dashboard");
|
|
120
|
+
expect(ensureUserProfile).toHaveBeenCalledWith(user);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("reports provider failures", async () => {
|
|
124
|
+
auth.signUp.mockResolvedValue(err("signup-failed"));
|
|
125
|
+
expectRedirect(
|
|
126
|
+
await handleSignUp(formPost("/auth/sign-up", credentials)),
|
|
127
|
+
"/login?mode=signup&error=signup-failed",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("handleAuthCallback", () => {
|
|
133
|
+
it("exchanges the code and continues to the requested page", async () => {
|
|
134
|
+
auth.exchangeCodeForSession.mockResolvedValue(ok(user));
|
|
135
|
+
const response = await handleAuthCallback(
|
|
136
|
+
new Request("https://app.example/auth/callback?code=abc&next=%2Fprojects"),
|
|
137
|
+
);
|
|
138
|
+
expectRedirect(response, "/projects");
|
|
139
|
+
expect(auth.exchangeCodeForSession).toHaveBeenCalledWith("abc");
|
|
140
|
+
expect(ensureUserProfile).toHaveBeenCalledWith(user);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("reports missing and invalid codes", async () => {
|
|
144
|
+
expectRedirect(
|
|
145
|
+
await handleAuthCallback(new Request("https://app.example/auth/callback")),
|
|
146
|
+
"/login?error=auth-callback",
|
|
147
|
+
);
|
|
148
|
+
auth.exchangeCodeForSession.mockResolvedValue(err("invalid-code"));
|
|
149
|
+
expectRedirect(
|
|
150
|
+
await handleAuthCallback(new Request("https://app.example/auth/callback?code=bad")),
|
|
151
|
+
"/login?error=auth-callback",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("handleSignOut and direct visits", () => {
|
|
157
|
+
it("signs out and returns to the login page, even when the provider fails", async () => {
|
|
158
|
+
expectRedirect(await handleSignOut(), "/login");
|
|
159
|
+
expect(auth.signOut).toHaveBeenCalled();
|
|
160
|
+
|
|
161
|
+
auth.signOut.mockRejectedValue(new Error("network"));
|
|
162
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
163
|
+
expectRedirect(await handleSignOut(), "/login");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("sends direct visits to the login page", () => {
|
|
167
|
+
expectRedirect(handleFormEndpointVisit(), "/login");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getClientIp, safeRedirectPath } from "@clubedge/core";
|
|
4
|
+
import { getAppUrl } from "@/env/server";
|
|
5
|
+
import { loginUrl } from "@/lib/login-url";
|
|
6
|
+
import { getAuth, isAuthConfigured } from "./auth";
|
|
7
|
+
import { createRateLimiter } from "./cache";
|
|
8
|
+
import { ensureUserProfile } from "./users";
|
|
9
|
+
|
|
10
|
+
// Form endpoints for the login page. They answer with 303 redirects, so the forms work with
|
|
11
|
+
// or without JavaScript, the same way the Next.js server actions do.
|
|
12
|
+
|
|
13
|
+
const credentialsSchema = z.object({
|
|
14
|
+
email: z.email(),
|
|
15
|
+
password: z.string().min(8).max(128),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Ten attempts per minute per client address and action.
|
|
19
|
+
const authRateLimiter = createRateLimiter(10, 60);
|
|
20
|
+
|
|
21
|
+
function seeOther(location: string): Response {
|
|
22
|
+
return new Response(null, { status: 303, headers: { Location: location } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The form endpoints have no page of their own; send direct visits to the login page. */
|
|
26
|
+
export function handleFormEndpointVisit(): Response {
|
|
27
|
+
return seeOther("/login");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readCredentials(formData: FormData) {
|
|
31
|
+
return credentialsSchema.safeParse({
|
|
32
|
+
email: formData.get("email"),
|
|
33
|
+
password: formData.get("password"),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function isRateLimited(request: Request, action: "sign-in" | "sign-up") {
|
|
38
|
+
const ip = getClientIp(request.headers);
|
|
39
|
+
try {
|
|
40
|
+
const result = await authRateLimiter.limit(`auth:${action}:${ip}`);
|
|
41
|
+
return !result.success;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Fail open: an unavailable limiter backend should not lock every user out.
|
|
44
|
+
console.error("Rate limiter unavailable", error);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function handleSignIn(request: Request): Promise<Response> {
|
|
50
|
+
const formData = await request.formData();
|
|
51
|
+
const next = safeRedirectPath(formData.get("next"));
|
|
52
|
+
// The login page explains the missing configuration; never answer a form post with a 500.
|
|
53
|
+
if (!isAuthConfigured()) return seeOther(loginUrl({ next }));
|
|
54
|
+
if (await isRateLimited(request, "sign-in")) {
|
|
55
|
+
return seeOther(loginUrl({ error: "rate-limited", next }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsed = readCredentials(formData);
|
|
59
|
+
if (!parsed.success) return seeOther(loginUrl({ error: "invalid-input", next }));
|
|
60
|
+
|
|
61
|
+
const result = await getAuth().signInWithPassword(parsed.data);
|
|
62
|
+
if (!result.ok) return seeOther(loginUrl({ error: "invalid-credentials", next }));
|
|
63
|
+
|
|
64
|
+
await ensureUserProfile(result.value);
|
|
65
|
+
return seeOther(next);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function handleSignUp(request: Request): Promise<Response> {
|
|
69
|
+
const formData = await request.formData();
|
|
70
|
+
const next = safeRedirectPath(formData.get("next"));
|
|
71
|
+
if (!isAuthConfigured()) return seeOther(loginUrl({ mode: "signup", next }));
|
|
72
|
+
if (await isRateLimited(request, "sign-up")) {
|
|
73
|
+
return seeOther(loginUrl({ mode: "signup", error: "rate-limited", next }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parsed = readCredentials(formData);
|
|
77
|
+
if (!parsed.success) return seeOther(loginUrl({ mode: "signup", error: "invalid-input", next }));
|
|
78
|
+
|
|
79
|
+
const callbackUrl = new URL("/auth/callback", getAppUrl());
|
|
80
|
+
callbackUrl.searchParams.set("next", next);
|
|
81
|
+
const result = await getAuth().signUp({
|
|
82
|
+
...parsed.data,
|
|
83
|
+
emailRedirectTo: callbackUrl.toString(),
|
|
84
|
+
});
|
|
85
|
+
if (!result.ok) return seeOther(loginUrl({ mode: "signup", error: "signup-failed", next }));
|
|
86
|
+
if (result.value.needsConfirmation || !result.value.user) {
|
|
87
|
+
return seeOther(loginUrl({ mode: "signup", checkEmail: true, next }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await ensureUserProfile(result.value.user);
|
|
91
|
+
return seeOther(next);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function handleSignOut(): Promise<Response> {
|
|
95
|
+
if (!isAuthConfigured()) return seeOther("/login");
|
|
96
|
+
try {
|
|
97
|
+
await getAuth().signOut();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error("Sign-out failed", error);
|
|
100
|
+
}
|
|
101
|
+
return seeOther("/login");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function handleAuthCallback(request: Request): Promise<Response> {
|
|
105
|
+
const url = new URL(request.url);
|
|
106
|
+
const code = url.searchParams.get("code");
|
|
107
|
+
const next = safeRedirectPath(url.searchParams.get("next"));
|
|
108
|
+
|
|
109
|
+
if (code && isAuthConfigured()) {
|
|
110
|
+
const result = await getAuth().exchangeCodeForSession(code);
|
|
111
|
+
if (result.ok) {
|
|
112
|
+
await ensureUserProfile(result.value);
|
|
113
|
+
return seeOther(next);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return seeOther(loginUrl({ error: "auth-callback" }));
|
|
118
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { redirect } from "@tanstack/react-router";
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { safeRedirectPath } from "@clubedge/core";
|
|
5
|
+
import { loginUrl } from "@/lib/login-url";
|
|
6
|
+
import { getCurrentUser, isAuthConfigured } from "./auth";
|
|
7
|
+
|
|
8
|
+
// Server functions are RPC endpoints that route loaders call. The build replaces their
|
|
9
|
+
// bodies with network stubs in the browser bundle, so this file is safe to import anywhere.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* For pages: redirects signed-out visitors to the login page and back afterwards.
|
|
13
|
+
* Returns null while authentication is not configured so the starter stays explorable.
|
|
14
|
+
*/
|
|
15
|
+
export const getPageUser = createServerFn({ method: "GET" })
|
|
16
|
+
.validator(z.object({ pathname: z.string() }))
|
|
17
|
+
.handler(async ({ data }) => {
|
|
18
|
+
if (!isAuthConfigured()) return null;
|
|
19
|
+
const user = await getCurrentUser();
|
|
20
|
+
if (!user) throw redirect({ href: loginUrl({ next: safeRedirectPath(data.pathname) }) });
|
|
21
|
+
return user;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Lets the login page explain that authentication still needs configuring. */
|
|
25
|
+
export const getAuthStatus = createServerFn({ method: "GET" }).handler(() => ({
|
|
26
|
+
configured: isAuthConfigured(),
|
|
27
|
+
}));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import type { AuthProvider, AuthUser } from "@clubedge/auth";
|
|
3
|
+
import { createSupabaseAuth, createSupabaseServerClient } from "@clubedge/auth/supabase";
|
|
4
|
+
import { AppError } from "@clubedge/core";
|
|
5
|
+
import { getSupabaseAuthEnv } from "@/env/server";
|
|
6
|
+
import { requestCookieStore } from "./cookies";
|
|
7
|
+
|
|
8
|
+
export type { AuthUser } from "@clubedge/auth";
|
|
9
|
+
|
|
10
|
+
export function isAuthConfigured(): boolean {
|
|
11
|
+
return getSupabaseAuthEnv() !== null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function supabaseConfig() {
|
|
15
|
+
const env = getSupabaseAuthEnv();
|
|
16
|
+
if (!env) throw new Error("Supabase Auth is not configured.");
|
|
17
|
+
return { ...env, cookies: requestCookieStore() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Request-scoped auth provider. Throws when authentication is not configured. */
|
|
21
|
+
export function getAuth(): AuthProvider {
|
|
22
|
+
return createSupabaseAuth(supabaseConfig());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Request-scoped Supabase client for provider features such as Storage. */
|
|
26
|
+
export async function getSupabaseClient() {
|
|
27
|
+
return createSupabaseServerClient(supabaseConfig());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Validates the session and rotates its cookies. Runs once per request from src/start.ts. */
|
|
31
|
+
export async function refreshAuthSession(): Promise<void> {
|
|
32
|
+
if (!isAuthConfigured()) return;
|
|
33
|
+
try {
|
|
34
|
+
await getAuth().refreshSession();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Pages still render; protected handlers re-check the user and reject when needed.
|
|
37
|
+
console.error("Session refresh failed", error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function getCurrentUser(): Promise<AuthUser | null> {
|
|
42
|
+
if (!isAuthConfigured()) return null;
|
|
43
|
+
return getAuth().getUser();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** For server functions and routes: throws a 401 AppError when signed out. */
|
|
47
|
+
export async function requireUser(): Promise<AuthUser> {
|
|
48
|
+
const user = await getCurrentUser();
|
|
49
|
+
if (!user) throw new AppError("Authentication required.", "UNAUTHENTICATED", 401);
|
|
50
|
+
return user;
|
|
51
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import {
|
|
3
|
+
createRateLimiter as createLimiter,
|
|
4
|
+
createRedisCache,
|
|
5
|
+
type RateLimiter,
|
|
6
|
+
} from "@clubedge/cache";
|
|
7
|
+
import { getRedisUrl } from "@/env/server";
|
|
8
|
+
|
|
9
|
+
export type { RateLimiter, RateLimitResult } from "@clubedge/cache";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Uses Redis when REDIS_URL is configured, otherwise a per-process memory limiter. The
|
|
13
|
+
* backend is chosen on first use so module-level limiters do not read env at import time.
|
|
14
|
+
*/
|
|
15
|
+
export function createRateLimiter(requests = 10, windowSeconds = 60): RateLimiter {
|
|
16
|
+
let limiter: RateLimiter | undefined;
|
|
17
|
+
return {
|
|
18
|
+
limit(identifier) {
|
|
19
|
+
limiter ??= createLimiter({
|
|
20
|
+
requests,
|
|
21
|
+
windowSeconds,
|
|
22
|
+
redisUrl: getRedisUrl(),
|
|
23
|
+
});
|
|
24
|
+
return limiter.limit(identifier);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cache reads miss and writes are skipped while Redis is not configured. */
|
|
30
|
+
export async function cacheGet<T>(key: string): Promise<T | null> {
|
|
31
|
+
const url = getRedisUrl();
|
|
32
|
+
return url ? createRedisCache(url).get<T>(key) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function cacheSet(key: string, value: unknown, ttlSeconds: number): Promise<void> {
|
|
36
|
+
const url = getRedisUrl();
|
|
37
|
+
if (url) await createRedisCache(url).set(key, value, ttlSeconds);
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const server = vi.hoisted(() => ({
|
|
4
|
+
request: new Request("https://app.example/"),
|
|
5
|
+
cookies: {} as Record<string, string>,
|
|
6
|
+
setCookie: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
10
|
+
getRequest: () => server.request,
|
|
11
|
+
getCookies: () => server.cookies,
|
|
12
|
+
setCookie: server.setCookie,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const { requestCookieStore } = await import("./cookies");
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
server.request = new Request("https://app.example/");
|
|
19
|
+
server.cookies = { session: "old" };
|
|
20
|
+
server.setCookie.mockClear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("requestCookieStore", () => {
|
|
24
|
+
it("reads the request cookies", () => {
|
|
25
|
+
expect(requestCookieStore().getAll()).toEqual([{ name: "session", value: "old" }]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("shares writes with later readers in the same request and sends them to the browser", () => {
|
|
29
|
+
const options = { httpOnly: true, path: "/" };
|
|
30
|
+
requestCookieStore().setAll([{ name: "session", value: "new", options }]);
|
|
31
|
+
|
|
32
|
+
expect(requestCookieStore().getAll()).toEqual([{ name: "session", value: "new" }]);
|
|
33
|
+
expect(server.setCookie).toHaveBeenCalledWith("session", "new", options);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("starts fresh for every request", () => {
|
|
37
|
+
requestCookieStore().setAll([{ name: "session", value: "new" }]);
|
|
38
|
+
server.request = new Request("https://app.example/next");
|
|
39
|
+
expect(requestCookieStore().getAll()).toEqual([{ name: "session", value: "old" }]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import { getCookies, getRequest, setCookie } from "@tanstack/react-start/server";
|
|
3
|
+
import type { CookieStore } from "@clubedge/auth";
|
|
4
|
+
|
|
5
|
+
const jars = new WeakMap<Request, CookieStore>();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bridges TanStack Start request cookies to the framework-agnostic CookieStore.
|
|
9
|
+
*
|
|
10
|
+
* There is one jar per request, so a session refreshed by the request middleware is visible
|
|
11
|
+
* to the server functions and routes that run later in the same request, and every write
|
|
12
|
+
* is also sent to the browser.
|
|
13
|
+
*/
|
|
14
|
+
export function requestCookieStore(): CookieStore {
|
|
15
|
+
const request = getRequest();
|
|
16
|
+
const existing = jars.get(request);
|
|
17
|
+
if (existing) return existing;
|
|
18
|
+
|
|
19
|
+
const values = new Map(Object.entries(getCookies()));
|
|
20
|
+
const store: CookieStore = {
|
|
21
|
+
getAll: () => Array.from(values, ([name, value]) => ({ name, value })),
|
|
22
|
+
setAll(cookies) {
|
|
23
|
+
for (const { name, value, options } of cookies) {
|
|
24
|
+
values.set(name, value);
|
|
25
|
+
setCookie(name, value, options);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
jars.set(request, store);
|
|
30
|
+
return store;
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import { createDb, type Database } from "@clubedge/db";
|
|
3
|
+
import { getServerEnv } from "@/env/server";
|
|
4
|
+
|
|
5
|
+
const globalForDb = globalThis as typeof globalThis & { clubedgeDb?: Database };
|
|
6
|
+
|
|
7
|
+
/** Creates the connection pool on first use and reuses it across dev hot reloads. */
|
|
8
|
+
export function getDb(): Database {
|
|
9
|
+
if (globalForDb.clubedgeDb) return globalForDb.clubedgeDb;
|
|
10
|
+
const env = getServerEnv();
|
|
11
|
+
globalForDb.clubedgeDb = createDb({
|
|
12
|
+
url: env.DATABASE_URL,
|
|
13
|
+
maxConnections: env.NODE_ENV === "production" ? 10 : 1,
|
|
14
|
+
});
|
|
15
|
+
return globalForDb.clubedgeDb;
|
|
16
|
+
}
|