Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7c74753
add ghost mode and custom status fields to user schema
senutpal Mar 12, 2026
9181383
add resend and auth0 env configuration
senutpal Mar 12, 2026
b5edb19
add resend dependency for team invitation emails
senutpal Mar 12, 2026
858ad53
add email service with resend for team invitations
senutpal Mar 12, 2026
1b6dac0
add auth0 sso service for enterprise authentication
senutpal Mar 12, 2026
4c4305b
add ghost mode custom status and account deletion
senutpal Mar 12, 2026
49508fb
add auth0 sso routes for enterprise login
senutpal Mar 12, 2026
7bab338
add team analytics conflicts and invitation emails
senutpal Mar 12, 2026
00041a8
add team leaderboard endpoint
senutpal Mar 12, 2026
2bd303f
add stats history endpoint with date range support
senutpal Mar 12, 2026
1b94d27
add frontend api types and methods for new endpoints
senutpal Mar 12, 2026
a26b21a
add sso support and token extraction to auth context
senutpal Mar 12, 2026
b809a14
add theme preset system with custom color support
senutpal Mar 12, 2026
6fbe787
overhaul settings page with ghost mode themes and account deletion
senutpal Mar 12, 2026
3bf9162
add time range selector and daily activity chart to stats
senutpal Mar 12, 2026
8312f23
add leaderboard analytics conflicts and slack tabs to team detail
senutpal Mar 12, 2026
a72c2f7
add conflict toast listener to dashboard
senutpal Mar 12, 2026
f3fd65a
show custom status on friend cards
senutpal Mar 12, 2026
262b408
add enterprise sso button to header
senutpal Mar 12, 2026
796f242
add theme provider to root layout
senutpal Mar 12, 2026
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"pino": "^10.1.0",
"pino-pretty": "^13.1.3",
"razorpay": "^2.9.6",
"resend": "^6.9.3",
"ws": "^8.19.0",
"zod": "^3.25.76"
},
Expand Down
6 changes: 4 additions & 2 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ model User {
avatarUrl String?
email String?
tier Tier @default(FREE)
privacyMode Boolean @default(false)
createdAt DateTime @default(now())
privacyMode Boolean @default(false)
customStatus String? @db.VarChar(50)
ghostMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// Phase 5: Razorpay Billing
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ const envSchema = z
SLACK_CLIENT_SECRET: z.string().trim().min(1).optional(),
SLACK_SIGNING_SECRET: z.string().trim().min(1).optional(),

/* Auth0 SSO/SAML (Optional - TEAM tier) */
AUTH0_DOMAIN: z.string().trim().min(1).optional(),
AUTH0_CLIENT_ID: z.string().trim().min(1).optional(),
AUTH0_CLIENT_SECRET: z.string().trim().min(1).optional(),
AUTH0_CALLBACK_URL: z.string().url().optional(),

/* Resend Email (Optional) */
RESEND_API_KEY: z.string().trim().min(1).optional(),

/* Razorpay Billing (Required for billing features) */
RAZORPAY_KEY_ID: z.string().trim().min(1, 'RAZORPAY_KEY_ID is required'),
RAZORPAY_KEY_SECRET: z.string().trim().min(1, 'RAZORPAY_KEY_SECRET is required'),
Expand Down Expand Up @@ -91,6 +100,23 @@ const envSchema = z
path: ['SLACK_CLIENT_ID'],
});
}

const auth0Vars = [
val.AUTH0_DOMAIN,
val.AUTH0_CLIENT_ID,
val.AUTH0_CLIENT_SECRET,
val.AUTH0_CALLBACK_URL,
];
const auth0AnySet = auth0Vars.some((v) => v != null);
const auth0AllSet = auth0Vars.every((v) => v != null);
if (auth0AnySet && !auth0AllSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'If enabling Auth0 SSO, set AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, and AUTH0_CALLBACK_URL',
path: ['AUTH0_DOMAIN'],
});
}
});

/**
Expand Down
141 changes: 139 additions & 2 deletions apps/server/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
/**
* Authentication Routes
*
* GitHub OAuth flow for user authentication.
* GitHub OAuth flow and Auth0 SSO/SAML for user authentication.
*/

import { z } from 'zod';

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

