Skip to content
Draft
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
2 changes: 2 additions & 0 deletions backend/iam/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
LoginView,
PasswordResetView,
ResetPasswordConfirmView,
SendInvtationView,
SessionTokenView,
SetPasswordView,
)
Expand All @@ -25,6 +26,7 @@
ResetPasswordConfirmView.as_view(),
name="password-reset-confirm",
),
path("send-invitation/", SendInvtationView.as_view(), name="send-invitation"),
path("set-password/", SetPasswordView.as_view(), name="set-password"),
path("sso/", include("iam.sso.urls")),
path(
Expand Down
31 changes: 31 additions & 0 deletions backend/iam/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,37 @@ def post(self, request):
return Response({"token": session_token})


class SendInvtationView(views.APIView):
permission_classes = [permissions.AllowAny]

@method_decorator(ensure_csrf_cookie)
def post(self, request):
email = request.data["email"] # type: ignore
associated_user = User.objects.filter(email=email).first()
if associated_user is None:
return Response(
status=HTTP_500_INTERNAL_SERVER_ERROR,
data={"error": "No user associated with this email"},
)
if EMAIL_HOST or EMAIL_HOST_RESCUE:
if associated_user is not None and associated_user.is_local:
try:
associated_user.mailing(
email_template_name="registration/first_connection_email.html",
subject=_("CISO Assistant: Invitation"),
)
print("Sending invitation mail to", email)
except Exception as e:
print(e)
return Response(status=HTTP_202_ACCEPTED)
return Response(
data={
"error": "Email server not configured, please contact your administrator"
},
status=HTTP_500_INTERNAL_SERVER_ERROR,
)
Comment on lines +270 to +298
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Harden SendInvtationView: input validation, status codes, and info leakage

A few points to address here:

  • Input validation: request.data["email"] will raise if the field is missing or malformed. Prefer request.data.get("email") with a 400 response when it’s absent/invalid.
  • Status code for unknown user: returning HTTP_500_INTERNAL_SERVER_ERROR for “No user associated with this email” is misleading; it’s a client issue. Also, this message + distinct status leaks whether an email exists, unlike PasswordResetView, which avoids that. Consider always returning 202 for non‑local/non‑existing users to avoid email enumeration.
  • Logging: print(...) should be replaced by logger.info / logger.error for consistency with the rest of the file.
  • Naming: SendInvtationView has a typo; renaming it to SendInvitationView will avoid confusion (remember to update imports/URL mapping).

An example of a more robust implementation:

-class SendInvtationView(views.APIView):
+class SendInvitationView(views.APIView):
     permission_classes = [permissions.AllowAny]

     @method_decorator(ensure_csrf_cookie)
     def post(self, request):
-        email = request.data["email"]  # type: ignore
-        associated_user = User.objects.filter(email=email).first()
-        if associated_user is None:
-            return Response(
-                status=HTTP_500_INTERNAL_SERVER_ERROR,
-                data={"error": "No user associated with this email"},
-            )
-        if EMAIL_HOST or EMAIL_HOST_RESCUE:
-            if associated_user is not None and associated_user.is_local:
-                try:
-                    associated_user.mailing(
-                        email_template_name="registration/first_connection_email.html",
-                        subject=_("CISO Assistant: Invitation"),
-                    )
-                    print("Sending invitation mail to", email)
-                except Exception as e:
-                    print(e)
-            return Response(status=HTTP_202_ACCEPTED)
-        return Response(
-            data={
-                "error": "Email server not configured, please contact your administrator"
-            },
-            status=HTTP_500_INTERNAL_SERVER_ERROR,
-        )
+        email = request.data.get("email")  # type: ignore[assignment]
+        if not email:
+            return Response(
+                data={"error": "emailRequired"},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        associated_user = User.objects.filter(email=email).first()
+
+        # Do not leak whether the user exists / is local; match PasswordResetView semantics.
+        if not (associated_user and associated_user.is_local):
+            return Response(status=HTTP_202_ACCEPTED)
+
+        if not (EMAIL_HOST or EMAIL_HOST_RESCUE):
+            return Response(
+                data={
+                    "error": "Email server not configured, please contact your administrator"
+                },
+                status=HTTP_500_INTERNAL_SERVER_ERROR,
+            )
+
+        try:
+            associated_user.mailing(
+                email_template_name="registration/first_connection_email.html",
+                subject=_("CISO Assistant: Invitation"),
+            )
+            logger.info("invitation email sent", email=email, user_id=associated_user.id)
+        except Exception as e:
+            logger.error("invitation email failed", email=email, user_id=associated_user.id, error=e)
+            return Response(
+                data={"error": "An error occurred while sending the email"},
+                status=HTTP_500_INTERNAL_SERVER_ERROR,
+            )
+
+        return Response(status=HTTP_202_ACCEPTED)

Also consider tightening permission_classes (e.g. requiring authentication/role) if you don’t intend this endpoint to be publicly callable.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/iam/views.py around lines 270-298, the SendInvtationView has
input-validation, status-code, logging and naming issues: change
request.data["email"] to request.data.get("email") and return
HTTP_400_BAD_REQUEST when the email value is missing/invalid; do not return
HTTP_500 when no user is found — instead treat unknown or non-local users the
same as successful requests and return HTTP_202_ACCEPTED to avoid email
enumeration; replace print(...) with the module logger
(logger.info/logger.error) for sending and exception logging; rename the class
to SendInvitationView and update all imports/URL mappings accordingly; finally
review permission_classes and tighten authentication/roles if the endpoint
should not be public.



class PasswordResetView(views.APIView):
permission_classes = [permissions.AllowAny]

Expand Down
35 changes: 35 additions & 0 deletions frontend/src/lib/components/DetailView/DetailView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,26 @@
modalStore.trigger(modal);
}

function modalSendInvitation(id: string, email: string, action: string): void {
const modalComponent: ModalComponent = {
ref: ConfirmModal,
props: {
_form: { id: id, urlmodel: getModelInfo('representatives').urlModel, email: email },
id: id,
debug: false,
URLModel: getModelInfo('representatives').urlModel,
formAction: action
}
};
const modal: ModalSettings = {
type: 'component',
component: modalComponent,
title: m.confirmModalTitle(),
body: `Do you want to send the invitation to ${email}?`
};
modalStore.trigger(modal);
}
Comment on lines +230 to +248
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use i18n message instead of hard‑coded confirmation text

The confirmation body text (Do you want to send the invitation to ${email}?) is hard‑coded in English, unlike the rest of the DetailView which uses m.*/safeTranslate. Please switch this to a paraglide message (e.g. m.sendInvitationConfirm({ email })) and add the corresponding translation entry.

🤖 Prompt for AI Agents
In frontend/src/lib/components/DetailView/DetailView.svelte around lines 230 to
248, the modal confirmation body is hard-coded in English; replace the template
string `Do you want to send the invitation to ${email}?` with the paraglide/i18n
message call (e.g. use m.sendInvitationConfirm({ email }) or the project’s
safeTranslate helper) so it uses localized text, and add the corresponding
translation entry (sendInvitationConfirm with an {email} interpolation) to the
app’s translations/messages file(s) for all supported locales.


function getReverseForeignKeyEndpoint({
parentModel,
targetUrlModel,
Expand Down Expand Up @@ -632,6 +652,21 @@
{/if}

{#if displayEditButton()}
{#if data.urlModel === 'representatives' && data.data.user}
<button
class="btn preset-filled-ghost-500 mr-2"
onclick={() =>
modalSendInvitation(
data.data.id,
data.data.email,
'/representatives/send-invitation'
)}
data-testid="send-invitation-button"
>
<i class="fa-solid fa-envelope mr-2"></i>
Send invitation
</button>
Comment on lines 654 to +668
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Localize the “Send invitation” button label

The button label Send invitation is hard‑coded. For consistency with the rest of the UI and to keep translations working, this should come from m.* (e.g. {m.sendInvitation()}) with the corresponding message defined in your messages file.

🤖 Prompt for AI Agents
In frontend/src/lib/components/DetailView/DetailView.svelte around lines 654 to
668 the "Send invitation" button label is hard-coded; replace the literal text
with the localized message call (e.g. use m.sendInvitation() or the appropriate
m.* accessor used in this project) and add the corresponding key/value entry for
sendInvitation in the messages file so translations pick it up; ensure the
component imports/has access to m and update any tests referencing the button
text if necessary to use the localized output or data-testid instead.

{/if}
{#if data.data.state === 'Created'}
<Tooltip
open={openStateRA && !data.data.approver}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { BASE_API_URL } from '$lib/utils/constants';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async (event) => {
const requestInitOptions: RequestInit = {
method: 'POST'
};
const form = await event.request.formData();
const raw = form.get('__superform_json') as string;

let parsed = JSON.parse(raw);

let email: string | undefined;

if (Array.isArray(parsed)) {
const mapping = parsed[0];
if (mapping && typeof mapping === 'object' && typeof mapping.email === 'number') {
const emailIndex = mapping.email;
email = parsed[emailIndex];
} else {
email = parsed.find((v: any) => typeof v === 'string' && v.includes('@'));
}
} else if (parsed && typeof parsed === 'object') {
email = parsed.email ?? parsed['__email'] ?? undefined;
}

const res = await fetch(`${BASE_API_URL}/iam/send-invitation/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
return new Response(null, {
headers: {
'Content-Type': 'application/json'
}
});
Comment on lines +4 to +36
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate form payload, ensure email is present, and propagate backend response

The handler is quite optimistic right now:

  • form.get('__superform_json') and JSON.parse(raw) can fail; no 4xx handling, so you’ll just get a 500 on bad input.
  • email can stay undefined, leading to {} being sent to the backend and causing an error there.
  • The downstream response (res) is ignored; the client always gets 200 with an empty body, so messages like “no mailer configured” or other errors are lost.

A more robust version could look like:

 export const POST: RequestHandler = async (event) => {
-	const requestInitOptions: RequestInit = {
-		method: 'POST'
-	};
-	const form = await event.request.formData();
-	const raw = form.get('__superform_json') as string;
-
-	let parsed = JSON.parse(raw);
-
-	let email: string | undefined;
+	const form = await event.request.formData();
+	const raw = form.get('__superform_json');
+	if (typeof raw !== 'string') {
+		return new Response(JSON.stringify({ error: 'invalidFormPayload' }), {
+			status: 400,
+			headers: { 'Content-Type': 'application/json' }
+		});
+	}
+
+	let parsed: unknown;
+	try {
+		parsed = JSON.parse(raw);
+	} catch {
+		return new Response(JSON.stringify({ error: 'invalidJsonPayload' }), {
+			status: 400,
+			headers: { 'Content-Type': 'application/json' }
+		});
+	}
+
+	let email: string | undefined;

-	if (Array.isArray(parsed)) {
+	if (Array.isArray(parsed)) {
 		const mapping = parsed[0];
 		if (mapping && typeof mapping === 'object' && typeof mapping.email === 'number') {
 			const emailIndex = mapping.email;
 			email = parsed[emailIndex];
 		} else {
 			email = parsed.find((v: any) => typeof v === 'string' && v.includes('@'));
 		}
-	} else if (parsed && typeof parsed === 'object') {
+	} else if (parsed && typeof parsed === 'object') {
 		email = parsed.email ?? parsed['__email'] ?? undefined;
 	}
 
-	const res = await fetch(`${BASE_API_URL}/iam/send-invitation/`, {
+	if (!email) {
+		return new Response(JSON.stringify({ error: 'emailRequired' }), {
+			status: 400,
+			headers: { 'Content-Type': 'application/json' }
+		});
+	}
+
+	const res = await fetch(`${BASE_API_URL}/iam/send-invitation/`, {
 		method: 'POST',
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({ email: email })
 	});
-	return new Response(null, {
-		headers: {
-			'Content-Type': 'application/json'
-		}
-	});
+
+	const text = await res.text();
+	return new Response(text || null, {
+		status: res.status,
+		headers: {
+			'Content-Type': res.headers.get('Content-Type') ?? 'application/json'
+		}
+	});
 };

This keeps the email extraction logic but fails fast on bad form/JSON, guarantees an email before calling the API, and forwards the backend’s status and body so the UI can react appropriately.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const POST: RequestHandler = async (event) => {
const requestInitOptions: RequestInit = {
method: 'POST'
};
const form = await event.request.formData();
const raw = form.get('__superform_json') as string;
let parsed = JSON.parse(raw);
let email: string | undefined;
if (Array.isArray(parsed)) {
const mapping = parsed[0];
if (mapping && typeof mapping === 'object' && typeof mapping.email === 'number') {
const emailIndex = mapping.email;
email = parsed[emailIndex];
} else {
email = parsed.find((v: any) => typeof v === 'string' && v.includes('@'));
}
} else if (parsed && typeof parsed === 'object') {
email = parsed.email ?? parsed['__email'] ?? undefined;
}
const res = await fetch(`${BASE_API_URL}/iam/send-invitation/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
return new Response(null, {
headers: {
'Content-Type': 'application/json'
}
});
export const POST: RequestHandler = async (event) => {
const form = await event.request.formData();
const raw = form.get('__superform_json');
if (typeof raw !== 'string') {
return new Response(JSON.stringify({ error: 'invalidFormPayload' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return new Response(JSON.stringify({ error: 'invalidJsonPayload' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
let email: string | undefined;
if (Array.isArray(parsed)) {
const mapping = parsed[0];
if (mapping && typeof mapping === 'object' && typeof mapping.email === 'number') {
const emailIndex = mapping.email;
email = parsed[emailIndex];
} else {
email = parsed.find((v: any) => typeof v === 'string' && v.includes('@'));
}
} else if (parsed && typeof parsed === 'object') {
email = parsed.email ?? parsed['__email'] ?? undefined;
}
if (!email) {
return new Response(JSON.stringify({ error: 'emailRequired' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const res = await fetch(`${BASE_API_URL}/iam/send-invitation/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
const text = await res.text();
return new Response(text || null, {
status: res.status,
headers: {
'Content-Type': res.headers.get('Content-Type') ?? 'application/json'
}
});
};

};
Loading