From 37385fc3ab66af35e8cdb3261588cd0645faa49f Mon Sep 17 00:00:00 2001 From: Michal Torma Date: Fri, 9 Oct 2026 13:08:31 +0200 Subject: [PATCH 1/3] Refactor invite link handling in OIC login process - Simplified the invite token validation logic to only clear the session token after successful user login. - Updated the response for expired or already-used invite links to redirect to the normal login page with a notice. - Enhanced the frontend to display an error dialog when an invite link has expired, using consistent messaging. This improves user experience by allowing reuse of invite links while providing clear feedback on their status. --- specifyweb/backend/accounts/views.py | 29 +++++++++++-------- .../js_src/lib/components/Login/OicLogin.tsx | 23 +++++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index b2dc0358d35..90d8ee8bc36 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -156,19 +156,18 @@ def oic_callback(request: http.HttpRequest) -> http.HttpResponse: if 'invite_token' in request.session: # We are in the invite link workflow. token: InviteToken = request.session['invite_token'] + del request.session['invite_token'] user = Specifyuser.objects.annotate(token_seq=Max('spuserexternalid__id')).get(id=token['userid']) - if time.time() > token['expires'] or user.token_seq != token['sequence']: - return http.HttpResponseBadRequest("Token expired.", content_type="text/plain") - - user.spuserexternalid_set.create( - provider=provider, - providerid=str(ext_user['sub']), - idtoken=ext_user, - ) - del request.session['invite_token'] - login(request, user, backend='django.contrib.auth.backends.ModelBackend') - return http.HttpResponseRedirect('/accounts/choose_collection/') + if time.time() <= token['expires'] and user.token_seq == token['sequence']: + user.spuserexternalid_set.create( + provider=provider, + providerid=str(ext_user['sub']), + idtoken=ext_user, + ) + login(request, user, backend='django.contrib.auth.backends.ModelBackend') + return http.HttpResponseRedirect('/accounts/choose_collection/') + # Invite already used or expired: fall through to normal SSO login. try: spuserexternalid = Spuserexternalid.objects.get(provider=provider, providerid=str(ext_user['sub'])) @@ -228,6 +227,9 @@ def use_invite_link(request) -> http.HttpResponse: it in a server side session variable, then redirect to the regular OIC login process which will retrieve the token after the user authenticates with their chosen IdP. + + Expired or already-used invite links redirect to the normal login + page so users who bookmark/reuse the invite URL can still sign in. """ message = request.GET['token'].encode('utf-8') mac = base64.urlsafe_b64decode(request.GET['mac']) @@ -239,7 +241,10 @@ def use_invite_link(request) -> http.HttpResponse: user = Specifyuser.objects.annotate(token_seq=Max('spuserexternalid__id')).get(id=token['userid']) if time.time() > token['expires'] or user.token_seq != token['sequence']: - return http.HttpResponseBadRequest("Token expired.", content_type="text/plain") + # Do not clear an existing session invite_token here: the user may + # still have a valid invite in progress from a newer link. + # invite_expired=1 lets the login UI show a non-blocking notice. + return http.HttpResponseRedirect('/accounts/login/?invite_expired=1') token['username'] = user.name # Just in case it has changed. request.session['invite_token'] = token diff --git a/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx b/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx index 6c598cadd04..f3a38b03242 100644 --- a/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx +++ b/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx @@ -9,14 +9,20 @@ import { useSearchParameter } from '../../hooks/navigation'; import { userText } from '../../localization/user'; import type { Language } from '../../localization/utils/config'; import type { RA } from '../../utils/types'; +import { localized } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; +import { displayError } from '../Core/Contexts'; import { SplashScreen } from '../Core/SplashScreen'; +import { ErrorDialog } from '../Errors/ErrorDialog'; import { formatUrl } from '../Router/queryString'; import { LoginLanguageChooser } from './index'; +/** Same wording as the former HttpResponseBadRequest from use_invite_link. */ +const inviteExpiredMessage = localized('Token expired.'); + export type OicProvider = { readonly provider: string; readonly title: LocalizedString; @@ -37,6 +43,23 @@ export function OicLogin({ const providerRef = React.useRef(null); const formRef = React.useRef(null); const [next = ''] = useSearchParameter('next'); + const [inviteExpired, setInviteExpired] = useSearchParameter('invite_expired'); + + React.useEffect(() => { + if (inviteExpired !== '1') return; + setInviteExpired(undefined); + displayError(({ onClose: handleClose }) => ( + + {inviteExpiredMessage} + + )); + }, [inviteExpired, setInviteExpired]); + return ( From fe6d770d29ed27af6485ff3a3fcad59264e49efe Mon Sep 17 00:00:00 2001 From: Michal Torma Date: Fri, 9 Oct 2026 17:07:25 +0200 Subject: [PATCH 2/3] Update OIC login error handling with localized message for expired invite links - Replaced hardcoded expired token message with a localized string from userText. - Ensured consistent messaging in the error dialog for expired invite links, improving user experience. This change enhances the clarity and maintainability of the code by utilizing localization for user-facing messages. --- .../frontend/js_src/lib/components/Login/OicLogin.tsx | 11 ++++------- specifyweb/frontend/js_src/lib/localization/user.ts | 3 +++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx b/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx index f3a38b03242..7144bce8054 100644 --- a/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx +++ b/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx @@ -9,7 +9,6 @@ import { useSearchParameter } from '../../hooks/navigation'; import { userText } from '../../localization/user'; import type { Language } from '../../localization/utils/config'; import type { RA } from '../../utils/types'; -import { localized } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { Link } from '../Atoms/Link'; @@ -20,9 +19,6 @@ import { ErrorDialog } from '../Errors/ErrorDialog'; import { formatUrl } from '../Router/queryString'; import { LoginLanguageChooser } from './index'; -/** Same wording as the former HttpResponseBadRequest from use_invite_link. */ -const inviteExpiredMessage = localized('Token expired.'); - export type OicProvider = { readonly provider: string; readonly title: LocalizedString; @@ -48,14 +44,15 @@ export function OicLogin({ React.useEffect(() => { if (inviteExpired !== '1') return; setInviteExpired(undefined); + const message = userText.inviteLinkExpired(); displayError(({ onClose: handleClose }) => ( - {inviteExpiredMessage} + {message} )); }, [inviteExpired, setInviteExpired]); diff --git a/specifyweb/frontend/js_src/lib/localization/user.ts b/specifyweb/frontend/js_src/lib/localization/user.ts index 9a529a8f3ca..6f9b15dde53 100644 --- a/specifyweb/frontend/js_src/lib/localization/user.ts +++ b/specifyweb/frontend/js_src/lib/localization/user.ts @@ -178,6 +178,9 @@ export const userText = createDictionary({ 'hr-hr': 'Pozdrav, {userName:string}!', nb: 'Hallo, {userName:string}!', }, + inviteLinkExpired: { + 'en-us': 'Token expired.', + }, oicWelcomeMessage: { 'en-us': "You've been invited to associate an external login to your Specify user account. This will enable you to log in to Specify with your chosen provider going forward.", From 10da0f6321c61d8a0842909f9625bc5d0d27501a Mon Sep 17 00:00:00 2001 From: Michal Torma Date: Fri, 9 Oct 2026 17:24:19 +0200 Subject: [PATCH 3/3] Update invite link expired message for improved clarity - Changed the message for expired invite links from 'Token expired.' to 'This invite link has already been used or has expired.' in the localization file. - This update enhances user understanding of the invite link status, aligning with recent improvements in error handling and messaging consistency. --- specifyweb/frontend/js_src/lib/localization/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/localization/user.ts b/specifyweb/frontend/js_src/lib/localization/user.ts index 6f9b15dde53..f1cd5f9c24d 100644 --- a/specifyweb/frontend/js_src/lib/localization/user.ts +++ b/specifyweb/frontend/js_src/lib/localization/user.ts @@ -179,7 +179,7 @@ export const userText = createDictionary({ nb: 'Hallo, {userName:string}!', }, inviteLinkExpired: { - 'en-us': 'Token expired.', + 'en-us': 'This invite link has already been used or has expired.', }, oicWelcomeMessage: { 'en-us':