Skip to content
Merged
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
32 changes: 32 additions & 0 deletions Backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -------------------------------------------------------
# GitQuest Backend – Environment Variable Reference
# -------------------------------------------------------
# Copy relevant values into:
# • appsettings.Development.json (local dev)
# • Azure App Service / Render environment settings (production)
#
# ASP.NET Core maps environment variables to configuration using "__" as
# the section separator. For example the variable
# JwtSettings__Key=your-secret
# is equivalent to appsettings.json > JwtSettings > Key.
# -------------------------------------------------------

# --- Database ---
ConnectionStrings__DefaultConnection="Server=YOUR_SERVER;Database=GitQuestDB;User Id=YOUR_USER;Password=YOUR_PASSWORD;"

# --- JWT ---
JwtSettings__Key="replace-with-a-long-random-secret-min-32-chars"
JwtSettings__Issuer="GitQuestBackend"
JwtSettings__Audience="GitQuestFrontend"
JwtSettings__DurationInMinutes="1440"

# --- GitHub OAuth App ---
GitHub__ClientId="your-github-oauth-app-client-id"
GitHub__ClientSecret="your-github-oauth-app-client-secret"
GitHub__CallbackUrl="https://YOUR_FRONTEND_DOMAIN/api/auth/callback/github"

# --- CORS (comma-separated list is NOT supported; use JSON array in appsettings) ---
# In production appsettings / environment, set the AllowedOrigins JSON array:
# AllowedOrigins__0="https://your-frontend.vercel.app"
# AllowedOrigins__1="https://www.yourdomain.com"
AllowedOrigins__0="http://localhost:3000"
1 change: 1 addition & 0 deletions Backend/Backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public AuthController(IConfiguration config, GitQuestContext context, IHttpClien
}

[HttpPost("github-login")]
[HttpPost("github")]
public async Task<IActionResult> GitHubLogin([FromBody] string code)
{
// 1. Exchange Code for GitHub Access Token
Expand Down
19 changes: 17 additions & 2 deletions Backend/Backend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,28 @@
builder.Services.AddAuthorization();

// 5. CORS
var allowedOrigins = builder.Configuration
.GetSection("AllowedOrigins")
.Get<string[]>()
?? new[] { "http://localhost:3000" };

if (builder.Environment.IsProduction() &&
allowedOrigins.Length == 1 &&
allowedOrigins[0] == "http://localhost:3000")
{
throw new InvalidOperationException(
"AllowedOrigins is not configured for production. " +
"Set at least one production origin in appsettings or environment variables.");
}

builder.Services.AddCors(options =>
{
options.AddPolicy("GitQuestPolicy", policy =>
{
policy.WithOrigins("http://localhost:3000")
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
.AllowAnyMethod()
.AllowCredentials();
});
});

Expand Down
3 changes: 3 additions & 0 deletions Backend/Backend/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
}
},
"AllowedHosts": "*",
"AllowedOrigins": [
"http://localhost:3000"
],
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=GitQuestDB;Trusted_Connection=True;MultipleActiveResultSets=true"
},
Expand Down
18 changes: 18 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# -------------------------------------------------------
# GitQuest Frontend – Environment Variable Reference
# -------------------------------------------------------
# Copy this file to .env.local for local development.
# In Vercel (production) set these in Project > Settings > Environment Variables.
# -------------------------------------------------------

# Base URL of the ASP.NET Core backend.
# Local dev: http://localhost:5198
# Production: https://your-backend.azurewebsites.net (or Render URL)
NEXT_PUBLIC_API_BASE_URL=http://localhost:5198

# GitHub OAuth App credentials
# Create an OAuth App at https://github.com/settings/developers
# Set the Authorization callback URL to:
# Local: http://localhost:3000/api/auth/callback/github
# Production: https://your-frontend.vercel.app/api/auth/callback/github
NEXT_PUBLIC_GITHUB_CLIENT_ID=your-github-oauth-app-client-id
48 changes: 48 additions & 0 deletions frontend/app/api/auth/callback/github/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* app/api/auth/callback/github/route.ts
*
* Next.js API route – GitHub OAuth callback.
*
* Flow:
* 1. GitHub redirects here with ?code=<one-time-code>
* 2. We forward the code to the ASP.NET backend (POST /api/auth/github)
* 3. The backend exchanges the code for a GitHub access token, fetches the
* user profile, persists it, and returns a signed JWT.
* 4. We store the JWT in an HttpOnly cookie and redirect the user to /discover.
*/

import { NextRequest, NextResponse } from "next/server";
import { loginWithGitHub } from "@/lib/api";
import { buildAuthCookies } from "@/lib/auth";

