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
34 changes: 29 additions & 5 deletions dashboard/.env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
# Postgres connection string (direct, no pooler).
# Local dev: docker-compose.yml provides the matching credentials.
# Production (Neon): direct connection string (no "-pooler" in hostname).
# Connection pooling in prod is handled via @prisma/adapter-neon, not a separate URL.
# ─── Database ──────────────────────────────────────────────────────────────────
# Local dev: credentials from docker-compose.yml (port 5433).
# Neon (prod): https://console.neon.tech → your project → Connection string
# Use the "Pooled connection" string for DATABASE_URL.
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/worktrace"

# ─── Google OAuth ──────────────────────────────────────────────────────────────
# https://console.cloud.google.com → APIs & Services → Credentials
# → "+ CREATE CREDENTIALS" → "OAuth 2.0 Client ID"
#
# For the Extension auth flow (Week 2):
# Application type: Chrome Extension
# Application ID: your unpacked extension ID from chrome://extensions
# → copy the generated Client ID here
#
# For the Web Dashboard OAuth (Week 3):
# Create a SEPARATE Client ID with Application type: Web application
# Authorized redirect URIs: http://localhost:3000/api/auth/callback/google
GOOGLE_CLIENT_ID=""

# Client secret — needed in Week 3 for the web dashboard authorization code flow.
# Same credentials page as GOOGLE_CLIENT_ID (Web application type).
GOOGLE_CLIENT_SECRET=""

# ─── JWT ───────────────────────────────────────────────────────────────────────
# Random 256-bit secret used to sign/verify our own JWTs.
# Generate: openssl rand -base64 32
JWT_SECRET=""
NEXT_PUBLIC_APP_URL="http://localhost:3000"

# Token lifetime — any value accepted by the `ms` library (e.g. 7d, 24h, 3600).
JWT_EXPIRES_IN="7d"

# ─── App ───────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_APP_URL="http://localhost:3000"
30 changes: 30 additions & 0 deletions dashboard/app/api/auth/extension/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { withCors, corsPreflight } from "@/server/cors";
import { ExtensionAuthInput } from "@/server/schemas/auth";
import { authenticateExtensionUser } from "@/server/auth";

export const POST = withCors(async (req: NextRequest) => {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = ExtensionAuthInput.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.format() }, { status: 400 });
}

try {
const token = await authenticateExtensionUser(parsed.data.googleToken);
return NextResponse.json({ token }, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Authentication failed";
return NextResponse.json({ error: message }, { status: 401 });
}
});

export function OPTIONS(req: NextRequest) {
return corsPreflight(req);
}
57 changes: 57 additions & 0 deletions dashboard/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { OAuth2Client } from "google-auth-library";
import jwt from "jsonwebtoken";
import { prisma } from "@/server/db";

function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}

const googleClient = new OAuth2Client();

async function verifyGoogleToken(idToken: string) {
const ticket = await googleClient.verifyIdToken({
idToken,
audience: requireEnv("GOOGLE_CLIENT_ID"),
});
const payload = ticket.getPayload();
if (!payload?.sub || !payload?.email) {
throw new Error("Invalid token payload: missing sub or email");
}
return {
googleId: payload.sub,
email: payload.email,
name: payload.name ?? null,
picture: payload.picture ?? null,
};
}

async function upsertUser(data: {
googleId: string;
email: string;
name: string | null;
picture: string | null;
}) {
return prisma.user.upsert({
where: { googleId: data.googleId },
update: { email: data.email, name: data.name, picture: data.picture },
create: { googleId: data.googleId, email: data.email,
name: data.name, picture: data.picture },
});
}

function issueJWT(userId: string, email: string): string {
const expiresIn = (process.env.JWT_EXPIRES_IN ?? "7d") as jwt.SignOptions["expiresIn"];
return jwt.sign(
{ sub: userId, email },
requireEnv("JWT_SECRET"),
{ expiresIn },
);
}

export async function authenticateExtensionUser(googleIdToken: string): Promise<string> {
const googleUser = await verifyGoogleToken(googleIdToken);
const user = await upsertUser(googleUser);
return issueJWT(user.id, user.email);
}
6 changes: 6 additions & 0 deletions dashboard/server/schemas/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { z } from "zod";

