Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,83 @@ export async function GET() {
```
[next-auth][error][NO_SECRET]
```
Add `NEXTAUTH_SECRET` to `.env.local`.
Add `NEXTAUTH_SECRET` to `.env.local`. Generate one with:
```bash
# macOS / Linux
openssl rand -base64 32
# Windows PowerShell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```

---

### GitHub OAuth `error=github` Redirect Loop

**Symptom:** After clicking "Sign in with GitHub" and completing the GitHub flow, the browser redirects back to `/auth/signin?error=github` instead of the dashboard.

Work through this checklist in order:

#### 1. Missing or placeholder env vars (most common cause)

Open `.env.local` and confirm these four are set to real values (not `your_...` placeholders):

```env
GITHUB_ID=Ov23... # from github.com/settings/developers
GITHUB_SECRET=ghp_... # generated in the same OAuth App
NEXTAUTH_SECRET=<32-byte> # run: openssl rand -base64 32
NEXTAUTH_URL=http://localhost:3000
```

Also required for the database upsert on sign-in:
```env
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
```

If `NEXT_PUBLIC_SUPABASE_URL` or `SUPABASE_SERVICE_ROLE_KEY` are missing, the server log will print:
```
signIn: supabaseAdmin is not configured; skipping DB upsert.
```
Authentication will still succeed, but no user record will be written to Supabase.

#### 2. Callback URL mismatch in the GitHub OAuth App

The **Authorization callback URL** in your GitHub OAuth App must be **exactly**:

```
http://localhost:3000/api/auth/callback/github
```

Any trailing slash, different port, or HTTPS vs HTTP mismatch will cause `error=github`. Verify at [github.com/settings/developers](https://github.com/settings/developers) → your OAuth App → **Authorization callback URL**.

#### 3. `ENCRYPTION_KEY` not set

The `ENCRYPTION_KEY` is required for OAuth token encryption:

```env
ENCRYPTION_KEY=<64 hex chars> # run: openssl rand -hex 32
```

On Windows PowerShell:
```powershell
-join ((1..32) | ForEach-Object { "{0:x2}" -f (Get-Random -Maximum 256) })
```

#### 4. Restart the dev server after changing env vars

Next.js reads `.env.local` only at startup. After any change, stop and restart:

```bash
npm run dev
```

#### 5. Check the server console for the real error

The browser only shows `error=github` — the actual error is printed to the **terminal running `npm run dev`**. Look for lines starting with `[next-auth]` or `signIn:`.

---

### GitHub OAuth callback mismatch
### GitHub OAuth callback URL mismatch
```
The redirect_uri is not associated with this application
```
Expand Down
100 changes: 98 additions & 2 deletions src/app/auth/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,80 @@
"use client";

import { signIn } from "next-auth/react";
import { useEffect, useRef } from "react";
import { Suspense, useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";


const A = "#818cf8";
const ERR = "#f87171";
const MONO = "var(--font-jetbrains, ui-monospace, monospace)";
const DISP = "var(--font-syne, system-ui, sans-serif)";

/** Maps NextAuth error codes → user-facing messages. */
const AUTH_ERROR_MESSAGES: Record<string, string> = {
github:
"GitHub sign-in failed. This is usually caused by incorrect OAuth credentials or a mismatched callback URL. Check your GitHub OAuth App settings and try again.",
OAuthCallback:
"The OAuth callback could not be completed. Please try signing in again.",
OAuthSignin:
"Could not start the GitHub sign-in flow. Please try again.",
Configuration:
"There is a server configuration error. Please contact the site administrator.",
AccessDenied:
"Access was denied. You may have cancelled the GitHub authorization.",
Verification:
"The sign-in link has expired or has already been used.",
Default:
"An unexpected authentication error occurred. Please try again.",
};

function getErrorMessage(error: string): string {
return AUTH_ERROR_MESSAGES[error] ?? AUTH_ERROR_MESSAGES.Default;
}

function AuthErrorBanner({ error }: { error: string }) {
return (
<div
role="alert"
aria-live="assertive"
style={{
width: "100%",
marginBottom: 24,
padding: "12px 16px",
borderRadius: 8,
background: "rgba(248,113,113,0.08)",
border: `1px solid rgba(248,113,113,0.25)`,
textAlign: "left",
}}
>
<p
style={{
fontFamily: MONO,
fontSize: 12,
fontWeight: 700,
color: ERR,
margin: "0 0 4px",
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
⚠ Sign-in failed
</p>
<p
style={{
fontFamily: MONO,
fontSize: 12,
color: "#e87a7a",
margin: 0,
lineHeight: 1.65,
}}
>
{getErrorMessage(error)}
</p>
</div>
);
}

function MouseSpotlight() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
Expand Down Expand Up @@ -35,7 +103,25 @@ function MouseSpotlight() {
);
}

export default function SignInPage() {
/**
* Inner component that reads search params — must live inside a Suspense
* boundary because useSearchParams() opts the subtree out of static rendering.
*/
function SignInContent() {
const searchParams = useSearchParams();
const error = searchParams.get("error");

// Clear the ?error= param from the URL immediately after reading it so
// that refreshing the page or navigating back doesn't show a stale error
// from a previous sign-in attempt.
useEffect(() => {
if (error && typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.delete("error");
window.history.replaceState({}, "", url.toString());
}
}, [error]);

return (
<main
style={{
Expand Down Expand Up @@ -121,6 +207,8 @@ export default function SignInPage() {
Track streaks, PR velocity &amp; coding growth.
</p>

{error && <AuthErrorBanner error={error} />}

<button
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
style={{
Expand Down Expand Up @@ -175,3 +263,11 @@ export default function SignInPage() {
</main>
);
}

export default function SignInPage() {
return (
<Suspense>
<SignInContent />
</Suspense>
);
}
6 changes: 3 additions & 3 deletions src/components/landing/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ function LandingNav() {
<span style={{ fontFamily: MONO, fontWeight: 700, fontSize: 14, color: TEXT, letterSpacing: '-0.02em' }}>
<span style={{ color: A }}>▲</span> DEVTRACK
</span>
<a href="/api/auth/signin/github?callbackUrl=/dashboard" className="lnd-nav-link">
<a href="/auth/signin" className="lnd-nav-link">
SIGN IN →
</a>
</nav>
Expand Down Expand Up @@ -402,7 +402,7 @@ function HeroSection() {

{/* CTAs */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<a href="/api/auth/signin/github?callbackUrl=/dashboard" className="lnd-cta-primary">
<a href="/auth/signin" className="lnd-cta-primary">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Expand Down Expand Up @@ -667,7 +667,7 @@ function SetupSection() {
</div>

<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<a href="/api/auth/signin/github?callbackUrl=/dashboard" className="lnd-cta-primary">
<a href="/auth/signin" className="lnd-cta-primary">
Sign in with GitHub
</a>
<a
Expand Down
67 changes: 47 additions & 20 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const authOptions: NextAuthOptions = {
}),
],
pages: {
signIn: "/auth/signin",
},
signIn: "/auth/signin",
},
session: {
strategy: "jwt",
maxAge: SESSION_MAX_AGE,
Expand All @@ -38,26 +38,53 @@ export const authOptions: NextAuthOptions = {
async signIn({ account, profile }) {
if (account?.provider === "github" && profile) {
const p = profile as { id: number; login: string };
const { data: user } = await supabaseAdmin.from("users").upsert(
{
github_id: String(p.id),
github_login: p.login,
updated_at: new Date().toISOString(),
},
{ onConflict: "github_id" }
).select("id").single();

if (user?.id && account.access_token) {
try {
await syncGitHubAchievementsForUser({
userId: user.id,
githubLogin: p.login,
token: account.access_token,
force: true,
});
} catch (error) {
console.error("GitHub achievements sync failed:", error);
// Guard: supabaseAdmin is null when Supabase env vars are missing or
// contain placeholder values (see src/lib/supabase.ts). Calling .from()
// on null throws a TypeError which NextAuth silently converts to
// return false → error=github redirect. Skip the upsert gracefully
// so authentication can still succeed with degraded functionality.
if (!supabaseAdmin) {
console.warn(
"signIn: supabaseAdmin is not configured; skipping DB upsert. " +
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local."
);
return true;
}

try {
const { data: user } = await supabaseAdmin
.from("users")
.upsert(
{
github_id: String(p.id),
github_login: p.login,
updated_at: new Date().toISOString(),
},
{ onConflict: "github_id" }
)
.select("id")
.single();

if (user?.id && account.access_token) {
try {
await syncGitHubAchievementsForUser({
userId: user.id,
githubLogin: p.login,
token: account.access_token,
force: true,
});
} catch (error) {
console.error("GitHub achievements sync failed:", error);
}
}
} catch (error) {
// A DB error must never block authentication. Log it and proceed so
// the user can still reach the dashboard even if the upsert fails.
console.error(
"signIn: Supabase upsert failed, proceeding without DB sync:",
error
);
}
}
return true;
Expand Down
Loading