Skip to content

Commit a2eab13

Browse files
authored
feat(auth): add signup route and refactor auth form with state machine (#2148)
1 parent 43b8f1b commit a2eab13

28 files changed

+1393
-613
lines changed

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ GOOGLE_CLOUD_PROJECT=kriasoft
1818
GOOGLE_CLOUD_REGION=us-central1
1919

2020
# Database
21-
# https://console.neon.tech/
21+
# https://get.neon.com/HD157BR (referral — helps support this project)
2222
DATABASE_URL=postgres://postgres:postgres@localhost:5432/example
2323

2424
# Cloudflare Hyperdrive for local development

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Be sure to join our [Discord channel](https://discord.gg/2nKEnKq) for assistance
6161
### Database & ORM
6262

6363
- [Drizzle ORM](https://orm.drizzle.team/) — TypeScript ORM with excellent DX
64-
- [Neon PostgreSQL](https://neon.tech/) — Serverless PostgreSQL database
64+
- [Neon PostgreSQL](https://get.neon.com/HD157BR) — Serverless PostgreSQL (referral — supports the project)
6565

6666
### Development Tools
6767

Lines changed: 71 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,31 @@
1+
import { getErrorMessage, isUnauthenticatedError } from "@/lib/errors";
12
import { sessionQueryKey } from "@/lib/queries/session";
2-
import { queryClient } from "@/lib/query";
33
import { Button } from "@repo/ui";
4-
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
4+
import {
5+
useQueryClient,
6+
useQueryErrorResetBoundary,
7+
} from "@tanstack/react-query";
58
import { AlertCircle } from "lucide-react";
6-
import * as React from "react";
79
import { ErrorBoundary } from "react-error-boundary";
810

9-
interface AuthErrorFallbackProps {
10-
error: Error;
11+
interface ResetProps {
1112
resetErrorBoundary: () => void;
1213
}
1314

14-
// Error type that may include status code
15-
// NOTE: This extends Error to handle both tRPC errors (with status) and native JS errors
16-
interface ErrorWithStatus extends Error {
17-
status?: number;
18-
}
19-
20-
// Determine if an error is authentication-related
21-
// WARNING: This function must catch all auth errors to prevent infinite error loops
22-
// when wrapped components try to access protected resources
23-
function isAuthError(error: Error): boolean {
24-
// Check for explicit status codes
25-
const status = (error as ErrorWithStatus)?.status;
26-
if (status === 401 || status === 403) return true;
15+
// Fallback for auth errors in protected routes
16+
function AuthErrorFallback({ resetErrorBoundary }: ResetProps) {
17+
const queryClient = useQueryClient();
2718

28-
// Check for auth-related error messages
29-
const message = error.message?.toLowerCase() || "";
30-
return (
31-
message.includes("unauthorized") ||
32-
message.includes("unauthenticated") ||
33-
message.includes("session expired") ||
34-
message.includes("401") ||
35-
message.includes("403")
36-
);
37-
}
38-
39-
// Fallback component for auth errors
40-
function AuthErrorFallback({
41-
error,
42-
resetErrorBoundary,
43-
}: AuthErrorFallbackProps) {
4419
const handleRetry = () => {
45-
// Reset React Query errors and component errors
46-
// NOTE: resetQueries() clears error state but preserves cached data,
47-
// allowing immediate retry without full data refetch
48-
queryClient.resetQueries();
20+
queryClient.resetQueries({ queryKey: sessionQueryKey });
4921
resetErrorBoundary();
5022
};
5123

5224
const handleSignIn = () => {
53-
// Clear auth caches and redirect to login
54-
// WARNING: removeQueries() permanently deletes cached auth data.
55-
// Using window.location.href (not router navigation) ensures full page reload,
56-
// clearing all React state that might hold stale auth tokens
57-
queryClient.removeQueries({ queryKey: ["auth"] });
58-
window.location.href = "/login";
25+
queryClient.removeQueries({ queryKey: sessionQueryKey });
26+
const { pathname, search, hash } = window.location;
27+
const returnTo = encodeURIComponent(pathname + search + hash);
28+
window.location.href = `/login?returnTo=${returnTo}`;
5929
};
6030

6131
return (
@@ -64,10 +34,7 @@ function AuthErrorFallback({
6434
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-destructive" />
6535
<h1 className="mb-2 text-2xl font-bold">Authentication Required</h1>
6636
<p className="mb-6 text-muted-foreground">
67-
{error.message === "Unauthorized" ||
68-
(error as ErrorWithStatus)?.status === 401
69-
? "Your session has expired. Please sign in again."
70-
: "You need to sign in to access this page."}
37+
Please sign in to access this page.
7138
</p>
7239
<div className="flex justify-center gap-3">
7340
<Button variant="outline" onClick={handleRetry}>
@@ -80,53 +47,61 @@ function AuthErrorFallback({
8047
);
8148
}
8249

83-
interface AuthErrorBoundaryProps {
84-
children: React.ReactNode;
50+
interface ErrorFallbackProps {
51+
error: unknown;
52+
resetErrorBoundary: () => void;
8553
}
8654

87-
// General error fallback that handles both auth and other errors
88-
// This wrapper ensures auth errors get special UI treatment while preserving
89-
// generic error handling for all other failures
90-
function ErrorFallbackWrapper({
55+
// Generic error fallback for non-auth errors
56+
function GenericErrorFallback({
9157
error,
9258
resetErrorBoundary,
93-
}: AuthErrorFallbackProps) {
94-
// If it's not an auth error, show a generic error message
95-
if (!isAuthError(error)) {
96-
return (
97-
<div className="flex min-h-svh flex-col items-center justify-center p-6">
98-
<div className="mx-auto max-w-md text-center">
99-
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-destructive" />
100-
<h1 className="mb-2 text-2xl font-bold">Something went wrong</h1>
101-
<p className="mb-6 text-muted-foreground">
102-
{error.message || "An unexpected error occurred"}
103-
</p>
104-
<Button onClick={resetErrorBoundary}>Try Again</Button>
105-
</div>
59+
}: ErrorFallbackProps) {
60+
return (
61+
<div className="flex min-h-svh flex-col items-center justify-center p-6">
62+
<div className="mx-auto max-w-md text-center">
63+
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-destructive" />
64+
<h1 className="mb-2 text-2xl font-bold">Something went wrong</h1>
65+
<p className="mb-6 text-muted-foreground">{getErrorMessage(error)}</p>
66+
<Button onClick={resetErrorBoundary}>Try Again</Button>
10667
</div>
107-
);
108-
}
68+
</div>
69+
);
70+
}
10971

110-
// For auth errors, use the auth-specific UI
111-
return (
112-
<AuthErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />
72+
interface ErrorBoundaryProps {
73+
children: React.ReactNode;
74+
}
75+
76+
// Routes auth errors to AuthErrorFallback, others to GenericErrorFallback
77+
function AuthAwareErrorFallback({
78+
error,
79+
resetErrorBoundary,
80+
}: ErrorFallbackProps) {
81+
return isUnauthenticatedError(error) ? (
82+
<AuthErrorFallback resetErrorBoundary={resetErrorBoundary} />
83+
) : (
84+
<GenericErrorFallback
85+
error={error}
86+
resetErrorBoundary={resetErrorBoundary}
87+
/>
11388
);
11489
}
11590

116-
// Modern auth error boundary using react-error-boundary
117-
export function AuthErrorBoundary({ children }: AuthErrorBoundaryProps) {
91+
// Auth error boundary for protected routes only.
92+
// Catches auth errors (tRPC UNAUTHORIZED or HTTP 401) and shows recovery UI.
93+
// 403 (forbidden) falls through to generic handler since user IS authenticated.
94+
export function AuthErrorBoundary({ children }: ErrorBoundaryProps) {
95+
const queryClient = useQueryClient();
11896
const { reset } = useQueryErrorResetBoundary();
11997

12098
return (
12199
<ErrorBoundary
122-
FallbackComponent={ErrorFallbackWrapper}
100+
FallbackComponent={AuthAwareErrorFallback}
123101
onReset={reset}
124-
onError={(error, errorInfo) => {
125-
console.error("Error caught by boundary:", error, errorInfo);
126-
// Clear stale session data for auth errors
127-
// NOTE: sessionQueryKey is imported from queries/session - ensures we clear
128-
// the exact query key used by useSession() hook to prevent stale auth state
129-
if (isAuthError(error)) {
102+
onError={(error) => {
103+
console.error("Error caught by boundary:", error);
104+
if (isUnauthenticatedError(error)) {
130105
queryClient.removeQueries({ queryKey: sessionQueryKey });
131106
}
132107
}}
@@ -135,3 +110,18 @@ export function AuthErrorBoundary({ children }: AuthErrorBoundaryProps) {
135110
</ErrorBoundary>
136111
);
137112
}
113+
114+
// Generic error boundary for app root - no auth-specific handling
115+
export function AppErrorBoundary({ children }: ErrorBoundaryProps) {
116+
const { reset } = useQueryErrorResetBoundary();
117+
118+
return (
119+
<ErrorBoundary
120+
FallbackComponent={GenericErrorFallback}
121+
onReset={reset}
122+
onError={(error) => console.error("Uncaught error:", error)}
123+
>
124+
{children}
125+
</ErrorBoundary>
126+
);
127+
}

0 commit comments

Comments
 (0)