import { isProduction } from '@/config';
import { env, isProduction } from '@/config';
import { ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import {
isAuth0Configured,
getAuth0AuthorizeUrl,
exchangeAuth0Code,
getAuth0UserInfo,
} from '@/services/auth0';
import { getDb } from '@/services/db';
import { getGitHubAuthUrl, authenticateWithGitHub } from '@/services/github';

/**
Expand Down Expand Up @@ -212,4 +219,134 @@
});
}
);

// ─── Auth0 SSO Routes ───────────────────────────────────────────────

// GET /sso - Redirect to Auth0 for SSO login
app.get('/sso', async (request: FastifyRequest, reply: FastifyReply) => {
if (!isAuth0Configured()) {
return reply.status(501).send({
error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured on this server' },
});
}

const querySchema = z.object({
connection: z.string().optional(),
});
const query = querySchema.safeParse(request.query);
const connection = query.success ? query.data.connection : undefined;

const state = crypto.randomUUID();

reply.setCookie('sso_state', state, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 600,
path: '/',
});

const authUrl = getAuth0AuthorizeUrl(state, connection);
logger.debug({ connection }, 'Redirecting to Auth0 SSO');

return reply.redirect(authUrl);
});
Comment on lines +226 to +253

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 5 days ago

To fix the problem, the /sso route should be configured with rate limiting using the same mechanism already used for /sso/callback. Fastify’s rate limit plugin supports per-route configuration via a config.rateLimit object in the route options. The best low-impact fix is therefore to wrap the /sso handler in a route definition that includes config: { rateLimit: { ... } }, mirroring the existing callback configuration but possibly with slightly different limits appropriate for an initiation endpoint.

Concretely, in apps/server/src/routes/auth.ts, edit the app.get('/sso', ...) declaration (around lines 225–253) to add a second argument: a route options object with a config.rateLimit property, before the async handler function. For example, configure a modest per-IP rate limit such as max: 30, timeWindow: '1 minute'. No new imports are needed because Fastify picks up these options via the already-registered rate-limit plugin (as evidenced by the /sso/callback route using the same pattern). The rest of the handler body (Auth0 configuration check, cookie setting, redirect) remains unchanged.

Suggested changeset 1
apps/server/src/routes/auth.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts
--- a/apps/server/src/routes/auth.ts
+++ b/apps/server/src/routes/auth.ts
@@ -223,35 +223,43 @@
   // ─── Auth0 SSO Routes ───────────────────────────────────────────────
 
   // GET /sso - Redirect to Auth0 for SSO login
-  app.get('/sso', async (request: FastifyRequest, reply: FastifyReply) => {
-    if (!isAuth0Configured()) {
-      return reply.status(501).send({
-        error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured on this server' },
+  app.get(
+    '/sso',
+    {
+      config: {
+        rateLimit: { max: 30, timeWindow: '1 minute' },
+      },
+    },
+    async (request: FastifyRequest, reply: FastifyReply) => {
+      if (!isAuth0Configured()) {
+        return reply.status(501).send({
+          error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured on this server' },
+        });
+      }
+
+      const querySchema = z.object({
+        connection: z.string().optional(),
       });
-    }
+      const query = querySchema.safeParse(request.query);
+      const connection = query.success ? query.data.connection : undefined;
 
-    const querySchema = z.object({
-      connection: z.string().optional(),
-    });
-    const query = querySchema.safeParse(request.query);
-    const connection = query.success ? query.data.connection : undefined;
+      const state = crypto.randomUUID();
 
-    const state = crypto.randomUUID();
+      reply.setCookie('sso_state', state, {
+        httpOnly: true,
+        secure: isProduction,
+        sameSite: 'lax',
+        maxAge: 600,
+        path: '/',
+      });
 
-    reply.setCookie('sso_state', state, {
-      httpOnly: true,
-      secure: isProduction,
-      sameSite: 'lax',
-      maxAge: 600,
-      path: '/',
-    });
+      const authUrl = getAuth0AuthorizeUrl(state, connection);
+      logger.debug({ connection }, 'Redirecting to Auth0 SSO');
 
-    const authUrl = getAuth0AuthorizeUrl(state, connection);
-    logger.debug({ connection }, 'Redirecting to Auth0 SSO');
+      return reply.redirect(authUrl);
+    }
+  );
 
-    return reply.redirect(authUrl);
-  });
-
   // GET /sso/callback - Auth0 SSO callback
   app.get(
     '/sso/callback',
EOF
@@ -223,35 +223,43 @@
// ─── Auth0 SSO Routes ───────────────────────────────────────────────

// GET /sso - Redirect to Auth0 for SSO login
app.get('/sso', async (request: FastifyRequest, reply: FastifyReply) => {
if (!isAuth0Configured()) {
return reply.status(501).send({
error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured on this server' },
app.get(
'/sso',
{
config: {
rateLimit: { max: 30, timeWindow: '1 minute' },
},
},
async (request: FastifyRequest, reply: FastifyReply) => {
if (!isAuth0Configured()) {
return reply.status(501).send({
error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured on this server' },
});
}

const querySchema = z.object({
connection: z.string().optional(),
});
}
const query = querySchema.safeParse(request.query);
const connection = query.success ? query.data.connection : undefined;

const querySchema = z.object({
connection: z.string().optional(),
});
const query = querySchema.safeParse(request.query);
const connection = query.success ? query.data.connection : undefined;
const state = crypto.randomUUID();

const state = crypto.randomUUID();
reply.setCookie('sso_state', state, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 600,
path: '/',
});

reply.setCookie('sso_state', state, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 600,
path: '/',
});
const authUrl = getAuth0AuthorizeUrl(state, connection);
logger.debug({ connection }, 'Redirecting to Auth0 SSO');

const authUrl = getAuth0AuthorizeUrl(state, connection);
logger.debug({ connection }, 'Redirecting to Auth0 SSO');
return reply.redirect(authUrl);
}
);

return reply.redirect(authUrl);
});

// GET /sso/callback - Auth0 SSO callback
app.get(
'/sso/callback',
Copilot is powered by AI and may make mistakes. Always verify output.

// GET /sso/callback - Auth0 SSO callback
app.get(
'/sso/callback',
{
config: {
rateLimit: { max: 10, timeWindow: '1 minute' },
},
},
async (request: FastifyRequest, reply: FastifyReply) => {
if (!isAuth0Configured()) {
return reply.status(501).send({
error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured' },
});
}

const callbackSchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
Comment on lines +270 to +272

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept Auth0 error callbacks without requiring code

The callback schema requires code even when Auth0 returns an OAuth error response (error/error_description with no code), so those valid failure callbacks are rejected as Invalid SSO callback parameters before your explicit if (error) handling runs. Users then get the wrong failure path instead of the intended SSO error response.

Useful? React with 👍 / 👎.

error: z.string().optional(),
error_description: z.string().optional(),
});

const result = callbackSchema.safeParse(request.query);
if (!result.success) {
throw new ValidationError('Invalid SSO callback parameters');
}

const { code, state, error, error_description } = result.data;

/* Validate CSRF state */
const storedState = request.cookies.sso_state;
if (!state || !storedState || state !== storedState) {
logger.warn('SSO state mismatch');
return reply.status(400).send({
error: { code: 'INVALID_STATE', message: 'Invalid SSO state parameter' },
});
}
reply.clearCookie('sso_state');

if (error) {
logger.warn({ error, error_description }, 'Auth0 SSO error');
return reply.status(400).send({
error: { code: 'SSO_ERROR', message: error_description ?? error },
});
}

/* Exchange code for tokens and fetch user info */
const tokens = await exchangeAuth0Code(code);
const userInfo = await getAuth0UserInfo(tokens.access_token);

const db = getDb();

/* Find existing user by email or create a new one */
let user = await db.user.findFirst({
where: { email: userInfo.email },
});

if (!user) {
user = await db.user.create({
data: {
githubId: `auth0_${userInfo.sub}`,
username: userInfo.nickname ?? userInfo.email.split('@')[0] ?? 'user',

Check failure on line 316 in apps/server/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check failure on line 316 in apps/server/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
displayName: userInfo.name || null,
avatarUrl: userInfo.picture || null,
email: userInfo.email,
tier: 'TEAM',
},
});

logger.info({ userId: user.id, email: userInfo.email }, 'New SSO user created');
}

/* Generate JWT */
const token = app.jwt.sign(
{
userId: user.id,
username: user.username,
tier: user.tier,
},
{ expiresIn: '7d' }
);

logger.info({ userId: user.id }, 'SSO authentication successful');

/* Redirect to web app with token */
const webUrl = new URL('/dashboard', env.WEB_APP_URL);
webUrl.searchParams.set('token', token);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop placing JWTs in the SSO redirect query string

Putting the signed session token into ?token= exposes a bearer credential in URL logs, browser history, and potential Referer leakage before client code strips it. This creates a concrete token disclosure path for every SSO login and should be replaced with a safer handoff (for example, HttpOnly cookie or one-time code exchange).

Useful? React with 👍 / 👎.

return reply.redirect(webUrl.toString());
}
Comment on lines +263 to +343

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 5 days ago