export const ExtensionAuthInput = z.object({
googleToken: z.string().min(1, "googleToken is required"),
});
export type ExtensionAuthInput = z.infer<typeof ExtensionAuthInput>;
13 changes: 12 additions & 1 deletion extension/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
VITE_API_BASE_URL=http://localhost:3000
# ─── Dashboard URL ─────────────────────────────────────────────────────────────
# Local dev: http://localhost:3000
# Production: your Vercel deployment URL (e.g. https://worktrace-ecru.vercel.app)
VITE_DASHBOARD_URL="http://localhost:3000"

# ─── Google OAuth ──────────────────────────────────────────────────────────────
# https://console.cloud.google.com → APIs & Services → Credentials
# → "+ CREATE CREDENTIALS" → "OAuth 2.0 Client ID"
# Application type: Chrome Extension
# Application ID: your unpacked extension ID from chrome://extensions
# → copy the generated Client ID here (same value as GOOGLE_CLIENT_ID in dashboard/.env)
VITE_GOOGLE_CLIENT_ID=""
154 changes: 152 additions & 2 deletions extension/src/background/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,155 @@
console.log("[worktrace] service worker booted");
import type { AuthMessage, AuthResponse, StoredAuth } from "../types/auth";

const DASHBOARD_URL = import.meta.env.VITE_DASHBOARD_URL as string;
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string;
const EXPIRY_BUFFER_MS = 60_000;

// ─── Storage ───────────────────────────────────────────────────────────────────

async function getStoredAuth(): Promise<StoredAuth | null> {
const r = await chrome.storage.local.get(["jwt", "jwtExpiresAt"]);
if (!r["jwt"] || !r["jwtExpiresAt"]) return null;
return { jwt: r["jwt"] as string, jwtExpiresAt: r["jwtExpiresAt"] as number };
}

async function storeAuth(jwt: string, expiresAt: number): Promise<void> {
await chrome.storage.local.set({ jwt, jwtExpiresAt: expiresAt });
}

async function clearAuth(): Promise<void> {
await chrome.storage.local.remove(["jwt", "jwtExpiresAt"]);
}

function isExpired(expiresAt: number): boolean {
return Date.now() >= expiresAt - EXPIRY_BUFFER_MS;
}

// ─── Google OAuth ──────────────────────────────────────────────────────────────

async function launchGoogleOAuth(): Promise<string> {
const redirectUrl = chrome.identity.getRedirectURL("auth");
const nonce = crypto.randomUUID();

const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", GOOGLE_CLIENT_ID);
url.searchParams.set("response_type", "id_token");
url.searchParams.set("redirect_uri", redirectUrl);
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("nonce", nonce);
url.searchParams.set("prompt", "select_account");

const responseUrl = await chrome.identity.launchWebAuthFlow({
url: url.toString(),
interactive: true,
});

if (!responseUrl) throw new Error("OAuth flow cancelled");

const fragment = new URL(responseUrl).hash.slice(1);
const idToken = new URLSearchParams(fragment).get("id_token");
if (!idToken) throw new Error("No id_token in OAuth response");
return idToken;
}

// ─── JWT exchange ──────────────────────────────────────────────────────────────

async function exchangeForJWT(googleIdToken: string): Promise<StoredAuth> {
const res = await fetch(`${DASHBOARD_URL}/api/auth/extension`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ googleToken: googleIdToken }),
});

if (!res.ok) {
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
throw new Error(`JWT exchange failed (${res.status}): ${JSON.stringify(body)}`);
}

const { token } = await res.json() as { token: string };
const payloadB64 = token.split(".")[1] ?? "";
const { exp } = JSON.parse(atob(payloadB64)) as { exp: number };
return { jwt: token, jwtExpiresAt: exp * 1000 };
}

// ─── Core ──────────────────────────────────────────────────────────────────────

async function ensureAuthenticated(): Promise<string> {
const stored = await getStoredAuth();
if (stored && !isExpired(stored.jwtExpiresAt)) return stored.jwt;

const googleIdToken = await launchGoogleOAuth();
const auth = await exchangeForJWT(googleIdToken);
await storeAuth(auth.jwt, auth.jwtExpiresAt);
return auth.jwt;
}

