Initial commit: Pokerface sprint planning poker for Jira

Full-stack app with Express/Socket.io backend, React frontend,
NATS JetStream for state, and Atlassian Jira OAuth integration.

Includes security hardening: NATS auth support, KV bucket TTL
enforcement, CAS retry for race conditions, error message
sanitization, and OAuth state stored in NATS KV.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-02-26 21:38:37 +01:00
commit fdd9ba8d56
36 changed files with 7596 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
.env
.DS_Store
backend/node_modules
frontend/node_modules
backend/prisma/migrations
frontend/dist
backend/.env

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM node:22-alpine
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev
COPY backend/ ./
COPY --from=frontend /app/frontend/dist /app/frontend/dist
ENV NODE_ENV=production
ENV PORT=4010
EXPOSE 4010
CMD ["node", "src/index.js"]

12
backend/.env.example Normal file
View file

@ -0,0 +1,12 @@
PORT=4010
FRONTEND_URL=http://localhost:5174
NATS_URL=nats://localhost:4222
# Jira OAuth (Atlassian 3LO)
JIRA_CLIENT_ID=
JIRA_CLIENT_SECRET=
JIRA_OAUTH_REDIRECT_URI=http://localhost:4010/api/jira/oauth/callback
JIRA_SCOPES="offline_access read:jira-work write:jira-work read:me"
JWT_SECRET=change-me-to-a-random-secret
JIRA_STORY_POINTS_FIELD=customfield_10016
JIRA_MOCK_FALLBACK=true

1648
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
backend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "pokerface-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"@mickl/socket.io-nats-adapter": "^2.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.3",
"nats": "^2.28.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}

294
backend/src/index.js Normal file
View file