export async function GET(request: NextRequest): Promise<NextResponse> {
const { searchParams } = request.nextUrl;
const code = searchParams.get("code");
const error = searchParams.get("error");

// GitHub can send an error param (e.g. when the user denies access)
if (error || !code) {
return NextResponse.redirect(
new URL("/?auth_error=access_denied", request.url)
);
}

const { data, error: apiError } = await loginWithGitHub(code);

if (apiError || !data) {
return NextResponse.redirect(
new URL("/?auth_error=login_failed", request.url)
);
}

const cookies = buildAuthCookies(data.token, {
gitHubUsername: data.user.gitHubUsername,
avatarUrl: data.user.avatarUrl,
});

const response = NextResponse.redirect(new URL("/discover", request.url));
for (const cookie of cookies) {
response.headers.append("Set-Cookie", cookie);
}
return response;
}
16 changes: 16 additions & 0 deletions frontend/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* app/api/auth/logout/route.ts
*
* Clears the HttpOnly JWT cookie (server-side logout).
*/

import { NextRequest, NextResponse } from "next/server";
import { buildClearAuthCookies } from "@/lib/auth";

export async function POST(_request: NextRequest): Promise<NextResponse> {
const response = NextResponse.json({ ok: true });
for (const cookie of buildClearAuthCookies()) {
response.headers.append("Set-Cookie", cookie);
}
return response;
}
180 changes: 180 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* lib/api.ts
*
* Typed fetch wrapper for the GitQuest backend.
* Works in both Server Components (Node.js) and Client Components (browser).
*/

export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:5198";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface ApiError {
message: string;
status: number;
}

export interface ApiResponse<T> {
data: T | null;
error: ApiError | null;
}

// Shape returned by POST /api/auth/github-login (or /api/auth/github)
export interface AuthResponse {
token: string;
user: {
id: string;
gitHubUsername: string;
avatarUrl: string | null;
experiencePoints: number;
currentStreak: number;
};
}

// Shape of a single issue returned by GET /api/issues/discover
export interface GitHubIssue {
gitHubIssueId: number;
title: string;
description: string | null;
repoFullName: string;
language: string | null;
issueUrl: string;
difficulty: string;
xpReward: number;
isActive: boolean;
}

// Shape returned by POST /api/issues/{id}/claim
export interface ClaimResponse {
message: string;
questId: string;
}

// ---------------------------------------------------------------------------
// Core fetch helper
// ---------------------------------------------------------------------------

async function apiFetch<T>(
path: string,
options: RequestInit = {},
token?: string
): Promise<ApiResponse<T>> {
const headers: HeadersInit = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};

try {
const res = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});

if (!res.ok) {
let message = res.statusText;
try {
const body = await res.text();
if (body) message = body;
} catch {
// ignore body parse failure
}
return { data: null, error: { message, status: res.status } };
}

// 204 No Content
if (res.status === 204) {
return { data: null, error: null };
}

const data: T = await res.json();
return { data, error: null };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Network error";
return { data: null, error: { message, status: 0 } };
}
}

// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------

/**
* Exchange a GitHub OAuth code for a GitQuest JWT.
* Calls POST /api/auth/github
*/
export async function loginWithGitHub(
code: string
): Promise<ApiResponse<AuthResponse>> {
// ASP.NET Core [FromBody] string reads a raw JSON string value ("abc123"),
// which is exactly what JSON.stringify produces for a plain string.
return apiFetch<AuthResponse>("/api/auth/github", {
method: "POST",
body: JSON.stringify(code),
});
}

// ---------------------------------------------------------------------------
// Issues API
// ---------------------------------------------------------------------------

/**
* Fetch recommended open-source issues.
* Calls GET /api/issues/discover?language=<language>
*/
export async function discoverIssues(
language = "typescript",
token?: string
): Promise<ApiResponse<GitHubIssue[]>> {
return apiFetch<GitHubIssue[]>(
`/api/issues/discover?language=${encodeURIComponent(language)}`,
{},
token
);
}

/**
* Claim an issue as a quest.
* Calls POST /api/issues/{id}/claim
* Requires JWT.
*/
export async function claimIssue(
id: number,
token: string
): Promise<ApiResponse<ClaimResponse>> {
return apiFetch<ClaimResponse>(
`/api/issues/${id}/claim`,
{ method: "POST" },
token
);
}

/**
* Fetch the current user's active quests.
* Calls GET /api/issues/my-active-quests
* Requires JWT.
*/
export async function getMyQuests(
token: string
): Promise<ApiResponse<unknown[]>> {
return apiFetch<unknown[]>("/api/issues/my-active-quests", {}, token);
}

/**
* Submit a completed quest.
* Calls POST /api/issues/{id}/submit
* Requires JWT.
*/
export async function submitQuest(
id: number,
token: string
): Promise<ApiResponse<{ message: string; totalXp: number }>> {
return apiFetch<{ message: string; totalXp: number }>(
`/api/issues/${id}/submit`,
{ method: "POST" },
token
);
}
Loading
Loading