Generally, the fix is to ensure that this authorization route is explicitly rate-limited at the Fastify route level, using the standard rateLimit option that works with the @fastify/rate-limit plugin. This avoids reliance on non-standard configuration keys that static analysis might miss and guarantees that the handler cannot be abused for denial-of-service via excessive Auth0 callbacks.

Concretely, in apps/server/src/routes/auth.ts, modify the /sso/callback route definition to move the existing config.rateLimit configuration into the top-level rateLimit property of the route options object:

app.get(
  '/sso/callback',
  {
    rateLimit: {
      max: 10,
      timeWindow: '1 minute',
    },
  },
  async (request, reply) => { ... }
);

This keeps the same limits (10 requests per minute per IP, assuming default plugin behavior) while using the canonical configuration shape recognized by @fastify/rate-limit. No other logic changes are needed. Because we must not change imports beyond well-known libraries, and we cannot see plugin registration in this file, we limit ourselves to changing only the route options object; the actual plugin registration (e.g., app.register(require('@fastify/rate-limit'), ...)) would remain elsewhere in the codebase.

Suggested changeset 1
apps/server/src/routes/auth.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts
--- a/apps/server/src/routes/auth.ts
+++ b/apps/server/src/routes/auth.ts
@@ -256,8 +256,9 @@
   app.get(
     '/sso/callback',
     {
-      config: {
-        rateLimit: { max: 10, timeWindow: '1 minute' },
+      rateLimit: {
+        max: 10,
+        timeWindow: '1 minute',
       },
     },
     async (request: FastifyRequest, reply: FastifyReply) => {
EOF
@@ -256,8 +256,9 @@
app.get(
'/sso/callback',
{
config: {
rateLimit: { max: 10, timeWindow: '1 minute' },
rateLimit: {
max: 10,
timeWindow: '1 minute',
},
},
async (request: FastifyRequest, reply: FastifyReply) => {
Copilot is powered by AI and may make mistakes. Always verify output.
);

// GET /sso/status - Check if SSO is available
app.get('/sso/status', async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.send({
enabled: isAuth0Configured(),
});
});
Comment on lines +347 to +351

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 5 days ago

To fix the problem, the /sso/status route should be explicitly rate-limited, just like /sso/callback. In Fastify, this is typically done via the config.rateLimit option on the route definition (assuming the fastify-rate-limit plugin is registered at the app level). We will wrap the handler in the object-based app.get(path, options, handler) form and add a conservative rate limit configuration.

Concretely, in apps/server/src/routes/auth.ts, locate the app.get('/sso/status', async (_request: FastifyRequest, reply: FastifyReply) => { ... }); definition near lines 346–351. Replace it with a three-argument call: the same path, an options object containing config: { rateLimit: { max: 60, timeWindow: '1 minute' } } (or similar reasonable values), and the existing async handler function unchanged. No new imports or helper methods are required, because the file already uses config.rateLimit for /sso/callback, implying the necessary plugin is configured elsewhere.

Suggested changeset 1
apps/server/src/routes/auth.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts
--- a/apps/server/src/routes/auth.ts
+++ b/apps/server/src/routes/auth.ts
@@ -344,9 +344,17 @@
   );
 
   // GET /sso/status - Check if SSO is available
-  app.get('/sso/status', async (_request: FastifyRequest, reply: FastifyReply) => {
-    return reply.send({
-      enabled: isAuth0Configured(),
-    });
-  });
+  app.get(
+    '/sso/status',
+    {
+      config: {
+        rateLimit: { max: 60, timeWindow: '1 minute' },
+      },
+    },
+    async (_request: FastifyRequest, reply: FastifyReply) => {
+      return reply.send({
+        enabled: isAuth0Configured(),
+      });
+    }
+  );
 }
EOF
@@ -344,9 +344,17 @@
);

// GET /sso/status - Check if SSO is available
app.get('/sso/status', async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.send({
enabled: isAuth0Configured(),
});
});
app.get(
'/sso/status',
{
config: {
rateLimit: { max: 60, timeWindow: '1 minute' },
},
},
async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.send({
enabled: isAuth0Configured(),
});
}
);
}
Copilot is powered by AI and may make mistakes. Always verify output.
}
Loading
Loading