@ -0,0 +1,294 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createServer } from 'http';
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import { Server } from 'socket.io';
import natsAdapter from '@mickl/socket.io-nats-adapter';
const { createAdapter } = natsAdapter;
import { connectNats, getNatsConnection } from './lib/nats.js';
import pokerRoutes from './routes/poker.js';
import jiraRoutes from './routes/jira.js';
import roomRoutes from './routes/rooms.js';
import {
canAccessSession,
getSessionSnapshot,
isSessionParticipant,
joinSession,
leaveSession,
revealIfComplete,
saveScopedEstimate,
submitVote
} from './services/pokerService.js';
import { updateIssueEstimate } from './services/jiraService.js';
import { getSocketUser } from './middleware/auth.js';
import { safeError } from './lib/errors.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isProd = process.env.NODE_ENV === 'production';
const app = express();
const httpServer = createServer(app);
const port = Number(process.env.PORT || 4010);
if (isProd && !process.env.FRONTEND_URL) {
throw new Error('FRONTEND_URL must be set in production.');
}
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5174';
const corsOptions = { origin: frontendUrl, credentials: true };
function isAllowedOrigin(origin) {
if (!origin) return !isProd;
return origin === frontendUrl;
}
app.disable('x-powered-by');
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
if (isProd) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
app.use(cors(corsOptions));
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/poker', pokerRoutes);
app.use('/api/jira', jiraRoutes);
app.use('/api/poker/rooms', roomRoutes);
if (isProd) {
const distPath = path.resolve(__dirname, '../../frontend/dist');
app.use(express.static(distPath));
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
res.sendFile(path.join(distPath, 'index.html'));
});
}
const io = new Server(httpServer, {
cors: corsOptions,
allowRequest: (req, callback) => {
if (isAllowedOrigin(req.headers.origin)) {
callback(null, true);
return;
}
callback('Origin not allowed', false);
}
});
async function emitSessionState(sessionId) {
const snapshot = await getSessionSnapshot(sessionId);
if (!snapshot) return;
io.to(`poker:${sessionId}`).emit('poker:participants', {
sessionId,
participants: snapshot.participants,
votedUserKeys: snapshot.votedUserKeys,
voteCount: snapshot.voteCount,
participantCount: snapshot.participantCount
});
io.to(`poker:${sessionId}`).emit('poker:vote-update', {
sessionId,
voteCount: snapshot.voteCount,
participantCount: snapshot.participantCount,
votedUserKeys: snapshot.votedUserKeys,
allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount
});
if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') {
io.to(`poker:${sessionId}`).emit('poker:revealed', {
sessionId,
votes: snapshot.votesByUser,
average: snapshot.session.averageEstimate,
suggestedEstimate: snapshot.session.suggestedEstimate,
savedEstimate: snapshot.session.savedEstimate
});
}
}
io.on('connection', (socket) => {
const user = getSocketUser(socket);
if (!user || !user.jiraCloudId) {
socket.emit('poker:error', { error: 'Not authenticated' });
socket.disconnect(true);
return;
}
socket.user = user;
socket.on('poker:join', async ({ sessionId }) => {
try {
if (!sessionId) {
socket.emit('poker:error', { error: 'sessionId is required.' });
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
socket.join(`poker:${sessionId}`);
const snapshot = await joinSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId,
userName: socket.user.displayName,
avatarUrl: socket.user.avatarUrl || null
});
if (!snapshot) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
await emitSessionState(sessionId);
} catch (error) {
console.error('[socket] poker:join failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
socket.on('poker:vote', async ({ sessionId, vote }) => {
try {
if (!sessionId) {
socket.emit('poker:error', { error: 'sessionId is required.' });
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
socket.emit('poker:error', { error: 'Join the session before voting.' });
return;
}
const voteResult = await submitVote({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId,
vote
});
if (!voteResult) {
socket.emit('poker:error', { error: 'Unable to submit vote for this session.' });
return;
}
const reveal = await revealIfComplete(sessionId);
await emitSessionState(sessionId);
if (reveal?.allVoted) {
io.to(`poker:${sessionId}`).emit('poker:revealed', {
sessionId,
votes: reveal.votesByUser,
average: reveal.average,
suggestedEstimate: reveal.suggestedEstimate
});
}
} catch (error) {
console.error('[socket] poker:vote failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
socket.on('poker:save', async ({ sessionId, estimate }) => {
try {
if (!sessionId) {
socket.emit('poker:error', { error: 'sessionId is required.' });
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
socket.emit('poker:error', { error: 'Join the session before saving.' });
return;
}
const numericEstimate = Number(estimate);
if (!Number.isFinite(numericEstimate)) {
socket.emit('poker:error', { error: 'estimate must be a number.' });
return;
}
const saved = await saveScopedEstimate({
sessionId,
estimate: numericEstimate,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId
});
if (!saved) {
socket.emit('poker:error', { error: 'Unable to save estimate for this session.' });
return;
}
const issueRef = saved.session.issueId || saved.session.issueKey;
try {
await updateIssueEstimate(socket.user.jiraAccountId, issueRef, numericEstimate, saved.session.boardId);
} catch (_jiraError) {
// Jira update is best-effort so poker flow continues even when Jira is unavailable.
}
io.to(`poker:${sessionId}`).emit('poker:saved', {
sessionId,
estimate: numericEstimate,
issueKey: saved.session.issueKey
});
io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId });
} catch (error) {
console.error('[socket] poker:save failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
socket.on('poker:leave', async ({ sessionId }) => {
try {
if (!sessionId) return;
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return;
await leaveSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId
});
socket.leave(`poker:${sessionId}`);
await emitSessionState(sessionId);
} catch (error) {
console.error('[socket] poker:leave failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
});
async function start() {
const nc = await connectNats();
io.adapter(createAdapter(nc));
httpServer.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Pokerface backend listening on :${port}`);
});
}
start().catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to start server:', error);
process.exit(1);
});

View file

@ -0,0 +1,5 @@
const isProd = process.env.NODE_ENV === 'production';
export function safeError(error) {
return isProd ? 'Internal server error.' : error.message;
}

78
backend/src/lib/nats.js Normal file
View file

@ -0,0 +1,78 @@
import { connect } from 'nats';
const natsUrl = process.env.NATS_URL || 'nats://localhost:4222';
const BUCKET_TTL_MS = 24 * 60 * 60 * 1000; // 24h in milliseconds
let nc = null;
let js = null;
let kvOAuth = null;
let kvRooms = null;
let kvSprintIndex = null;
let kvActiveRooms = null;
let kvSessions = null;
let kvSessionIndex = null;
let kvOAuthState = null;
export async function connectNats() {
const opts = { servers: natsUrl };
if (process.env.NATS_TOKEN) {
opts.token = process.env.NATS_TOKEN;
} else if (process.env.NATS_USER) {
opts.user = process.env.NATS_USER;
opts.pass = process.env.NATS_PASS || '';
}
nc = await connect(opts);
js = nc.jetstream();
async function getOrCreateKv(name, ttl = BUCKET_TTL_MS) {
const kv = await js.views.kv(name, { ttl });
// Enforce TTL on existing buckets whose config may be stale
const jsm = await nc.jetstreamManager();
const info = await jsm.streams.info(`KV_${name}`);
if (info.config.max_age !== ttl * 1_000_000) { // NATS uses nanoseconds
info.config.max_age = ttl * 1_000_000;
await jsm.streams.update(info.config.name, info.config);
}
return kv;
}
kvOAuth = await getOrCreateKv('oauth');
kvRooms = await getOrCreateKv('rooms');
kvSprintIndex = await getOrCreateKv('rooms-sprint-index');
kvActiveRooms = await getOrCreateKv('active-rooms');
kvSessions = await getOrCreateKv('sessions');
kvSessionIndex = await getOrCreateKv('session-issue-index');
kvOAuthState = await getOrCreateKv('oauth-state', 10 * 60 * 1000); // 10min TTL
// eslint-disable-next-line no-console
console.log('Connected to NATS at', natsUrl);
return nc;
}
export function getNatsConnection() {
return nc;
}
export async function withCasRetry(kv, key, transformFn, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const entry = await kv.get(key);
const current = entry ? entry.json() : null;
const revision = entry ? entry.revision : 0;
const result = await transformFn(current, revision);
if (result === undefined) return current; // no-op
try {
if (revision === 0) {
await kv.create(key, JSON.stringify(result));
} else {
await kv.update(key, JSON.stringify(result), revision);
}
return result;
} catch (err) {
if (attempt === maxRetries) throw err;
// CAS conflict — retry
}
}
}
export { nc, js, kvOAuth, kvRooms, kvSprintIndex, kvActiveRooms, kvSessions, kvSessionIndex, kvOAuthState };

View file

@ -0,0 +1,74 @@
import jwt from 'jsonwebtoken';
const COOKIE_NAME = 'pokerface_session';
const JWT_EXPIRY = '24h';
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function getSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('Missing JWT_SECRET env var');
return secret;
}
export function createSessionToken(user) {
return jwt.sign(
{
jiraAccountId: user.jiraAccountId,
jiraCloudId: user.jiraCloudId,
displayName: user.displayName,
avatarUrl: user.avatarUrl
},
getSecret(),
{ expiresIn: JWT_EXPIRY }
);
}
export function setSessionCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: SESSION_MAX_AGE_MS
});
}
export function clearSessionCookie(res) {
res.clearCookie(COOKIE_NAME);
}
function verifyToken(token) {
try {
return jwt.verify(token, getSecret());
} catch {
return null;
}
}
export function requireAuth(req, res, next) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid or expired session' });
}
if (!user.jiraAccountId || !user.jiraCloudId) {
return res.status(401).json({ error: 'Invalid session tenant context' });
}
req.user = user;
next();
}
export function getSocketUser(socket) {
const cookieHeader = socket.handshake.headers.cookie;
if (!cookieHeader) return null;
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
if (!match) return null;
return verifyToken(match[1]);
}

175
backend/src/routes/jira.js Normal file
View file

@ -0,0 +1,175 @@
import crypto from 'crypto';
import express from 'express';
import {
buildOAuthUrl,
exchangeCodeForToken,
getProjects,
getBoards,
getBoardSprints,
getSprintIssues,
saveOAuthConnection,
updateIssueEstimate
} from '../services/jiraService.js';
import {
createSessionToken,
setSessionCookie,
clearSessionCookie,
requireAuth
} from '../middleware/auth.js';
import { kvOAuthState } from '../lib/nats.js';
import { safeError } from '../lib/errors.js';
const router = express.Router();
router.get('/oauth/start', async (_req, res) => {
try {
const state = crypto.randomBytes(24).toString('hex');
await kvOAuthState.put(state, 'valid');
const url = buildOAuthUrl(state);
res.json({ url });
} catch (error) {
console.error('[oauth] Start failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/oauth/callback', async (req, res) => {
const { state, code } = req.query;
const isProd = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || (isProd ? '' : 'http://localhost:5174');
try {
const entry = await kvOAuthState.get(String(state));
if (!state || !entry) {
return res.status(400).send('Invalid OAuth state.');
}
if (!code) {
return res.status(400).send('Missing OAuth code.');
}
await kvOAuthState.delete(String(state));
const tokenPayload = await exchangeCodeForToken(String(code));
const { connection, profile } = await saveOAuthConnection(tokenPayload);
if (!connection.cloudId) {
throw new Error('No Jira cloud site available for this account.');
}
const jwt = createSessionToken({
jiraAccountId: profile.accountId,
jiraCloudId: connection.cloudId,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl
});
setSessionCookie(res, jwt);
res.redirect(`${frontendUrl}?auth=success`);
} catch (error) {
console.error('[oauth] Callback failed:', error.message);
const safeMessage = isProd ? 'Jira authentication failed.' : error.message;
res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`);
}
});
router.get('/me', requireAuth, (req, res) => {
res.json({
jiraAccountId: req.user.jiraAccountId,
jiraCloudId: req.user.jiraCloudId,
displayName: req.user.displayName,
avatarUrl: req.user.avatarUrl
});
});
router.post('/logout', (_req, res) => {
clearSessionCookie(res);
res.json({ ok: true });
});
router.get('/projects', requireAuth, async (req, res) => {
try {
const projects = await getProjects(req.user.jiraAccountId);
res.json({ projects });
} catch (error) {
console.error('[jira] Projects failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/boards', requireAuth, async (req, res) => {
try {
const { projectKeyOrId } = req.query;
if (!projectKeyOrId) {
return res.status(400).json({ error: 'projectKeyOrId query parameter is required.' });
}
const boards = await getBoards(req.user.jiraAccountId, projectKeyOrId);
res.json({ boards });
} catch (error) {
console.error('[jira] Boards failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/boards/:boardId/sprints', requireAuth, async (req, res) => {
try {
const sprints = await getBoardSprints(req.user.jiraAccountId, req.params.boardId);
res.json({ sprints });
} catch (error) {
console.error('[jira] Sprints failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/status', requireAuth, async (req, res) => {
try {
res.json({ connected: true, jiraAccountId: req.user.jiraAccountId });
} catch (error) {
console.error('[jira] Status failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/sprints/:sprintId/issues', requireAuth, async (req, res) => {
try {
const { sprintId } = req.params;
const { boardId } = req.query;
const issues = await getSprintIssues(req.user.jiraAccountId, String(sprintId), boardId ? String(boardId) : null);
res.json({ issues, source: 'jira' });
} catch (error) {
if (process.env.JIRA_MOCK_FALLBACK === 'true') {
const sprintId = req.params.sprintId;
const issues = [
{ id: `mock-${sprintId}-1`, key: `MOCK-${sprintId}1`, title: 'Authentication hardening', description: 'Harden authentication flow against common attacks.', estimate: 0, status: 'To Do', assignee: 'Alice' },
{ id: `mock-${sprintId}-2`, key: `MOCK-${sprintId}2`, title: 'Webhook retries', description: 'Add retry logic with exponential backoff for failed webhooks.', estimate: 0, status: 'To Do', assignee: 'Bob' },
{ id: `mock-${sprintId}-3`, key: `MOCK-${sprintId}3`, title: 'Billing summary endpoint', description: 'Create GET /billing/summary endpoint.', estimate: 5, status: 'In Progress', assignee: 'Eve' }
];
return res.json({ issues, source: 'mock' });
}
console.error('[jira] Sprint issues failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.patch('/issues/:issueIdOrKey/estimate', requireAuth, async (req, res) => {
try {
const { issueIdOrKey } = req.params;
const { estimate, boardId } = req.body;
if (estimate === undefined || estimate === null) {
return res.status(400).json({ error: 'estimate is required.' });
}
if (!boardId) {
return res.status(400).json({ error: 'boardId is required.' });
}
await updateIssueEstimate(req.user.jiraAccountId, issueIdOrKey, Number(estimate), String(boardId));
res.json({ ok: true });
} catch (error) {
console.error('[jira] Estimate update failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
export default router;

View file

@ -0,0 +1,65 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { canAccessSession, createScopedSession, getSessionSnapshot } from '../services/pokerService.js';
import { getRoomById } from '../services/roomService.js';
import { safeError } from '../lib/errors.js';
const router = express.Router();
router.post('/sessions', requireAuth, async (req, res) => {
try {
const { issueKey, issueId, issueTitle, roomId } = req.body;
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
if (!issueKey || !issueTitle) {
return res.status(400).json({ error: 'issueKey and issueTitle are required.' });
}
if (!roomId) {
return res.status(400).json({ error: 'roomId is required.' });
}
const room = await getRoomById(String(roomId), req.user.jiraCloudId);
if (!room) {
return res.status(404).json({ error: 'Room not found.' });
}
const snapshot = await createScopedSession({
issueKey,
issueId,
issueTitle,
roomId: String(roomId),
boardId: room.boardId,
tenantCloudId: req.user.jiraCloudId
});
return res.json({ session: snapshot.session });
} catch (error) {
console.error('[poker] Create session failed:', error);
return res.status(500).json({ error: safeError(error) });
}
});
router.get('/sessions/:sessionId', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
if (!await canAccessSession(req.params.sessionId, req.user.jiraCloudId)) {
return res.status(404).json({ error: 'Session not found.' });
}
const snapshot = await getSessionSnapshot(req.params.sessionId);
if (!snapshot) {
return res.status(404).json({ error: 'Session not found.' });
}
res.json(snapshot);
} catch (error) {
console.error('[poker] Get session failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
export default router;

View file

@ -0,0 +1,98 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { createRoom, getActiveRooms, getRoomById, deleteRoom } from '../services/roomService.js';
import { safeError } from '../lib/errors.js';
const router = express.Router();
router.post('/', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const { boardId, projectKey, projectName, sprintId, sprintName } = req.body;
if (!boardId || !sprintId || !sprintName) {
return res.status(400).json({ error: 'boardId, sprintId, and sprintName are required.' });
}
const numericBoardId = Number(boardId);
const numericSprintId = Number(sprintId);
if (!Number.isInteger(numericBoardId) || !Number.isInteger(numericSprintId) || numericBoardId <= 0 || numericSprintId <= 0) {
return res.status(400).json({ error: 'boardId and sprintId must be valid integers.' });
}
const room = await createRoom({
cloudId: req.user.jiraCloudId,
boardId: numericBoardId,
projectKey: projectKey || '',
projectName: projectName || '',
sprintId: numericSprintId,
sprintName,
createdByAccountId: req.user.jiraAccountId,
createdByName: req.user.displayName
});
res.json({ room });
} catch (error) {
console.error('[rooms] Create room failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const rooms = await getActiveRooms(req.user.jiraCloudId);
res.json({ rooms });
} catch (error) {
console.error('[rooms] Get active rooms failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/:roomId', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
if (!room) {
return res.status(404).json({ error: 'Room not found.' });
}
res.json({ room });
} catch (error) {
console.error('[rooms] Get room failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.delete('/:roomId', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
if (!room) {
return res.status(404).json({ error: 'Room not found.' });
}
if (room.createdByAccountId !== req.user.jiraAccountId) {
return res.status(403).json({ error: 'Only the room creator can delete this room.' });
}
await deleteRoom(req.params.roomId, req.user.jiraCloudId);
res.json({ ok: true });
} catch (error) {
console.error('[rooms] Delete room failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
export default router;

View file

@ -0,0 +1,285 @@
import { kvOAuth } from '../lib/nats.js';
const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources';
const ATLASSIAN_ME_URL = 'https://api.atlassian.com/me';
function getRequiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}
function getExpiresAt(expiresInSeconds) {
if (!expiresInSeconds) return null;
return Date.now() + (Number(expiresInSeconds) - 60) * 1000;
}
export function buildOAuthUrl(state) {
const clientId = getRequiredEnv('JIRA_CLIENT_ID');
const redirectUri = getRequiredEnv('JIRA_OAUTH_REDIRECT_URI');
const scope = process.env.JIRA_SCOPES || 'offline_access read:jira-work write:jira-work read:me';
const params = new URLSearchParams({
audience: 'api.atlassian.com',
client_id: clientId,
scope,
redirect_uri: redirectUri,
state,
response_type: 'code',
prompt: 'consent'
});
return `${ATLASSIAN_AUTH_URL}?${params.toString()}`;
}
export async function exchangeCodeForToken(code) {
const body = {
grant_type: 'authorization_code',
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
code,
redirect_uri: getRequiredEnv('JIRA_OAUTH_REDIRECT_URI')
};
const response = await fetch(ATLASSIAN_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
return response.json();
}
async function getAccessibleResources(accessToken) {
const response = await fetch(ATLASSIAN_RESOURCES_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to load Jira resources: ${response.status} ${errorText}`);
}
return response.json();
}
export async function fetchJiraProfile(accessToken) {
const response = await fetch(ATLASSIAN_ME_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch Jira profile: ${response.status} ${errorText}`);
}
const data = await response.json();
return {
accountId: data.account_id,
displayName: data.name || data.displayName || 'Unknown',
avatarUrl: data.picture || null,
email: data.email || null
};
}
export async function saveOAuthConnection(tokenPayload) {
const profile = await fetchJiraProfile(tokenPayload.access_token);
const resources = await getAccessibleResources(tokenPayload.access_token);
const primary = resources[0] || {};
const connection = {
jiraAccountId: profile.accountId,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
email: profile.email,
cloudId: primary.id || null,
siteUrl: primary.url || null,
accessToken: tokenPayload.access_token,
refreshToken: tokenPayload.refresh_token || null,
scope: tokenPayload.scope || null,
expiresAt: getExpiresAt(tokenPayload.expires_in)
};
await kvOAuth.put(`oauth.${profile.accountId}`, JSON.stringify(connection));
return { connection, profile };
}
async function refreshAccessToken(connection) {
if (!connection.refreshToken) {
throw new Error('Jira refresh token is missing. Reconnect Jira OAuth.');
}
const body = {
grant_type: 'refresh_token',
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
refresh_token: connection.refreshToken
};
const response = await fetch(ATLASSIAN_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
}
const refreshed = await response.json();
const updated = {
...connection,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || connection.refreshToken,
scope: refreshed.scope || connection.scope,
expiresAt: getExpiresAt(refreshed.expires_in)
};
await kvOAuth.put(`oauth.${connection.jiraAccountId}`, JSON.stringify(updated));
return updated;
}
async function getValidConnection(jiraAccountId) {
const entry = await kvOAuth.get(`oauth.${jiraAccountId}`);
if (!entry) throw new Error('Jira is not connected for this account.');
const connection = entry.json();
if (!connection.accessToken || !connection.cloudId) {
throw new Error('Jira is not connected for this account.');
}
if (!connection.expiresAt || connection.expiresAt > Date.now()) {
return connection;
}
return refreshAccessToken(connection);
}
async function jiraFetch(jiraAccountId, path, options = {}) {
const connection = await getValidConnection(jiraAccountId);
const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`;
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${connection.accessToken}`,
...(options.headers || {})
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Jira API failed: ${response.status} ${errorText}`);
}
if (response.status === 204) return null;
return response.json();
}
function normalizeIssue(issue) {
return {
id: issue.id,
key: issue.key,
title: issue.fields?.summary || issue.key,
description: issue.fields?.description || null,
estimate: issue.fields?.[process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016'] || 0,
status: issue.fields?.status?.name || 'Unknown',
reporter: issue.fields?.reporter?.displayName || null
};
}
export async function getSprintIssues(jiraAccountId, sprintId, boardId) {
let spField = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016';
if (boardId) {
try {
const config = await jiraFetch(jiraAccountId, `/rest/agile/1.0/board/${boardId}/configuration`);
const estField = config?.estimation?.field?.fieldId;
if (estField) spField = estField;
} catch (_e) {
// fall back to default
}
}
const response = await jiraFetch(
jiraAccountId,
`/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=200&jql=${encodeURIComponent('resolution = Unresolved AND issuetype not in subtaskIssueTypes()')}&fields=summary,status,reporter,description,${spField}`
);
return (response?.issues || []).map((issue) => ({
id: issue.id,
key: issue.key,
title: issue.fields?.summary || issue.key,
description: issue.fields?.description || null,
estimate: issue.fields?.[spField] || 0,
status: issue.fields?.status?.name || 'Unknown',
reporter: issue.fields?.reporter?.displayName || null
})).filter((issue) => !issue.estimate);
}
export async function updateIssueEstimate(jiraAccountId, issueIdOrKey, estimate, boardId) {
await jiraFetch(jiraAccountId, `/rest/agile/1.0/issue/${issueIdOrKey}/estimation?boardId=${boardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(estimate) })
});
}
export async function getProjects(jiraAccountId) {
const response = await jiraFetch(
jiraAccountId,
'/rest/api/3/project/search?maxResults=100&orderBy=name'
);
return (response?.values || []).map((project) => ({
id: project.id,
key: project.key,
name: project.name,
avatarUrl: project.avatarUrls?.['24x24'] || null
}));
}
export async function getBoards(jiraAccountId, projectKeyOrId) {
const response = await jiraFetch(
jiraAccountId,
`/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}&maxResults=100`
);
return (response?.values || [])
.filter((board) => board.type === 'scrum' || board.type === 'simple')
.map((board) => ({
id: board.id,
name: board.name,
type: board.type,
projectKey: board.location?.projectKey || null,
projectName: board.location?.projectName || null
}));
}
export async function getBoardSprints(jiraAccountId, boardId) {
const response = await jiraFetch(
jiraAccountId,
`/rest/agile/1.0/board/${boardId}/sprint?state=active,future`
);
return (response?.values || []).map((sprint) => ({
id: sprint.id,
name: sprint.name,
state: sprint.state,
startDate: sprint.startDate || null,
endDate: sprint.endDate || null
}));
}

View file

@ -0,0 +1,250 @@
import crypto from 'crypto';
import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js';
const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100];
function parseVote(vote) {
if (vote === null || vote === undefined) return { rawValue: '', numericValue: null };
if (typeof vote === 'number') return { rawValue: String(vote), numericValue: vote };
if (typeof vote === 'string') {
const normalized = vote.trim();
const numeric = Number(normalized);
return {
rawValue: normalized,
numericValue: Number.isFinite(numeric) ? numeric : null
};
}
return { rawValue: String(vote), numericValue: null };
}
function nearestFibonacci(value) {
return FIBONACCI.reduce((best, candidate) =>
Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best
);
}
function serializeSession(session) {
return {
...session,
participants: Object.fromEntries(session.participants),
votes: Object.fromEntries(session.votes)
};
}
function deserializeSession(data) {
if (!data) return null;
data.participants = new Map(Object.entries(data.participants));
data.votes = new Map(Object.entries(data.votes));
return data;
}
async function getSession(sessionId) {
const entry = await kvSessions.get(sessionId);
if (!entry) return null;
return deserializeSession(entry.json());
}
async function putSession(session) {
await kvSessions.put(session.id, JSON.stringify(serializeSession(session)));
}
async function withSessionCas(sessionId, transformFn) {
return withCasRetry(kvSessions, sessionId, (raw) => {
const session = deserializeSession(raw);
if (!session) return undefined;
const result = transformFn(session);
if (result === undefined) return undefined;
return serializeSession(result);
});
}
function getSnapshot(session) {
// Accept both Map-based and plain-object sessions
const participantsMap = session.participants instanceof Map
? session.participants
: new Map(Object.entries(session.participants));
const votesMap = session.votes instanceof Map
? session.votes
: new Map(Object.entries(session.votes));
const participants = [...participantsMap.values()];
const votes = Object.fromEntries(votesMap);
const votedUserKeys = [...votesMap.keys()];
return {
session: {
id: session.id,
issueKey: session.issueKey,
issueId: session.issueId,
issueTitle: session.issueTitle,
boardId: session.boardId,
state: session.state,
averageEstimate: session.averageEstimate,
suggestedEstimate: session.suggestedEstimate,
savedEstimate: session.savedEstimate
},
participants,
votesByUser: votes,
voteCount: votesMap.size,
participantCount: participantsMap.size,
votedUserKeys
};
}
function issueIndexKey(cloudId, issueKey) {
return `${cloudId}.${issueKey}`;
}
export async function createScopedSession({ issueKey, issueId, issueTitle, roomId, boardId, tenantCloudId }) {
// Check for existing active session via issue index
const indexEntry = await kvSessionIndex.get(issueIndexKey(tenantCloudId, issueKey));
if (indexEntry) {
const existingId = indexEntry.string();
const existing = await getSession(existingId);
if (existing && existing.tenantCloudId === tenantCloudId) {
if (existing.state === 'VOTING') {
return getSnapshot(existing);
}
// Clean up stale revealed/saved sessions
await kvSessions.delete(existingId);
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, issueKey));
}
}
const id = crypto.randomUUID();
const session = {
id,
issueKey,
issueId,
issueTitle,
roomId,
boardId,
tenantCloudId,
createdAt: Date.now(),
state: 'VOTING',
participants: new Map(),
votes: new Map(),
averageEstimate: null,
suggestedEstimate: null,
savedEstimate: null
};
await putSession(session);
await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id);
return getSnapshot(session);
}
export async function getSessionSnapshot(sessionId) {
const session = await getSession(sessionId);
if (!session) return null;
return getSnapshot(session);
}
export async function canAccessSession(sessionId, tenantCloudId) {
const session = await getSession(sessionId);
if (!session) return false;
return session.tenantCloudId === tenantCloudId;
}
export async function isSessionParticipant(sessionId, userKey) {
const session = await getSession(sessionId);
if (!session) return false;
return session.participants.has(userKey);
}
export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) {
const result = await withSessionCas(sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
session.participants.set(userKey, { userKey, userName, avatarUrl });
session.votes.delete(userKey);
return session;
});
if (!result) return null;
return getSnapshot(result);
}
export async function leaveSession({ sessionId, tenantCloudId, userKey }) {
const result = await withSessionCas(sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
session.participants.delete(userKey);
session.votes.delete(userKey);
return session;
});
if (!result) return null;
return getSnapshot(result);
}
export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) {
const result = await withSessionCas(sessionId, (session) => {
if (session.state === 'REVEALED' || session.state === 'SAVED') return undefined;
if (session.state !== 'VOTING') return undefined;
if (session.tenantCloudId !== tenantCloudId) return undefined;
if (!session.participants.has(userKey)) return undefined;
const parsed = parseVote(vote);
session.votes.set(userKey, parsed.rawValue);
return session;
});
if (!result) {
// Return current snapshot for REVEALED/SAVED states
const current = await getSession(sessionId);
if (current && (current.state === 'REVEALED' || current.state === 'SAVED')) {
return getSnapshot(current);
}
return null;
}
return getSnapshot(result);
}
export async function revealIfComplete(sessionId) {
const result = await withSessionCas(sessionId, (session) => {
const allVoted = session.participants.size > 0 &&
session.votes.size === session.participants.size;
if (!allVoted) return undefined; // no mutation needed
const numericVotes = [...session.votes.values()]
.map(Number)
.filter(Number.isFinite);
const average = numericVotes.length
? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length
: 0;
const suggestedEstimate = nearestFibonacci(average);
session.state = 'REVEALED';
session.averageEstimate = average;
session.suggestedEstimate = suggestedEstimate;
return session;
});
if (!result) {
// Not all voted — return current snapshot
const current = await getSession(sessionId);
if (!current) return null;
return { ...getSnapshot(current), allVoted: false };
}
const snapshot = getSnapshot(result);
return {
...snapshot,
allVoted: true,
average: snapshot.session.averageEstimate,
suggestedEstimate: snapshot.session.suggestedEstimate
};
}
export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) {
const session = await getSession(sessionId);
if (!session) return null;
if (session.tenantCloudId !== tenantCloudId) return null;
if (!session.participants.has(userKey)) return null;
session.savedEstimate = estimate;
session.state = 'SAVED';
const snapshot = getSnapshot(session);
// Clean up — session is done
await kvSessions.delete(sessionId);
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, session.issueKey));
return snapshot;
}

View file

@ -0,0 +1,124 @@
import crypto from 'crypto';
import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js';
function assertCloudId(cloudId) {
if (!cloudId) {
throw new Error('Missing Jira tenant (cloudId). Please reconnect Jira.');
}
}
function roomKey(cloudId, id) { return `${cloudId}.${id}`; }
function sprintKey(cloudId, sprintId) { return `${cloudId}.${sprintId}`; }
export async function createRoom(payload) {
const {
cloudId,
boardId,
projectKey,
projectName,
sprintId,
sprintName,
createdByAccountId,
createdByName
} = payload;
assertCloudId(cloudId);
// Return existing room for this sprint if one exists
try {
const existingEntry = await kvSprintIndex.get(sprintKey(cloudId, sprintId));
if (existingEntry) {
const existingId = existingEntry.string();
if (existingId) {
const roomEntry = await kvRooms.get(roomKey(cloudId, existingId));
if (roomEntry) return roomEntry.json();
}
}
} catch {
// Stale index data — continue to create a new room
}
const id = crypto.randomUUID();
const room = {
id,
cloudId,
boardId,
projectKey,
projectName,
sprintId,
sprintName,
createdByAccountId,
createdByName
};
await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room));
await kvSprintIndex.put(sprintKey(cloudId, sprintId), id);
// Update active rooms set with CAS retry
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
const ids = activeIds || [];
if (ids.includes(id)) return undefined; // already present, no-op
return [...ids, id];
});
return room;
}
export async function getActiveRooms(cloudId) {
assertCloudId(cloudId);
const activeEntry = await kvActiveRooms.get(cloudId);
if (!activeEntry) return [];
const ids = activeEntry.json();
if (!ids.length) return [];
const staleIds = [];
const parsedRooms = [];
for (const id of ids) {
if (!id) { staleIds.push(id); continue; }
try {
const entry = await kvRooms.get(roomKey(cloudId, id));
if (!entry) {
staleIds.push(id);
continue;
}
parsedRooms.push(entry.json());
} catch {
staleIds.push(id);
}
}
if (staleIds.length) {
await withCasRetry(kvActiveRooms, cloudId, (current) => {
const currentIds = current || [];
return currentIds.filter((id) => !staleIds.includes(id));
});
}
return parsedRooms;
}
export async function getRoomById(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
return entry ? entry.json() : null;
}
export async function deleteRoom(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
if (!entry) return;
const room = entry.json();
await kvRooms.delete(roomKey(cloudId, roomId));
await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId));
// Remove from active rooms set with CAS retry
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
if (!activeIds) return undefined;
const filtered = activeIds.filter((id) => id !== roomId);
if (filtered.length === activeIds.length) return undefined; // not found, no-op
return filtered;
});
}

1
frontend/.env.example Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=http://localhost:4010/api

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Pokerface</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2375
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "pokerface-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"highlight.js": "^11.11.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.4",
"vite": "^7.1.7"
}
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<rect x="4" y="3" width="24" height="26" rx="3" fill="none" stroke="#10b981" stroke-width="2"/>
<text x="16" y="20.5" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="800" font-size="12" fill="#10b981">PF</text>
</svg>

After

Width:  |  Height:  |  Size: 359 B

137
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import LoginScreen from './components/LoginScreen';
import Lobby from './components/Lobby';
import Room from './components/Room';
import LegalPage from './components/LegalPage';
import { api } from './services/api';
function useDarkMode() {
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem('pokerface-dark');
if (saved !== null) return saved === 'true';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('pokerface-dark', dark);
}, [dark]);
return [dark, () => setDark((d) => !d)];
}
export default function App() {
const [view, setView] = useState('loading');
const [user, setUser] = useState(null);
const [activeRoom, setActiveRoom] = useState(null);
const [prevView, setPrevView] = useState('login');
const [dark, toggleDark] = useDarkMode();
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
const params = new URLSearchParams(window.location.search);
if (params.get('auth') === 'error') {
const message = params.get('message') || 'Authentication failed.';
console.error('[auth] OAuth callback error:', message);
window.history.replaceState({}, '', window.location.pathname);
setView('login');
return;
}
if (params.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
try {
const me = await api.getMe();
setUser(me);
// Restore room from URL if present
const roomId = new URLSearchParams(window.location.search).get('room');
if (roomId) {
try {
const { room } = await api.getRoom(roomId);
if (room) {
setActiveRoom(room);
setView('room');
return;
}
} catch {
// room gone, fall through to lobby
window.history.replaceState({}, '', window.location.pathname);
}
}
setView('lobby');
} catch {
setView('login');
}
}
async function handleLogout() {
try {
await api.logout();
} catch {
// ignore
}
setUser(null);
setActiveRoom(null);
setView('login');
window.history.replaceState({}, '', window.location.pathname);
}
if (view === 'loading') {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-slate-900">
<p className="text-slate-400 text-sm tracking-wide">Loading...</p>
</div>
);
}
function showLegal(page) {
setPrevView(view);
setView(`legal-${page}`);
}
if (view.startsWith('legal-')) {
const page = view.replace('legal-', '');
return <LegalPage page={page} dark={dark} onBack={() => setView(prevView)} />;
}
if (view === 'login') {
return <LoginScreen dark={dark} toggleDark={toggleDark} onShowLegal={showLegal} />;
}
if (view === 'room' && activeRoom) {
return (
<Room
room={activeRoom}
user={user}
dark={dark}
toggleDark={toggleDark}
onBack={() => {
setActiveRoom(null);
setView('lobby');
window.history.replaceState({}, '', window.location.pathname);
}}
/>
);
}
return (
<Lobby
user={user}
dark={dark}
toggleDark={toggleDark}
onEnterRoom={(room) => {
setActiveRoom(room);
setView('room');
window.history.replaceState({}, '', `?room=${room.id}`);
}}
onLogout={handleLogout}
onShowLegal={showLegal}
/>
);
}

View file

@ -0,0 +1,194 @@
import React, { useEffect, useRef } from 'react';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import java from 'highlight.js/lib/languages/java';
import sql from 'highlight.js/lib/languages/sql';
import json from 'highlight.js/lib/languages/json';
import xml from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import bash from 'highlight.js/lib/languages/bash';
import csharp from 'highlight.js/lib/languages/csharp';
import yaml from 'highlight.js/lib/languages/yaml';
import 'highlight.js/styles/github-dark.min.css';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('py', python);
hljs.registerLanguage('java', java);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('json', json);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('css', css);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('shell', bash);
hljs.registerLanguage('csharp', csharp);
hljs.registerLanguage('c#', csharp);
hljs.registerLanguage('yaml', yaml);
function CodeBlock({ code, language }) {
const elRef = useRef(null);
useEffect(() => {
if (!elRef.current) return;
const codeEl = elRef.current.querySelector('code');
if (!codeEl) return;
// Reset for re-highlight
codeEl.removeAttribute('data-highlighted');
codeEl.className = language && hljs.getLanguage(language) ? `language-${language}` : '';
codeEl.textContent = code;
try {
hljs.highlightElement(codeEl);
} catch {
// fallback: no highlighting
}
}, [code, language]);
return (
<pre ref={elRef} className="bg-slate-900 dark:bg-slate-950 text-slate-100 rounded p-3 my-2 overflow-x-auto text-xs font-mono">
<code>{code}</code>
</pre>
);
}
function parseWikiInline(text) {
const parts = [];
const regex = /\*([^*\n]+)\*|_([^_\n]+)_|-([^-\n]+)-|\+([^+\n]+)\+|\{\{([^}]*?)\}\}|\[([^|\]\n]+)\|([^\]\n]+?)(?:\|[^\]]*?)?\]|\[(https?:\/\/[^\]\s]+)\]/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
if (match[1] != null) {
parts.push(<strong key={match.index}>{match[1]}</strong>);
} else if (match[2] != null) {
parts.push(<em key={match.index}>{match[2]}</em>);
} else if (match[3] != null) {
parts.push(<del key={match.index}>{match[3]}</del>);
} else if (match[4] != null) {
parts.push(<u key={match.index}>{match[4]}</u>);
} else if (match[5] != null) {
parts.push(<code key={match.index} className="bg-slate-100 dark:bg-slate-700 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded text-sm font-mono">{match[5]}</code>);
} else if (match[6] != null && match[7] != null) {
parts.push(<a key={match.index} href={match[7]} target="_blank" rel="noopener noreferrer" className="text-emerald-600 dark:text-emerald-400 hover:underline">{match[6]}</a>);
} else if (match[8] != null) {
parts.push(<a key={match.index} href={match[8]} target="_blank" rel="noopener noreferrer" className="text-emerald-600 dark:text-emerald-400 hover:underline">{match[8]}</a>);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length === 0 ? [text] : parts;
}
function renderWikiMarkup(text) {
const elements = [];
// Extract code blocks first
const segments = [];
const codeRegex = /\{(code|noformat)(?::([^}]*))?\}([\s\S]*?)\{\1\}/g;
let lastIdx = 0;
let m;
while ((m = codeRegex.exec(text)) !== null) {
if (m.index > lastIdx) segments.push({ type: 'text', value: text.slice(lastIdx, m.index) });
segments.push({ type: 'code', value: m[3], language: m[2] || '' });
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) segments.push({ type: 'text', value: text.slice(lastIdx) });
for (const seg of segments) {
if (seg.type === 'code') {
elements.push(<CodeBlock key={elements.length} code={seg.value.trim()} language={seg.language} />);
continue;
}
const lines = seg.value.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
const headingMatch = line.match(/^h([1-6])\.\s+(.*)/);
if (headingMatch) {
const level = headingMatch[1];
const Tag = `h${level}`;
const sizes = { 1: 'text-xl font-bold mb-3 mt-4', 2: 'text-lg font-bold mb-2 mt-3', 3: 'text-base font-semibold mb-2 mt-3', 4: 'text-sm font-semibold mb-1 mt-2', 5: 'text-xs font-semibold mb-1 mt-2', 6: 'text-xs font-semibold mb-1 mt-1' };
elements.push(<Tag key={elements.length} className={sizes[level]}>{parseWikiInline(headingMatch[2])}</Tag>);
i++; continue;
}
if (line.match(/^----?\s*$/)) {
elements.push(<hr key={elements.length} className="my-4 border-slate-200 dark:border-slate-700" />);
i++; continue;
}
const bqMatch = line.match(/^bq\.\s+(.*)/);
if (bqMatch) {
elements.push(
<blockquote key={elements.length} className="border-l-2 border-slate-300 dark:border-slate-600 pl-3 italic text-slate-500 dark:text-slate-400 my-2">
<p>{parseWikiInline(bqMatch[1])}</p>
</blockquote>
);
i++; continue;
}
if (line.match(/^\*+\s+/)) {
const items = [];
while (i < lines.length && lines[i].match(/^\*+\s+/)) {
const lm = lines[i].match(/^(\*+)\s+(.*)/);
items.push({ depth: lm[1].length, text: lm[2] });
i++;
}
elements.push(
<ul key={elements.length} className="list-disc pl-5 mb-2 space-y-0.5">
{items.map((item, j) => <li key={j} className="leading-relaxed" style={{ marginLeft: `${(item.depth - 1) * 16}px` }}>{parseWikiInline(item.text)}</li>)}
</ul>
);
continue;
}
if (line.match(/^#+\s+/)) {
const items = [];
while (i < lines.length && lines[i].match(/^#+\s+/)) {
const lm = lines[i].match(/^(#+)\s+(.*)/);
items.push({ depth: lm[1].length, text: lm[2] });
i++;
}
elements.push(
<ol key={elements.length} className="list-decimal pl-5 mb-2 space-y-0.5">
{items.map((item, j) => <li key={j} className="leading-relaxed" style={{ marginLeft: `${(item.depth - 1) * 16}px` }}>{parseWikiInline(item.text)}</li>)}
</ol>
);
continue;
}
if (line.trim() === '') { i++; continue; }
elements.push(<p key={elements.length} className="mb-2 leading-relaxed">{parseWikiInline(line)}</p>);
i++;
}
}
return elements;
}
export default function AdfRenderer({ document, className = '', fallback = '' }) {
if (!document) {
return fallback ? <p className="text-slate-400 italic text-sm">{fallback}</p> : null;
}
if (typeof document === 'string') {
return <div className={`text-sm ${className}`.trim()}>{renderWikiMarkup(document)}</div>;
}
return fallback ? <p className="text-slate-400 italic text-sm">{fallback}</p> : null;
}

View file

@ -0,0 +1,19 @@
export default function DarkModeToggle({ dark, toggleDark, className = '' }) {
return (
<button
onClick={toggleDark}
className={`w-8 h-8 flex items-center justify-center transition-colors cursor-pointer border-none bg-transparent ${className}`}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 text-slate-400 hover:text-slate-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
);
}

View file

@ -0,0 +1,239 @@
export default function LegalPage({ page, dark, onBack }) {
return (
<div className="min-h-screen flex flex-col" style={{ background: dark ? '#09090b' : '#f0f1f5' }}>
<header className="flex items-center gap-3 px-5 py-3 shrink-0">
<button
onClick={onBack}
className="text-sm font-medium px-3 py-1 border-none cursor-pointer transition-colors"
style={{
color: dark ? '#94a3b8' : '#64748b',
background: dark ? '#1e293b' : '#e2e8f0'
}}
>
&larr; Back
</button>
<span className="font-syne font-bold text-sm tracking-tight" style={{ color: dark ? '#fff' : '#0f172a' }}>
POKERFACE
</span>
</header>
<main className="flex-1 px-5 py-6 max-w-2xl mx-auto w-full">
{page === 'terms' && <TermsOfService dark={dark} />}
{page === 'privacy' && <PrivacyPolicy dark={dark} />}
{page === 'support' && <Support dark={dark} />}
</main>
</div>
);
}
function Heading({ children, dark }) {
return <h1 className="text-2xl font-bold mb-4 mt-0" style={{ color: dark ? '#fff' : '#0f172a' }}>{children}</h1>;
}
function SubHeading({ children, dark }) {
return <h2 className="text-base font-semibold mt-6 mb-2" style={{ color: dark ? '#e2e8f0' : '#1e293b' }}>{children}</h2>;
}
function P({ children, dark }) {
return <p className="text-sm leading-relaxed my-2" style={{ color: dark ? '#94a3b8' : '#475569' }}>{children}</p>;
}
function Li({ children, dark }) {
return <li className="text-sm leading-relaxed my-1" style={{ color: dark ? '#94a3b8' : '#475569' }}>{children}</li>;
}
function TermsOfService({ dark }) {
return (
<>
<Heading dark={dark}>Terms of Service</Heading>
<P dark={dark}><em>Last updated: February 2026</em></P>
<SubHeading dark={dark}>1. Acceptance</SubHeading>
<P dark={dark}>
By accessing or using Pokerface ("the Service"), you agree to these terms.
If you do not agree, do not use the Service.
</P>
<SubHeading dark={dark}>2. Description</SubHeading>
<P dark={dark}>
Pokerface is a free sprint planning poker tool that integrates with Atlassian Jira.
It is provided as a convenience for agile teams to facilitate estimation sessions.
</P>
<SubHeading dark={dark}>3. No Warranty</SubHeading>
<P dark={dark}>
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND,
WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
NON-INFRINGEMENT. NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED FROM
THE SERVICE SHALL CREATE ANY WARRANTY NOT EXPRESSLY STATED HEREIN.
</P>
<SubHeading dark={dark}>4. Limitation of Liability</SubHeading>
<P dark={dark}>
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE DEVELOPERS,
OPERATORS, OR CONTRIBUTORS OF POKERFACE BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, DATA, USE, OR
GOODWILL, HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, ARISING OUT OF OR IN
CONNECTION WITH YOUR ACCESS TO OR USE OF (OR INABILITY TO USE) THE SERVICE.
</P>
<P dark={dark}>
THE TOTAL AGGREGATE LIABILITY OF THE SERVICE OPERATORS FOR ALL CLAIMS RELATING TO
THE SERVICE SHALL NOT EXCEED ZERO EUROS (EUR 0.00).
</P>
<SubHeading dark={dark}>5. No Guarantee of Availability</SubHeading>
<P dark={dark}>
The Service may be modified, suspended, or discontinued at any time without notice.
We are under no obligation to maintain, support, or update the Service.
</P>
<SubHeading dark={dark}>6. User Responsibilities</SubHeading>
<P dark={dark}>
You are responsible for your use of the Service and any data you transmit through it.
You must comply with Atlassian's terms of service when using Jira integration features.
</P>
<SubHeading dark={dark}>7. Third-Party Services</SubHeading>
<P dark={dark}>
Pokerface integrates with Atlassian Jira via OAuth. Your use of Jira is governed by
Atlassian's own terms and privacy policy. We are not responsible for any third-party
service availability or behavior.
</P>
<SubHeading dark={dark}>8. Changes to Terms</SubHeading>
<P dark={dark}>
These terms may be updated at any time. Continued use of the Service after changes
constitutes acceptance of the revised terms.
</P>
</>
);
}
function PrivacyPolicy({ dark }) {
return (
<>
<Heading dark={dark}>Privacy Policy</Heading>
<P dark={dark}><em>Last updated: February 2026</em></P>
<SubHeading dark={dark}>1. What Data We Collect</SubHeading>
<P dark={dark}>
When you sign in with Jira, we receive the following information from Atlassian via OAuth:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}><strong>Jira account ID</strong> your unique Atlassian identifier</Li>
<Li dark={dark}><strong>Display name</strong> your Jira profile name</Li>
<Li dark={dark}><strong>Avatar URL</strong> a link to your Jira profile picture</Li>
<Li dark={dark}><strong>Email address</strong> your Jira account email</Li>
<Li dark={dark}><strong>Cloud ID and site URL</strong> identifies your Jira workspace</Li>
<Li dark={dark}><strong>OAuth tokens</strong> access and refresh tokens for Jira API calls</Li>
</ul>
<P dark={dark}>
During poker sessions, we temporarily store:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}>Room and session metadata (project name, sprint name, issue keys)</Li>
<Li dark={dark}>Participant names and avatar URLs</Li>
<Li dark={dark}>Votes submitted during estimation sessions</Li>
</ul>
<SubHeading dark={dark}>2. How We Use Your Data</SubHeading>
<P dark={dark}>
Your data is used solely to operate the poker planning functionality:
authenticating you with Jira, displaying participants in sessions, recording votes,
and writing agreed estimates back to Jira issues. We do not use your data for
analytics, advertising, profiling, or any other purpose.
</P>
<SubHeading dark={dark}>3. Data Storage and Retention</SubHeading>
<P dark={dark}>
All data is stored in NATS JetStream key-value buckets with automatic time-to-live (TTL) expiration:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}><strong>OAuth connections</strong> automatically deleted after 24 hours</Li>
<Li dark={dark}><strong>Rooms and sessions</strong> automatically deleted after 24 hours</Li>
<Li dark={dark}><strong>OAuth state tokens</strong> automatically deleted after 10 minutes</Li>
</ul>
<P dark={dark}>
There is no long-term database. All session data is ephemeral and automatically purged
by TTL. When a poker session is saved, the session data is deleted immediately.
</P>
<SubHeading dark={dark}>4. Cookies</SubHeading>
<P dark={dark}>
Pokerface uses a single, strictly functional cookie:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}>
<strong>pokerface_session</strong> an HttpOnly, Secure, SameSite=Lax JWT cookie
that contains your Jira account ID, cloud ID, display name, and avatar URL. It
expires after 24 hours. This cookie is required for the application to function.
</Li>
</ul>
<P dark={dark}>
We do not use tracking cookies, analytics cookies, or any third-party cookies.
A dark mode preference is stored in your browser's localStorage (not a cookie)
and never sent to our servers.
</P>
<SubHeading dark={dark}>5. Data Sharing</SubHeading>
<P dark={dark}>
We do not sell, share, or transfer your personal data to any third party.
The only external communication is between our backend and Atlassian's Jira API,
using the OAuth tokens you authorized, to read project/sprint data and write
estimates back to issues.
</P>
<SubHeading dark={dark}>6. Data Security</SubHeading>
<P dark={dark}>
All traffic is encrypted via HTTPS in production. Session cookies are marked HttpOnly
and Secure. Security headers (HSTS, X-Frame-Options DENY, nosniff) are applied to all
responses. OAuth tokens are stored server-side only and never exposed to the browser.
</P>
<SubHeading dark={dark}>7. Your Rights</SubHeading>
<P dark={dark}>
Since all data expires automatically within 24 hours, there is no persistent personal
data to request deletion of. You can sign out at any time to clear your session cookie.
Revoking the Pokerface OAuth connection in your Atlassian account settings will
invalidate all stored tokens.
</P>
</>
);
}
function Support({ dark }) {
return (
<>
<Heading dark={dark}>Support</Heading>
<P dark={dark}><em>Last updated: February 2026</em></P>
<SubHeading dark={dark}>About Pokerface</SubHeading>
<P dark={dark}>
Pokerface is a free, open tool for sprint planning poker with Jira integration.
It is provided as-is, with no guarantees of availability, support, or maintenance.
</P>
<SubHeading dark={dark}>No Formal Support</SubHeading>
<P dark={dark}>
This product does not come with dedicated support, SLAs, or guaranteed response times.
There is no helpdesk, ticketing system, or support team.
</P>
<SubHeading dark={dark}>Best-Effort Assistance</SubHeading>
<P dark={dark}>
If you encounter a bug or issue, you may reach out via the project's repository.
Any assistance is provided on a best-effort basis at the maintainer's discretion.
</P>
<SubHeading dark={dark}>Alternatives</SubHeading>
<P dark={dark}>
If Pokerface does not meet your needs, there are many alternative planning poker tools
available in the Atlassian Marketplace and elsewhere. You are free to stop using
Pokerface at any time simply sign out and revoke the OAuth connection in your
Atlassian account settings.
</P>
</>
);
}

