diff --git a/apps/api/plane/authentication/views/app/trusted.py b/apps/api/plane/authentication/views/app/trusted.py index e36235fb1..a6e6a8b8f 100644 --- a/apps/api/plane/authentication/views/app/trusted.py +++ b/apps/api/plane/authentication/views/app/trusted.py @@ -38,12 +38,14 @@ import logging import os import time +import uuid from typing import Optional, Tuple from urllib.parse import urlparse import jwt as pyjwt import redis import requests +from django.db import transaction from django.http import HttpResponseRedirect, HttpResponseNotFound from django.views import View @@ -55,7 +57,7 @@ from plane.authentication.utils.host import base_host from plane.authentication.utils.login import user_login from plane.authentication.utils.redirection_path import get_redirection_path from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow -from plane.db.models import User +from plane.db.models import Profile, User from plane.settings.redis import redis_instance from plane.utils.path_validator import get_safe_redirect_url @@ -231,18 +233,34 @@ class TrustedSignInEndpoint(View): if not email: return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", "TRUSTED_JWT_TOKEN_NO_EMAIL", next_path) - # Find-or-create. Plane's User model uses email as a unique natural key; - # other OAuth providers do the same lookup via the OauthAdapter base. - # We mirror that behavior here without going through OauthAdapter — this - # endpoint is a NEW entry-point, not a fifth OAuth provider. - user, created = User.objects.get_or_create( - email=email, - defaults={ - "first_name": claims.get("first_name") or claims.get("given_name") or "", - "last_name": claims.get("last_name") or claims.get("family_name") or "", - "is_password_autoset": True, - }, - ) + # Find-or-create. We mirror the User-creation shape that + # OauthAdapter.complete_login_or_signup() produces (apps/api/plane/ + # authentication/adapter/base.py:289-342) — same field set, same + # required side-effect of creating a Profile row. Skipping the Profile + # would cause Plane's SPA /api/users/me/profile/ to 404 and bounce + # the user back to /login in an onboarding loop. + user = User.objects.filter(email=email).first() + created = user is None + if created: + with transaction.atomic(): + user = User( + email=email, + username=uuid.uuid4().hex, + first_name=claims.get("first_name") or claims.get("given_name") or "", + last_name=claims.get("last_name") or claims.get("family_name") or "", + is_password_autoset=True, + is_email_verified=True, + ) + # Random password — user signs in via SSO; the password exists + # only so Django's auth machinery has a non-empty hash and the + # break-glass admin pattern can be applied later by ALTER-ing + # this user out of band if needed. + user.set_password(uuid.uuid4().hex) + user.save() + # Profile row is mandatory: every Plane API endpoint that + # touches user state (workspace listings, onboarding) reads + # Profile, and the SPA's /api/users/me/profile/ 404s without it. + Profile.objects.create(user=user) # Plane's existing post-auth workflow (default workspace, invitations, etc.) post_user_auth_workflow(user=user, is_signup=created, request=request)