// Використовується в AC 5 для всіх API-запитів
export async function apiFetch(
path: string,
init: RequestInit = {},
): Promise<Response> {
const token = await ensureAuthenticated();
return fetch(`${DASHBOARD_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init.headers as Record<string, string> | undefined),
Authorization: `Bearer ${token}`,
},
});
}

// ─── Message listener ──────────────────────────────────────────────────────────

chrome.runtime.onMessage.addListener(
(message: AuthMessage, _sender, sendResponse: (r: AuthResponse) => void) => {
if (message.type === "AUTH_LOGIN") {
ensureAuthenticated()
.then((jwt) => sendResponse({ success: true, jwt }))
.catch((err: unknown) =>
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}),
);
return true;
}

if (message.type === "AUTH_LOGOUT") {
clearAuth()
.then(() => sendResponse({ success: true }))
.catch((err: unknown) =>
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}),
);
return true;
}

if (message.type === "AUTH_GET_STATUS") {
getStoredAuth()
.then((stored) =>
sendResponse({
success: true,
isAuthenticated: stored !== null && !isExpired(stored.jwtExpiresAt),
}),
)
.catch((err: unknown) =>
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}),
);
return true;
}

return false;
},
);

// ─── Lifecycle ─────────────────────────────────────────────────────────────────

chrome.runtime.onInstalled.addListener((details) => {
console.log("[worktrace] installed", details.reason);
console.log("[worktrace] service worker installed:", details.reason);
});
2 changes: 1 addition & 1 deletion extension/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default defineManifest({
run_at: "document_idle",
},
],
permissions: ["storage", "activeTab", "tabs"],
permissions: ["storage", "activeTab", "tabs", "identity"],
host_permissions: [
"http://localhost:3000/*",
"https://worktrace-ecru.vercel.app/*",
Expand Down
6 changes: 5 additions & 1 deletion extension/src/popup/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
<body>
<main>
<h1>WorkTrace</h1>
<p>Coming soon.</p>
<div id="auth-section">
<p id="auth-status">Checking...</p>
<button id="btn-login">Sign in with Google</button>
<button id="btn-logout">Sign out</button>
</div>
</main>
<script type="module" src="./popup.ts"></script>
</body>
Expand Down
32 changes: 31 additions & 1 deletion extension/src/popup/popup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
console.log("[worktrace] popup loaded");
import type { AuthMessage, AuthResponse } from "../types/auth";

function sendMessage(message: AuthMessage): Promise<AuthResponse> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(message, resolve);
});
}

const statusEl = document.getElementById("auth-status") as HTMLParagraphElement;
const btnLogin = document.getElementById("btn-login") as HTMLButtonElement;
const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement;

// Check auth status when popup opens
sendMessage({ type: "AUTH_GET_STATUS" }).then((res) => {
if (res.success && "isAuthenticated" in res) {
statusEl.textContent = res.isAuthenticated ? "✅ Signed in" : "❌ Not signed in";
}
});

btnLogin.addEventListener("click", async () => {
btnLogin.disabled = true;
statusEl.textContent = "Signing in...";
const res = await sendMessage({ type: "AUTH_LOGIN" });
statusEl.textContent = res.success ? "✅ Signed in" : `❌ ${!res.success ? res.error : ""}`;
btnLogin.disabled = false;
});

btnLogout.addEventListener("click", async () => {
await sendMessage({ type: "AUTH_LOGOUT" });
statusEl.textContent = "❌ Not signed in";
});
15 changes: 15 additions & 0 deletions extension/src/types/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface StoredAuth {
jwt: string;
jwtExpiresAt: number; // Unix ms
}

export type AuthMessage =
| { type: "AUTH_LOGIN" }
| { type: "AUTH_LOGOUT" }
| { type: "AUTH_GET_STATUS" };

export type AuthResponse =
| { success: true; jwt: string }
| { success: true; isAuthenticated: boolean }
| { success: true }
| { success: false; error: string };
Loading
Loading