View file

@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import { api } from '../services/api';
import SearchSelect from './SearchSelect';
import DarkModeToggle from './DarkModeToggle';
export default function Lobby({ user, dark, toggleDark, onEnterRoom, onLogout, onShowLegal }) {
const [rooms, setRooms] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const [projects, setProjects] = useState([]);
const [boards, setBoards] = useState([]);
const [sprints, setSprints] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [selectedBoard, setSelectedBoard] = useState(null);
const [selectedSprint, setSelectedSprint] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [deleteTarget, setDeleteTarget] = useState(null);
useEffect(() => {
loadRooms();
}, []);
async function loadRooms() {
try {
const { rooms: activeRooms } = await api.getRooms();
setRooms(activeRooms || []);
} catch (_err) {
// ignore
}
}
async function handlePlanSession() {
setShowCreate(true);
setError('');
try {
const { projects: projectList } = await api.getProjects();
setProjects(projectList || []);
} catch (err) {
setError(err.message);
}
}
async function handleProjectChange(projectKey) {
const project = projects.find((p) => p.key === projectKey);
setSelectedProject(project || null);
setSelectedBoard(null);
setSelectedSprint(null);
setBoards([]);
setSprints([]);
if (!projectKey) return;
try {
const { boards: boardList } = await api.getBoards(projectKey);
const list = boardList || [];
setBoards(list);
if (list.length === 1) {
const board = list[0];
setSelectedBoard(board);
try {
const { sprints: sprintList } = await api.getBoardSprints(board.id);
setSprints(sprintList || []);
} catch (err) {
setError(err.message);
}
}
} catch (err) {
setError(err.message);
}
}
async function handleBoardChange(boardId) {
const board = boards.find((b) => b.id === Number(boardId));
setSelectedBoard(board || null);
setSelectedSprint(null);
setSprints([]);
if (!boardId) return;
try {
const { sprints: sprintList } = await api.getBoardSprints(boardId);
setSprints(sprintList || []);
} catch (err) {
setError(err.message);
}
}
async function handleCreateRoom() {
if (!selectedProject || !selectedBoard || !selectedSprint) return;
setLoading(true);
setError('');
try {
const { room } = await api.createRoom({
boardId: selectedBoard.id,
projectKey: selectedProject.key,
projectName: selectedProject.name,
sprintId: selectedSprint.id,
sprintName: selectedSprint.name
});
onEnterRoom(room);
} catch (err) {
setError(err.message);
setLoading(false);
}
}
const projectOptions = projects.map((p) => ({ value: p.key, label: `${p.name} (${p.key})` }));
const boardOptions = boards.map((b) => ({ value: b.id, label: b.name }));
const sprintOptions = sprints.map((s) => ({ value: s.id, label: `${s.name} (${s.state})` }));
return (
<main className="min-h-screen flex flex-col dark:bg-slate-900">
{/* Header */}
<header className="flex justify-between items-center bg-slate-900 dark:bg-slate-950 text-white px-5 py-2 shrink-0">
<div className="flex items-center gap-3">
<span className="font-syne font-bold text-sm tracking-tight">POKERFACE</span>
<span className="text-slate-600 text-xs hidden sm:inline">/</span>
<span className="text-slate-400 text-xs hidden sm:inline">Lobby</span>
</div>
<div className="flex items-center gap-3">
<DarkModeToggle dark={dark} toggleDark={toggleDark} />
{user.avatarUrl && (
<img src={user.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
)}
<span className="text-sm text-slate-300">{user.displayName}</span>
<button
onClick={onLogout}
className="bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium px-3 py-1 transition-colors cursor-pointer border-none"
>
Sign out
</button>
</div>
</header>
{/* Content */}
<section className="flex-1 px-5 py-5 flex flex-col gap-5">
<div className="flex justify-between items-center">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 m-0">Active Rooms</h2>
<button
onClick={handlePlanSession}
className="border border-emerald-500/50 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500 hover:text-white font-medium text-sm px-4 py-1.5 transition-all cursor-pointer bg-transparent"
>
Plan Session
</button>
</div>
{error && <p className="text-red-600 dark:text-red-400 text-sm m-0">{error}</p>}
{showCreate && (
<div className="animate-fade-in bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 p-4 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 items-end relative z-10">
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
<span>Project</span>
<SearchSelect
options={projectOptions}
value={selectedProject?.key || ''}
onChange={(val) => handleProjectChange(val)}
placeholder="Select a project..."
/>
</div>
{boards.length > 1 && (
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
<span>Board</span>
<SearchSelect
options={boardOptions}
value={selectedBoard?.id || ''}
onChange={(val) => handleBoardChange(val)}
placeholder="Select a board..."
/>
</div>
)}
{sprints.length > 0 && (
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
<span>Sprint</span>
<SearchSelect
options={sprintOptions}
value={selectedSprint?.id || ''}
onChange={(val) => {
const s = sprints.find((sp) => sp.id === Number(val));
setSelectedSprint(s || null);
}}
placeholder="Select a sprint..."
/>
</div>
)}
<button
onClick={handleCreateRoom}
disabled={!selectedProject || !selectedBoard || !selectedSprint || loading}
className="bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold px-4 py-2 transition-colors cursor-pointer border-none text-sm"
>
{loading ? 'Creating...' : 'Create Room'}
</button>
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
{rooms.length === 0 ? (
<p className="text-slate-400 text-sm">No active rooms. Create one to start estimating.</p>
) : (
rooms.map((room, i) => (
<div
key={room.id}
onClick={() => onEnterRoom(room)}
style={{ animationDelay: `${i * 60}ms` }}
className="animate-fade-up border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 cursor-pointer flex flex-col gap-1.5 transition-all hover:border-emerald-400 hover:shadow-lg hover:shadow-emerald-500/5 hover:-translate-y-0.5 group relative"
>
{room.createdByAccountId === user.jiraAccountId && (
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(room);
}}
className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center text-slate-300 dark:text-slate-600 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors bg-transparent border-none cursor-pointer text-base leading-none opacity-0 group-hover:opacity-100"
title="Delete room"
>
&times;
</button>
)}
<strong className="text-sm font-semibold text-slate-900 dark:text-slate-100 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
{room.projectName || room.projectKey}
</strong>
<span className="text-slate-500 dark:text-slate-400 text-xs">{room.sprintName}</span>
<span className="text-slate-400 dark:text-slate-500 text-xs">Created by {room.createdByName}</span>
</div>
))
)}
</div>
</section>
<footer className="flex justify-center gap-3 px-5 py-3 text-xs" style={{ color: dark ? '#475569' : '#94a3b8' }}>
<button onClick={() => onShowLegal('terms')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Terms</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('privacy')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Privacy</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('support')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Support</button>
</footer>
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setDeleteTarget(null)}>
<div onClick={(e) => e.stopPropagation()} className="bg-white dark:bg-slate-800 w-72 p-5 flex flex-col gap-4 shadow-xl animate-fade-in">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100 m-0">Delete Room</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 m-0">
Remove <strong>{deleteTarget.projectName || deleteTarget.projectKey}</strong> {deleteTarget.sprintName}?
</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setDeleteTarget(null)}
className="px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 border-none cursor-pointer transition-colors"
>
Cancel
</button>
<button
onClick={() => {
api.deleteRoom(deleteTarget.id).then(loadRooms).catch(() => {});
setDeleteTarget(null);
}}
className="px-3 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 border-none cursor-pointer transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</main>
);
}

