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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import healthRoutes from './routes/healthRoutes.js';
import assessmentRoutes from './routes/assessmentRoutes.js';
import complianceRoutes from './routes/complianceRoutes.js';
import questionnaireRoutes from './routes/questionnaireRoutes.js';
import questionnairePublicRoutes from './routes/questionnairePublicRoutes.js';
import organizationRoutes from './routes/organizationRoutes.js';
import billingRoutes from './routes/billingRoutes.js';
import { handleStripeWebhook } from './controllers/billingController.js';
Expand Down Expand Up @@ -239,9 +240,14 @@ app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/organizations', organizationRoutes);
app.use('/api/v1/billing', billingRoutes);

// Unguarded: public vendor questionnaire form (token-gated, not workspace auth).
// Mounted ahead of requireActivePlan so a visitor's own session cookie (e.g. the
// workspace owner previewing their own link from a paused-plan org) can't 402
// a route a vendor with no Retrieva account must be able to reach.
app.use('/api/v1/questionnaires', questionnairePublicRoutes);

// Paid routes — optionalAuth sets req.user when a token is present so the
// plan guard can check it; public sub-routes (e.g. questionnaire respond)
// have no token and pass through to the router's own authenticate.
// plan guard can check it.
// authenticate is idempotent so the router's router.use(authenticate) is a
// no-op when req.user is already set by optionalAuth.
// B2: setTenantContext (after auth) makes the active workspace available to the
Expand Down
8 changes: 8 additions & 0 deletions backend/controllers/ragController.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ export const askQuestionStream = catchAsync(async (req, res) => {
closed = true;
});

// Keep the SSE connection alive through nginx's proxy_read_timeout (default 60s).
// The embedding phase can take minutes on CPU-only ollama — without this, nginx
// kills the idle connection before the first event is ever flushed.
const heartbeat = setInterval(() => {
if (!closed && !res.writableEnded) res.write(': keepalive\n\n');
}, 20000);

try {
await executeRAG({
question,
Expand All @@ -104,6 +111,7 @@ export const askQuestionStream = catchAsync(async (req, res) => {
send('error', { message: 'Stream failed', code: 'STREAM_ERROR' });
}
} finally {
clearInterval(heartbeat);
if (!res.writableEnded) res.end();
}
});
34 changes: 34 additions & 0 deletions backend/routes/questionnairePublicRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Router } from 'express';
import { getPublicForm, submitResponse } from '../controllers/questionnaireController.js';
import { validateBody, validateParams } from '../middleware/validate.js';
import { submitQuestionnaireResponseSchema, tokenParamsSchema } from '../validators/schemas.js';

const router = Router();

// ---------------------------------------------------------------------------
// Public routes — no authentication, no plan gate (token-based access only).
// Mounted unguarded in app.js so a visitor's own session cookie (e.g. the
// workspace owner previewing their own link) can't trigger requireActivePlan
// for a route a vendor with no Retrieva account must be able to reach.
// ---------------------------------------------------------------------------

/**
* @route GET /api/v1/questionnaires/respond/:token
* @desc Load the public vendor questionnaire form
* @access Public (token-gated)
*/
router.get('/respond/:token', validateParams(tokenParamsSchema), getPublicForm);

/**
* @route POST /api/v1/questionnaires/respond/:token
* @desc Save partial or final vendor response
* @access Public (token-gated)
*/
router.post(
'/respond/:token',
validateParams(tokenParamsSchema),
validateBody(submitQuestionnaireResponseSchema),
submitResponse
);

export default router;
29 changes: 2 additions & 27 deletions backend/routes/questionnaireRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,23 @@ import {
getQuestionnaire,
deleteQuestionnaire,
sendQuestionnaire,
getPublicForm,
submitResponse,
} from '../controllers/questionnaireController.js';
import { authenticate } from '../middleware/auth.js';
import { requireWorkspaceAccess } from '../middleware/workspaceAuth.js';
import { validateBody, validateParams, validateQuery } from '../middleware/validate.js';
import {
createQuestionnaireSchema,
sendQuestionnaireSchema,
submitQuestionnaireResponseSchema,
idParamsSchema,
tokenParamsSchema,
listQuestionnairesQuerySchema,
} from '../validators/schemas.js';

const router = Router();

// ---------------------------------------------------------------------------
// Public routes — no authentication required (token-based access only)
// ---------------------------------------------------------------------------

/**
* @route GET /api/v1/questionnaires/respond/:token
* @desc Load the public vendor questionnaire form
* @access Public (token-gated)
*/
router.get('/respond/:token', validateParams(tokenParamsSchema), getPublicForm);

/**
* @route POST /api/v1/questionnaires/respond/:token
* @desc Save partial or final vendor response
* @access Public (token-gated)
*/
router.post(
'/respond/:token',
validateParams(tokenParamsSchema),
validateBody(submitQuestionnaireResponseSchema),
submitResponse
);

// ---------------------------------------------------------------------------
// Authenticated routes — require JWT + workspace membership
// Public /respond/:token routes live in questionnairePublicRoutes.js, mounted
// unguarded in app.js (ahead of requireActivePlan).
// ---------------------------------------------------------------------------

/**
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"lucide-react": "^0.563.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/features/questionnaires/api/questionnaires.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ export interface CreateQuestionnaireDto {
// ---------------------------------------------------------------------------

const publicApiBaseURL =
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_API_URL
typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_API_URL
? process.env.NEXT_PUBLIC_API_URL
: '') + '/api/v1';
: '/api/v1';

const publicClient = axios.create({
baseURL: publicApiBaseURL,
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/features/settings/components/mfa-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Loader2, ShieldCheck, ShieldOff, Copy } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
Expand Down Expand Up @@ -123,17 +124,18 @@ export function MfaSection() {

{!enabled && setup && (
<div className="space-y-4">
<div className="space-y-1">
<div className="space-y-3">
<p className="text-sm font-medium">{t('settings.mfa.step1')}</p>
<p className="text-xs text-muted-foreground">
{t('settings.mfa.step1Desc')}
</p>
<code className="block break-all rounded bg-muted px-2 py-1 text-xs">
<div className="flex justify-center rounded-lg border bg-white p-4">
<QRCodeSVG value={setup.otpauthUrl} size={180} />
</div>
<p className="text-xs text-muted-foreground">{t('settings.mfa.manualEntry')}</p>
<code className="block break-all rounded bg-muted px-2 py-1 text-xs select-all">
{setup.secret}
</code>
<code className="block break-all rounded bg-muted px-2 py-1 text-xs">
{setup.otpauthUrl}
</code>
</div>
<Separator />
<div className="space-y-2">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/shared/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,8 @@
"copyCodes": "Copy codes",
"setup": "Set up two-factor authentication",
"step1": "1. Add this account to your authenticator app",
"step1Desc": "Scan the otpauth URL as a QR code, or enter the secret manually:",
"step1Desc": "Open Microsoft Authenticator (or Google Authenticator) and scan the QR code below:",
"manualEntry": "Can't scan? Enter this secret manually in your app:",
"step2": "2. Enter the 6-digit code to confirm",
"codePlaceholder": "123456",
"enable": "Enable",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/shared/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,8 @@
"copyCodes": "Copier les codes",
"setup": "Configurer l'authentification à deux facteurs",
"step1": "1. Ajoutez ce compte à votre application d'authentification",
"step1Desc": "Scannez l'URL otpauth sous forme de QR code, ou saisissez le secret manuellement :",
"step1Desc": "Ouvrez Microsoft Authenticator (ou Google Authenticator) et scannez le QR code ci-dessous :",
"manualEntry": "Impossible de scanner ? Saisissez ce secret manuellement dans votre application :",
"step2": "2. Saisissez le code à 6 chiffres pour confirmer",
"codePlaceholder": "123456",
"enable": "Activer",
Expand Down
Loading
Loading