View file

@ -0,0 +1,72 @@
import { useState } from 'react';
import { api } from '../services/api';
import DarkModeToggle from './DarkModeToggle';
export default function LoginScreen({ dark, toggleDark, onShowLegal }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleLogin() {
setLoading(true);
setError('');
try {
const { url } = await api.startJiraOAuth();
window.location.href = url;
} catch (err) {
setError(err.message);
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden bg-[#09090b] dark:bg-[#09090b] light:bg-white transition-colors"
style={{ background: dark ? '#09090b' : '#f0f1f5' }}
>
<DarkModeToggle dark={dark} toggleDark={toggleDark} className="absolute top-4 right-4" />
{/* Ambient glow */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: dark
? 'radial-gradient(ellipse at 50% 40%, rgba(16,185,129,0.07) 0%, transparent 55%)'
: 'radial-gradient(ellipse at 50% 40%, rgba(16,185,129,0.1) 0%, transparent 55%)'
}}
/>
<div className="flex flex-col items-center gap-8 max-w-xs w-full text-center relative z-10">
<div className="animate-fade-up flex flex-col items-center gap-4">
<h1 className="font-syne text-5xl font-extrabold tracking-tight m-0"
style={{ color: dark ? '#fff' : '#0f172a' }}
>
POKERFACE
</h1>
<div className="w-10 h-px bg-emerald-500" />
<p className="text-sm tracking-wide m-0" style={{ color: dark ? '#94a3b8' : '#64748b' }}>
Sprint planning poker for Jira teams
</p>
</div>
<div className="animate-fade-up w-full flex flex-col gap-3" style={{ animationDelay: '120ms' }}>
<button
onClick={handleLogin}
disabled={loading}
className="w-full bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed font-semibold px-6 py-3 transition-colors cursor-pointer border-none tracking-wide text-sm"
style={{ color: dark ? '#09090b' : '#fff' }}
>
{loading ? 'Redirecting...' : 'Sign in with Jira'}
</button>
{error && <p className="text-red-400 text-sm m-0 animate-fade-in">{error}</p>}
</div>
<div className="animate-fade-up flex gap-3 text-xs" style={{ animationDelay: '240ms', color: dark ? '#64748b' : '#94a3b8' }}>
<button onClick={() => onShowLegal('terms')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Terms</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('privacy')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Privacy</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('support')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Support</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,255 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { getSocket } from '../services/socket';
const CARDS = [
{ value: '?', label: '?', color: '#94a3b8' },
{ value: '☕', label: '☕', color: '#f59e0b' },
{ value: '0', label: '0', color: '#64748b' },
{ value: '0.5', label: '0.5', color: '#64748b' },
{ value: '1', label: '1', color: '#3b82f6' },
{ value: '2', label: '2', color: '#3b82f6' },
{ value: '3', label: '3', color: '#8b5cf6' },
{ value: '5', label: '5', color: '#8b5cf6' },
{ value: '8', label: '8', color: '#ec4899' },
{ value: '13', label: '13', color: '#ec4899' },
{ value: '20', label: '20', color: '#ef4444' },
{ value: '40', label: '40', color: '#ef4444' },
{ value: '100', label: '100', color: '#dc2626' }
];
export default function PokerRoom({ session, issue, user, onSaved }) {
const [participants, setParticipants] = useState([]);
const [votedUserKeys, setVotedUserKeys] = useState([]);
const [revealed, setRevealed] = useState(false);
const [votes, setVotes] = useState({});
const [myVote, setMyVote] = useState(null);
const [suggestedEstimate, setSuggestedEstimate] = useState(null);
const [average, setAverage] = useState(null);
const [manualEstimate, setManualEstimate] = useState('');
const [error, setError] = useState('');
const socket = useMemo(() => getSocket(), []);
const joinedRef = useRef(null);
useEffect(() => {
function onParticipants(payload) {
if (payload.sessionId !== session.id) return;
setParticipants(payload.participants || []);
setVotedUserKeys(payload.votedUserKeys || []);
}
function onVoteUpdate(payload) {
if (payload.sessionId !== session.id) return;
setVotedUserKeys(payload.votedUserKeys || []);
}
function onRevealed(payload) {
if (payload.sessionId !== session.id) return;
setRevealed(true);
setVotes(payload.votes || {});
setAverage(payload.average ?? null);
setSuggestedEstimate(payload.suggestedEstimate ?? null);
if (payload.suggestedEstimate !== undefined && payload.suggestedEstimate !== null) {
setManualEstimate(String(payload.suggestedEstimate));
}
}
function onSavedPayload(payload) {
if (payload.sessionId !== session.id) return;
onSaved(payload.estimate);
}
function onError(payload) {
setError(payload?.error || 'Unexpected poker error');
}
socket.on('poker:participants', onParticipants);
socket.on('poker:vote-update', onVoteUpdate);
socket.on('poker:revealed', onRevealed);
socket.on('poker:saved', onSavedPayload);
socket.on('poker:error', onError);
// Guard against React strict mode double-invoking effects
if (joinedRef.current !== session.id) {
joinedRef.current = session.id;
socket.emit('poker:join', {
sessionId: session.id,
userKey: user.key,
userName: user.name
});
}
return () => {
socket.off('poker:participants', onParticipants);
socket.off('poker:vote-update', onVoteUpdate);
socket.off('poker:revealed', onRevealed);
socket.off('poker:saved', onSavedPayload);
socket.off('poker:error', onError);
};
}, [session.id, socket, user.key, user.name]);
function handleVote(value) {
if (revealed) return;
setMyVote(value);
socket.emit('poker:vote', {
sessionId: session.id,
userKey: user.key,
vote: value
});
}
function handleSave() {
const estimate = Number(manualEstimate || suggestedEstimate);
if (!Number.isFinite(estimate)) {
setError('Pick a numeric estimate before saving.');
return;
}
socket.emit('poker:save', {
sessionId: session.id,
estimate
});
}
return (
<div className="flex flex-col h-full">
{/* Cards or Results */}
<div className="border-b border-slate-200 dark:border-slate-700">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider m-0">
{revealed ? 'Results' : 'Choose Your Estimate'}
</h2>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800/50">
{revealed ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-6 bg-purple-50 dark:bg-purple-900/20 rounded-sm">
<div className="text-sm text-slate-500 dark:text-slate-400 mb-2">Average</div>
<div className="text-4xl font-bold text-purple-600 dark:text-purple-400">
{average?.toFixed(1) ?? '-'}
</div>
</div>
<div className="text-center p-6 bg-green-50 dark:bg-green-900/20 rounded-sm">
<div className="text-sm text-slate-500 dark:text-slate-400 mb-2">Suggested</div>
<div className="text-4xl font-bold text-green-600 dark:text-green-400">
{suggestedEstimate ?? '-'}
</div>
</div>
</div>
{/* Individual votes */}
<div className="flex flex-wrap gap-2">
{Object.entries(votes).map(([userKey, value]) => {
const name = participants.find((p) => p.userKey === userKey)?.userName || userKey;
return (
<span
key={userKey}
className="text-xs bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm px-3 py-1 text-slate-600 dark:text-slate-300"
>
{name}: <strong>{value}</strong>
</span>
);
})}
</div>
{/* Override + Save */}
<div className="flex gap-3">
<input
type="number"
step="0.5"
value={manualEstimate}
onChange={(e) => setManualEstimate(e.target.value)}
placeholder="Override"
className="flex-1 border border-slate-300 dark:border-slate-600 rounded-sm px-3 py-2 text-sm bg-white dark:bg-slate-800 dark:text-slate-100"
/>
<button
onClick={handleSave}
className="bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-sm px-6 py-2 transition-colors cursor-pointer border-none"
>
Save Estimate
</button>
</div>
</div>
) : (
<div className="grid grid-cols-7 gap-2">
{CARDS.map((card) => {
const isSelected = myVote === card.value;
return (
<button
key={card.value}
onClick={() => handleVote(card.value)}
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent"
style={{
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
}}
>
<span
className="text-base font-bold"
style={{ color: isSelected ? card.color : 'var(--card-text, #475569)' }}
>
{card.label}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
{/* Participants */}
<div className="flex-1 flex flex-col overflow-y-auto">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider m-0">
Participants
</h2>
</div>
<div className="flex-1 p-4 bg-slate-50 dark:bg-slate-800/50">
<div className="flex flex-col gap-2">
{participants.map((participant) => {
const hasVoted = revealed
? votes[participant.userKey] !== undefined
: votedUserKeys.includes(participant.userKey);
const vote = revealed ? votes[participant.userKey] : null;
const isCurrentUser = participant.userKey === user.key;
return (
<div
key={participant.userKey}
className="flex items-center justify-between p-2 border border-slate-200 dark:border-slate-700 rounded-sm bg-white dark:bg-slate-800"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="w-7 h-7 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold shrink-0">
{participant.userName?.charAt(0)?.toUpperCase() || '?'}
</div>
<span className="text-sm text-slate-900 dark:text-slate-100 truncate">
{participant.userName}
{isCurrentUser && <span className="text-slate-400"> (you)</span>}
</span>
</div>
<div className="text-sm shrink-0 ml-2">
{revealed && vote !== null && vote !== undefined ? (
<span className="font-semibold text-purple-600 dark:text-purple-400">{vote}</span>
) : hasVoted ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-slate-400">...</span>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{error && (
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-t border-red-200 dark:border-red-800">
<p className="text-red-700 dark:text-red-400 text-sm m-0">{error}</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,210 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../services/api';
import PokerRoom from './PokerRoom';
import DarkModeToggle from './DarkModeToggle';
import AdfRenderer from './AdfRenderer';
export default function Room({ room, user, dark, toggleDark, onBack }) {
const [issues, setIssues] = useState([]);
const [activeSession, setActiveSession] = useState(null);
const [done, setDone] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const issuesRef = useRef([]);
const pokerUser = { key: user.jiraAccountId, name: user.displayName };
useEffect(() => {
loadIssues();
}, [room.sprintId]);
async function loadIssues() {
setLoading(true);
try {
const { issues: sprintIssues } = await api.getSprintIssues(room.sprintId, room.boardId);
const list = sprintIssues || [];
setIssues(list);
issuesRef.current = list;
startFirstUnestimated(list);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
function startFirstUnestimated(issueList) {
const issue = issueList.find((i) => !i.estimate || i.estimate === 0);
if (!issue) {
finishSession();
return;
}
startSessionForIssue(issue);
}
async function startSessionForIssue(issue) {
setError('');
try {
const { session } = await api.startSession({
issueKey: issue.key,
issueId: issue.id,
issueTitle: issue.title,
roomId: room.id,
boardId: room.boardId
});
setActiveSession({ session, issue });
} catch (err) {
setError(err.message);
}
}
async function finishSession() {
setDone(true);
try {
await api.deleteRoom(room.id);
} catch {
// best-effort cleanup
}
}
const activeSessionRef = useRef(null);
activeSessionRef.current = activeSession;
const advanceToNext = useCallback((estimate) => {
if (!activeSessionRef.current) return;
const currentKey = activeSessionRef.current.issue.key;
const updated = issuesRef.current.map((i) =>
i.key === currentKey ? { ...i, estimate } : i
);
issuesRef.current = updated;
setIssues(updated);
setActiveSession(null);
const next = updated.find((i) => !i.estimate || i.estimate === 0);
if (!next) {
finishSession();
return;
}
setTimeout(() => startSessionForIssue(next), 300);
}, []);
const estimatedCount = issues.filter((i) => i.estimate && i.estimate > 0).length;
const totalCount = issues.length;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-slate-900">
<p className="text-slate-400 text-sm">Loading sprint issues...</p>
</div>
);
}
if (done) {
return (
<div className="min-h-screen flex items-center justify-center p-4 dark:bg-slate-900">
<div className="max-w-xl w-full text-center flex flex-col gap-4 items-center animate-fade-up">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 m-0">Session Complete</h1>
<p className="text-slate-500 dark:text-slate-400 m-0">
{estimatedCount} of {totalCount} issues estimated
</p>
<ul className="w-full text-left list-none m-0 p-0 flex flex-col gap-2">
{issues.map((issue) => (
<li
key={issue.key}
className="border border-slate-200 dark:border-slate-700 p-3 flex justify-between gap-3 bg-white dark:bg-slate-800"
>
<div className="min-w-0">
<strong className="text-sm dark:text-slate-100">{issue.key}</strong>
<p className="text-sm text-slate-600 dark:text-slate-400 m-0 mt-0.5 truncate">{issue.title}</p>
</div>
<span className="font-bold text-purple-600 dark:text-purple-400 shrink-0">
{issue.estimate || 0} pts
</span>
</li>
))}
</ul>
<button
onClick={onBack}
className="bg-emerald-500 hover:bg-emerald-400 text-white font-semibold px-6 py-3 transition-colors cursor-pointer border-none text-sm tracking-wide"
>
Back to Lobby
</button>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col dark:bg-slate-900">
{/* Header */}
<header className="flex justify-between items-center bg-slate-900 dark:bg-slate-950 text-white px-5 py-2 shrink-0">
<div className="flex items-center gap-3">
<span className="font-syne font-bold text-xs text-slate-500 tracking-tight">POKERFACE</span>
<span className="text-slate-600">/</span>
<strong className="text-sm">{room.projectName || room.projectKey}</strong>
<span className="text-slate-600">&mdash;</span>
<span className="text-slate-400 text-sm">{room.sprintName}</span>
</div>
<div className="flex items-center gap-4">
<DarkModeToggle dark={dark} toggleDark={toggleDark} />
<span className="text-slate-400 text-sm">{estimatedCount}/{totalCount} estimated</span>
<button
onClick={onBack}
className="bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium px-3 py-1 transition-colors cursor-pointer border-none"
>
Leave Room
</button>
</div>
</header>
{error && (
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
<p className="text-red-700 dark:text-red-400 text-sm m-0">{error}</p>
</div>
)}
{/* Split layout */}
{activeSession && (
<div className="flex-1 flex min-h-0">
{/* Left: Poker cards + participants */}
<div className="flex-1 flex flex-col border-r border-slate-200 dark:border-slate-700 overflow-y-auto bg-white dark:bg-slate-900">
<PokerRoom
key={activeSession.session.id}
session={activeSession.session}
issue={activeSession.issue}
user={pokerUser}
onSaved={advanceToNext}
/>
</div>
{/* Right: Issue details */}
<div className="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-800/50">
<div className="bg-slate-100 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-4 py-3">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{activeSession.issue.key}
</div>
</div>
<div className="px-4 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100 leading-tight m-0">
{activeSession.issue.title}
</h3>
</div>
{activeSession.issue.description && (
<div className="p-4 text-slate-700 dark:text-slate-300">
<AdfRenderer document={activeSession.issue.description} />
</div>
)}
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700">
<div className="flex gap-4 text-xs text-slate-500 dark:text-slate-400">
<span>Reporter: {activeSession.issue.reporter || 'Unknown'}</span>
<span>Status: {activeSession.issue.status}</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
export default function SearchSelect({ options, value, onChange, placeholder }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [highlightIndex, setHighlightIndex] = useState(0);
const containerRef = useRef(null);
const inputRef = useRef(null);
const filtered = options.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
const selectedLabel = options.find((o) => String(o.value) === String(value))?.label || '';
useEffect(() => {
function handleClickOutside(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
setHighlightIndex(0);
}, [query]);
function handleOpen() {
setOpen(true);
setQuery('');
setTimeout(() => inputRef.current?.focus(), 0);
}
function selectOption(opt) {
onChange(opt.value);
setOpen(false);
setQuery('');
}
function handleKeyDown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[highlightIndex]) selectOption(filtered[highlightIndex]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-left cursor-pointer flex justify-between items-center px-3 py-2 text-sm transition-colors hover:border-slate-400 dark:hover:border-slate-500"
>
<span className={value ? 'text-slate-900 dark:text-slate-100' : 'text-slate-400'}>
{selectedLabel || placeholder || 'Select...'}
</span>
<svg className="w-4 h-4 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="square" strokeLinejoin="miter" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-xl shadow-black/8 max-h-60 flex flex-col">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="border-b border-slate-200 dark:border-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 outline-none bg-transparent"
/>
<div className="overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400">No matches</div>
) : (
filtered.map((opt, i) => (
<div
key={opt.value}
onClick={() => selectOption(opt)}
onMouseEnter={() => setHighlightIndex(i)}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
i === highlightIndex
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
} ${String(opt.value) === String(value) ? 'font-semibold' : ''}`}
>
{opt.label}
</div>
))
)}
</div>
</div>
)}
</div>
);
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,55 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('UNAUTHORIZED');
}
const text = await response.text();
throw new Error(text || `Request failed: ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
return null;
}
export const api = {
getMe: () => request('/jira/me'),
logout: () => request('/jira/logout', { method: 'POST' }),
startJiraOAuth: () => request('/jira/oauth/start'),
getProjects: () => request('/jira/projects'),
getBoards: (projectKeyOrId) => request(`/jira/boards?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}`),
getBoardSprints: (boardId) => request(`/jira/boards/${encodeURIComponent(boardId)}/sprints`),
getSprintIssues: (sprintId, boardId) => request(`/jira/sprints/${encodeURIComponent(sprintId)}/issues${boardId ? `?boardId=${encodeURIComponent(boardId)}` : ''}`),
createRoom: (payload) =>
request('/poker/rooms', {
method: 'POST',
body: JSON.stringify(payload)
}),
getRooms: () => request('/poker/rooms'),
getRoom: (roomId) => request(`/poker/rooms/${encodeURIComponent(roomId)}`),
deleteRoom: (roomId) =>
request(`/poker/rooms/${encodeURIComponent(roomId)}`, { method: 'DELETE' }),
startSession: (payload) =>
request('/poker/sessions', {
method: 'POST',
body: JSON.stringify(payload)
}),
getSession: (sessionId) => request(`/poker/sessions/${encodeURIComponent(sessionId)}`)
};
// When using same-origin (default), socketBaseUrl is undefined so io() connects to the current host
const explicit = import.meta.env.VITE_API_URL;
export const socketBaseUrl = explicit ? explicit.replace(/\/api$/, '') : undefined;

View file

@ -0,0 +1,25 @@
import { io } from 'socket.io-client';
import { socketBaseUrl } from './api';
let socket;
export function getSocket() {
if (!socket) {
const opts = {
autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 750
};
if (socketBaseUrl) {
opts.withCredentials = true;
socket = io(socketBaseUrl, opts);
} else {
socket = io(opts);
}
}
return socket;
}

58
frontend/src/styles.css Normal file
View file

@ -0,0 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Syne:wght@700;800&display=swap');
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-syne: 'Syne', sans-serif;
}
@layer base {
body {
font-family: 'Outfit', sans-serif;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #0f172a;
background: #f0f1f5;
background-image:
radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.045) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.03) 0%, transparent 50%);
transition: background-color 0.2s, color 0.2s;
}
:root {
--card-border: #e2e8f0;
--card-text: #475569;
}
.dark {
--card-border: #334155;
--card-text: #94a3b8;
}
.dark body {
color: #e2e8f0;
background: #0f172a;
background-image:
radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.04) 0%, transparent 50%);
}
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@utility animate-fade-up {
animation: fade-up 0.5s ease-out both;
}
@utility animate-fade-in {
animation: fade-in 0.35s ease-out both;
}

22
frontend/vite.config.js Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5174,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:4010',
changeOrigin: true
},
'/socket.io': {
target: 'http://localhost:4010',
changeOrigin: true,
ws: true
}
}
}
});

330
package-lock.json generated Normal file
View file

@ -0,0 +1,330 @@
{
"name": "pokerface",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pokerface",
"version": "0.1.0",
"hasInstallScript": true,
"devDependencies": {
"concurrently": "^9.1.2"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "pokerface",
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "npm install --prefix backend && npm install --prefix frontend",
"dev": "concurrently -n be,fe -c blue,green \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"build": "npm run build --prefix frontend",
"start": "node backend/src/index.js"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}