diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 626146e47749bdbf67013d08eb520a3759701a1b..611406464b289565a84927754557afb94dcabe13 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -151,9 +151,11 @@ "cancelInvitationDescription": "Do you want to cancel the invitation?" }, "error": { + "error": "Error", "invitationCreationFailedHeader": "Failed to create invite", "errorStatusCode": "Status code: {{statusCode}} (<3>{{statusText}}</3>)", "genericServerErrorBody": "The server reported:<1>{{errorBodyText}}</1>", - "contactHelp": "Contact help through the link in the footer if the problem persists." + "contactHelp": "Contact help through the link in the footer if the problem persists.", + "unknown": "An unknown error has occurred. If the problem persists, contact support." } } diff --git a/frontend/public/locales/en/invite.json b/frontend/public/locales/en/invite.json index 70693d275ab5a2c20d9513f1854d1fa4e0643844..ac094ff19553d9360f9af1885bc5a40152839691 100644 --- a/frontend/public/locales/en/invite.json +++ b/frontend/public/locales/en/invite.json @@ -1,6 +1,10 @@ { - "description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number.", - "header": "Guest Registration", + "description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number or Norwegian national ID number.", + "header": "Guest registration", "login": "Log in with FEIDE", - "manual": "Registrate manually" + "manual": "Register manually", + "errors": { + "invalidToken": "The invitation link you followed is invalid.", + "expiredToken": "The invitation link you followed has expired. Contact your host to get a new link." + } } diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index a21f2302e4289250ceeaca1ae8031139d82fcbcf..b9f2d0a7e70dd9bc4e59fd1f66e55eb4857b644a 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -151,9 +151,11 @@ "cancelInvitationDescription": "Vil du kansellere invitasjonen?" }, "error": { + "error": "Feil", "invitationCreationFailedHeader": "Kunne ikke opprette invitasjon", "errorStatusCode": "Statuskode: {{statusCode}} (<3>{{statusText}}</3>)", "genericServerErrorBody": "Respons fra server:<1>{{errorBodyText}}</1>", - "contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer." + "contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer.", + "unknown": "En ukjent feil har oppstått. Om problemet vedvarer, kontakt brukerstøtte." } } diff --git a/frontend/public/locales/nb/invite.json b/frontend/public/locales/nb/invite.json index 6f6b73f0d316f226596aeaa894184030db619f91..8ad98cd8c87f22369f8d57a1f9fb94a1eb7cb5cd 100644 --- a/frontend/public/locales/nb/invite.json +++ b/frontend/public/locales/nb/invite.json @@ -2,5 +2,9 @@ "description": "Vennligst velg hvordan du vil logge inn for å fullføre registreringen. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikke er mulig kan du fylle ut registreringskjemaet manuelt med passnummer", "header": "Gjestetjenesten", "login": "Logg inn med FEIDE", - "manual": "Registrer manuelt" + "manual": "Registrer manuelt", + "errors": { + "invalidToken": "Denne invitasjonslenka er ugyldig.", + "expiredToken": "Denne invitasjonslenka er utløpt. Kontakt verten din for å få tilsendt en ny." + } } diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 620b235c2acec3037497556c0027784f00054ac2..60fc0cc01df242b2171d6885213c7bfe3bc8c53c 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -152,9 +152,11 @@ "cancelInvitationDescription": "Vil du kansellere invitasjonen?" }, "error": { + "error": "Feil", "invitationCreationFailedHeader": "Kunne ikkje opprette invitasjon", "errorStatusCode": "Statuskode: {{statusCode}} (<3>{{statusText}}</3>)", "genericServerErrorBody": "Respons frå server:<1>{{errorBodyText}}</1>", - "contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer." + "contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer.", + "unknown": "Ein uventa feil oppstod. Om problemet varer ved, kontakt brukarstøtte." } } diff --git a/frontend/public/locales/nn/invite.json b/frontend/public/locales/nn/invite.json index abb2afaf0747f645944c94525cc49b28015e87bf..155b826bcf3b540b0a5bfb81ddfeb0d7c878bc6f 100644 --- a/frontend/public/locales/nn/invite.json +++ b/frontend/public/locales/nn/invite.json @@ -2,5 +2,9 @@ "description": "Ver venleg og vel korleis du vil logge inn for å fullføre registreringa. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikkje er mogeleg kan du fylle ut registreringskjemaet manuelt med passnummer", "header": "Gjestetjenesten", "login": "Logg inn med FEIDE", - "manual": "Registrer manuelt" + "manual": "Registrer manuelt", + "errors": { + "invalidToken": "Denne invitasjonslenka er ugyldig.", + "expiredToken": "Denne invitasjonslenka har utløpe. Kontakt verten din for å få tilsendt ei ny." + } } diff --git a/frontend/src/components/debug/index.tsx b/frontend/src/components/debug/index.tsx index aca2b8bcbe8b9f8fdccb4a23572584ab6131a899..e2bc7339b61b9f995cc5534f927a35990c956853 100644 --- a/frontend/src/components/debug/index.tsx +++ b/frontend/src/components/debug/index.tsx @@ -4,16 +4,7 @@ import { useTranslation } from 'react-i18next' import CheckIcon from '@mui/icons-material/Check' import ClearIcon from '@mui/icons-material/Clear' -import { - Box, - Button, - Table, - TableBody, - TableRow, - TableCell, - Stack, - Divider, -} from '@mui/material' +import { Box, Table, TableBody, TableRow, TableCell } from '@mui/material' import { appInst, appTimezone, appVersion } from 'appConfig' import { Link } from 'react-router-dom' @@ -26,8 +17,6 @@ export const Debug = () => { const [apiHealth, setApiHealth] = useState('not yet') const [didContactApi, setDidContactApi] = useState(false) const { i18n } = useTranslation(['common']) - const [csrf, setCsrf] = useState<String | null>(null) - const [username, setUsername] = useState(undefined) const { user } = useUserContext() if (!didContactApi) { @@ -47,87 +36,6 @@ export const Debug = () => { }) } - const getCSRF = () => { - fetch('/api/ui/v1/csrf/', { - credentials: 'same-origin', - }) - .then((res) => { - const csrfToken = res.headers.get('X-CSRFToken') - setCsrf(csrfToken) - console.log(csrfToken) - }) - .catch((err) => { - console.log(err) - }) - } - - const getSession = () => { - fetch('/api/ui/v1/session/?format=json', { - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }) - .then((res) => res.json()) - .then((data) => { - console.log(data) - getCSRF() - }) - .catch((err) => { - console.log(err) - }) - } - - const testMail = () => { - fetch('/api/ui/v1/testmail/', { - credentials: 'same-origin', - }) - .then((data) => { - console.log(data) - }) - .catch((err) => { - console.log(err) - }) - } - - const whoami = () => { - fetch('/api/ui/v1/whoami/', { - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }) - .then((res) => res.json()) - .then((data) => { - setUsername(data.username) - console.log(`You are logged in as: ${data.username}`) - }) - .catch((err) => { - console.log(err) - }) - } - - function isResponseOk(response: any) { - if (response.status >= 200 && response.status <= 299) { - return response.json() - } - throw Error(response.statusText) - } - - const logout = () => { - fetch('/api/ui/v1/logout/', { - credentials: 'same-origin', - }) - .then(isResponseOk) - .then((data) => { - console.log(data) - getCSRF() - }) - .catch((err) => { - console.log(err) - }) - } - const d = [ ['NODE_ENV', process.env.NODE_ENV], ['Version', appVersion], @@ -137,8 +45,6 @@ export const Debug = () => { ['Institution', appInst], ['API reachable?', apiHealth === 'yes' ? <Yes /> : apiHealth], ['Authenticated?', user.auth ? <Yes /> : <No />], - ['Username', username], - ['CSRF', csrf], ] return ( <Box> @@ -163,25 +69,6 @@ export const Debug = () => { </ul> </p> <h3>Debug</h3> - - <Stack - direction="row" - spacing={1} - divider={<Divider orientation="vertical" />} - > - <Button type="button" onClick={() => getSession()}> - AM I AUTHENTICATED? - </Button> - <Button type="button" onClick={() => whoami()}> - WHO AM I? - </Button> - <Button type="button" onClick={() => logout()}> - LOGOUT - </Button> - <Button type="button" onClick={() => testMail()}> - SEND TEST MAIL - </Button> - </Stack> <Box sx={{ maxWidth: '30rem' }}> <Table> <TableBody> diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index e273442a88500c9057310d7a663a4b068b2be86d..1276166a6855b29848e62951470931c8caae4798 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -5,24 +5,24 @@ import { styled } from '@mui/system' import { CssBaseline } from '@mui/material' import fetchIntercept from 'fetch-intercept' +import { registerLocale } from 'i18n-iso-countries' +import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json' +import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json' +import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json' + import { useUserContext } from 'contexts' import { getCookie, deleteCookie } from 'utils' +import GuestRegister from 'routes/guest/register' import Sponsor from 'routes/sponsor' import Register from 'routes/sponsor/register' import FrontPage from 'routes/frontpage' import Invite from 'routes/invite' -import InviteLink from 'routes/invitelink' -import LogoutInviteSession from 'routes/invitelink/logout' +import LogoutInviteSession from 'routes/invite/logout' import Footer from 'routes/components/footer' import Header from 'routes/components/header' import NotFound from 'routes/components/notFound' import ProtectedRoute from 'components/protectedRoute' -import { registerLocale } from 'i18n-iso-countries' -import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json' -import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json' -import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json' -import GuestRegister from './guest/register' const AppWrapper = styled('div')({ display: 'flex', @@ -77,7 +77,6 @@ export default function App() { <ProtectedRoute path="/register"> <Register /> </ProtectedRoute> - <Route path="/invitelink/" component={InviteLink} /> <Route path="/invite/logout" component={LogoutInviteSession} /> <Route path="/invite/" component={Invite} /> <Route path="/guestregister" component={GuestRegister} /> diff --git a/frontend/src/routes/invite/index.tsx b/frontend/src/routes/invite/index.tsx index ba43e2d7c2a31266e2f209d6a838d44099b22f48..14acbc64312086b838447bf554ba76f48e48329e 100644 --- a/frontend/src/routes/invite/index.tsx +++ b/frontend/src/routes/invite/index.tsx @@ -1,25 +1,27 @@ +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Page from 'components/page' +import Loading from 'components/loading' import { styled } from '@mui/material/styles' +import { useUserContext } from 'contexts' import { HrefButton } from 'components/button' import { HrefLineButton } from 'components/button/linebutton' +import { submitJsonOpts } from 'utils' const FlexDiv = styled('div')(() => ({ display: 'flex', gap: '0.5rem', })) -function Invite() { +function ChooseRegistrationMethod() { const { t } = useTranslation(['invite']) return ( <Page> <h1>{t('header')}</h1> - <p> - {t('description')} - </p> + <p>{t('description')}</p> <FlexDiv> <HrefButton to="/oidc/authenticate/">{t('login')}</HrefButton> <HrefLineButton to="/guestregister/">{t('manual')}</HrefLineButton> @@ -28,4 +30,125 @@ function Invite() { ) } +interface ShowFeedbackProps { + title: string + description: string +} + +function ShowFeedback(props: ShowFeedbackProps) { + const { title, description } = props + + return ( + <Page> + <h1>{title}</h1> + <p>{description}</p> + </Page> + ) +} + +function Invite() { + const { t } = useTranslation(['invite']) + const { user, fetchUserInfo } = useUserContext() + const [inviteToken, setInviteToken] = useState('') + const [tokenChecked, setTokenChecked] = useState(false) + const [isCheckingToken, setIsCheckingToken] = useState(false) + const [tokenOk, setTokenOk] = useState(false) + const [checkError, setCheckError] = useState('') + + async function checkToken(token: string) { + try { + const response = await fetch( + '/api/ui/v1/invitecheck/', + submitJsonOpts('POST', { invite_token: token }) + ) + if (response.status === 200) { + setTokenOk(true) + return + } + const data = await response.json() + + if ('code' in data) { + setCheckError(data.code) + } else { + setCheckError('unknown') + } + } catch (error) { + console.error(error) + setCheckError('unknown') + } finally { + setTokenChecked(true) + setIsCheckingToken(false) + } + } + + console.log({ inviteToken, isCheckingToken, tokenChecked, tokenOk, user }) + + useEffect(() => { + setIsCheckingToken(true) + // This may seem unecessary, but race conditions have been + // observed where the userinfo endpoint is called too fast + // and no invite_id is found in the server-side session + setTimeout(fetchUserInfo, 100) + }, [setTokenOk]) + + if (user.auth) { + return <ChooseRegistrationMethod /> + } + + if (isCheckingToken || (tokenOk && !user.auth)) { + return <Loading /> + } + + if (inviteToken !== '' && !tokenChecked) { + checkToken(inviteToken) + return <Loading /> + } + + if (checkError !== '') { + if ( + checkError === 'missing_invite_token' || + checkError === 'invalid_invite_token' + ) { + // Missing or invalid token + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('invite:errors.invalidToken')} + /> + ) + } + if (checkError === 'expired_invite_token') { + // Expired token + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('invite:errors.expiredToken')} + /> + ) + } + // Unknown error + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('common:error.unknown')} + /> + ) + } + + const providedToken = window.location.hash.slice(1).trim() + + if (!inviteToken && providedToken) { + setInviteToken(providedToken) + return <Loading /> + } + + // We'll end up here if no token was provided in the URL + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('invite:errors.invalidToken')} + /> + ) +} + export default Invite diff --git a/frontend/src/routes/invitelink/logout.tsx b/frontend/src/routes/invite/logout.tsx similarity index 100% rename from frontend/src/routes/invitelink/logout.tsx rename to frontend/src/routes/invite/logout.tsx diff --git a/frontend/src/routes/invitelink/index.tsx b/frontend/src/routes/invitelink/index.tsx deleted file mode 100644 index fd8d63f056582d59de1a1774688a6d13506e8ec2..0000000000000000000000000000000000000000 --- a/frontend/src/routes/invitelink/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect } from 'react' -import { Redirect } from 'react-router-dom' -import { submitJsonOpts, setCookie } from 'utils' - -function InviteLink() { - // Fetch backend endpoint to preserve invite_id in backend session then redirect - // to generic invite page with info about feide login or manual with passport. - - const id = window.location.hash.slice(1) - - useEffect(() => { - fetch('/api/ui/v1/invitecheck/', submitJsonOpts('POST', { uuid: id })) - }, []) - setCookie('redirect', '/guestregister') - return <Redirect to="/invite" /> -} - -export default InviteLink diff --git a/gregui/api/urls.py b/gregui/api/urls.py index 3b991355256b6eb6fb093284013e50408fa90aca..c2fb05dab808f6cd78d1c03c15685a977084efed 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -14,6 +14,7 @@ from gregui.api.views.role import RoleInfoViewSet from gregui.api.views.consent import ConsentTypeViewSet from gregui.api.views.roletypes import RoleTypeViewSet from gregui.api.views.unit import UnitsViewSet +from gregui.api.views.userinfo import UserInfoView router = DefaultRouter(trailing_slash=False) router.register(r"role", RoleInfoViewSet, basename="role") @@ -40,4 +41,5 @@ urlpatterns += [ PersonSearchViewSet.as_view({"get": "list"}), name="person-search", ), + path("userinfo/", UserInfoView.as_view(), name="userinfo"), ] diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py index a979d534b95357fe0a02a5e5ee7efb2275dda1a7..34b6d4fa79bc078e488b1f6e5b70f176761b6da6 100644 --- a/gregui/api/views/invitation.py +++ b/gregui/api/views/invitation.py @@ -118,15 +118,33 @@ class CheckInvitationView(APIView): Uses post to prevent invite id from showing up in various logs. """ - invite_id = request.data.get("uuid") + invite_id = request.data.get("invite_token") if not invite_id: - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "code": "missing_invite_token", + "message": "An invite token is required", + }, + ) try: invite_link = InvitationLink.objects.get(uuid=invite_id) except (InvitationLink.DoesNotExist, exceptions.ValidationError): - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_403_FORBIDDEN, + data={ + "code": "invalid_invite_token", + "message": "Invite token is invalid", + }, + ) if invite_link.expire <= timezone.now(): - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_403_FORBIDDEN, + data={ + "code": "expired_invite_token", + "message": "Invite token has expired", + }, + ) request.session["invite_id"] = invite_id return Response(status=status.HTTP_200_OK) diff --git a/gregui/api/views/userinfo.py b/gregui/api/views/userinfo.py index bba967c519e0c611d7018bd651a47c35bd984d96..f376fb0ad360d08257e4c52472ce80f06b19bd2d 100644 --- a/gregui/api/views/userinfo.py +++ b/gregui/api/views/userinfo.py @@ -21,8 +21,8 @@ class UserInfoView(APIView): Quick draft, we might want to expand this later. """ - authentication_classes: Sequence[Type[BaseAuthentication]] = [SessionAuthentication] - permission_classes: Sequence[Type[BasePermission]] = [AllowAny] + authentication_classes = [SessionAuthentication] + permission_classes = [AllowAny] def get(self, request, format=None): """ @@ -32,6 +32,7 @@ class UserInfoView(APIView): invitation id. Pure django users, and anonymous users are denied access. """ user = request.user + invite_id = request.session.get("invite_id") auth_type = "invite" @@ -40,6 +41,8 @@ class UserInfoView(APIView): person = None sponsor = None + user_profile = None + content = { "feide_id": None, "sponsor_id": None, @@ -48,34 +51,57 @@ class UserInfoView(APIView): "auth_type": auth_type, } - # Fetch sponsor and/or person object from profile of authenticated user if user.is_authenticated: try: - user_profile = GregUserProfile.objects.get(user=user) - sponsor = user_profile.sponsor - person = user_profile.person - content.update( - { - "feide_id": user_profile.userid_feide, - } - ) + user_profile = GregUserProfile.objects.select_related( + "person", "sponsor" + ).get(user=user) except GregUserProfile.DoesNotExist: - return Response(status=HTTP_403_FORBIDDEN) + return Response( + status=HTTP_403_FORBIDDEN, + data={ + "code": "no_user_profile", + "message": "Authenticated, but missing user profile", + }, + ) + # Fetch sponsor and/or person object from profile of authenticated user + if user_profile: + sponsor = user_profile.sponsor + person = user_profile.person + content["feide_id"] = user_profile.userid_feide # Or fetch person info for invited guest elif invite_id: - link = InvitationLink.objects.get(uuid=invite_id) + link = InvitationLink.objects.select_related( + "invitation__role__person", + ).get(uuid=invite_id) person = link.invitation.role.person - # Otherwise, deny access else: return Response(status=HTTP_403_FORBIDDEN) - # Add sponsor fields if sponsor object present if sponsor: - content.update({"sponsor_id": user_profile.sponsor.id}) - # Add person fields if person object present + content["sponsor_id"] = sponsor.id + if person: + person_roles = [ + { + "id": role.id, + "ou_nb": role.orgunit.name_nb, + "ou_en": role.orgunit.name_en, + "name_nb": role.type.name_nb, + "name_en": role.type.name_en, + "start_date": role.start_date, + "end_date": role.end_date, + "sponsor": { + "first_name": role.sponsor.first_name, + "last_name": role.sponsor.last_name, + }, + } + for role in person.roles.all().select_related( + "orgunit", "type", "sponsor" + ) + ] content.update( { "person_id": person.id, @@ -86,22 +112,7 @@ class UserInfoView(APIView): and person.private_mobile.value, "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), "passport": person.passport and person.passport.value, - "roles": [ - { - "id": role.id, - "ou_nb": role.orgunit.name_nb, - "ou_en": role.orgunit.name_en, - "name_nb": role.type.name_nb, - "name_en": role.type.name_en, - "start_date": role.start_date, - "end_date": role.end_date, - "sponsor": { - "first_name": role.sponsor.first_name, - "last_name": role.sponsor.last_name, - }, - } - for role in person.roles.all() - ], + "roles": person_roles, } ) return Response(content) diff --git a/gregui/tests/api/views/test_invitation.py b/gregui/tests/api/views/test_invitation.py index 8a10b3727b196398e143cf0f25ad6e4165afe818..0fb8e4564adfae9e04d43e30b8b8beed83b8dfd0 100644 --- a/gregui/tests/api/views/test_invitation.py +++ b/gregui/tests/api/views/test_invitation.py @@ -12,15 +12,21 @@ from greg.models import Consent, InvitationLink, Person, Identity @pytest.mark.django_db def test_post_invite(client): """Forbid access with bad invitation link uuid""" - response = client.post(reverse("gregui-v1:invite-verify"), data={"uuid": "baduuid"}) + response = client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": "baduuid"} + ) assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "code": "invalid_invite_token", + "message": "Invite token is invalid", + } @pytest.mark.django_db def test_post_invite_ok(client, invitation_link): """Access okay with valid invitation link""" response = client.post( - reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid} + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} ) assert response.status_code == status.HTTP_200_OK @@ -29,23 +35,34 @@ def test_post_invite_ok(client, invitation_link): def test_post_invite_expired(client, invitation_link_expired): """Forbid access with expired invite link""" response = client.post( - reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link_expired.uuid} + reverse("gregui-v1:invite-verify"), + data={"invite_token": invitation_link_expired.uuid}, ) assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "code": "expired_invite_token", + "message": "Invite token has expired", + } @pytest.mark.django_db def test_post_missing_invite_id(client): """Forbid access if no id provided.""" response = client.post(reverse("gregui-v1:invite-verify")) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "code": "missing_invite_token", + "message": "An invite token is required", + } @pytest.mark.django_db def test_get_invited_info_no_session(client, invitation_link): """Forbid access if invite expired after session was created.""" # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # expire the invitation link invitation_link.expire = timezone.now() - datetime.timedelta(days=1) invitation_link.save() @@ -60,7 +77,9 @@ def test_get_invited_info_session_okay( ): person, invitation_link = invited_person # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Get info response = client.get(reverse("gregui-v1:invited-info")) assert response.status_code == status.HTTP_200_OK @@ -95,7 +114,9 @@ def test_get_invited_info_expired_link( client, invitation_link, invitation_expired_date ): # Get a session while link is valid - client.get(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.get( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Set expire link to expire long ago invlink = InvitationLink.objects.get(uuid=invitation_link.uuid) invlink.expire = invitation_expired_date @@ -110,7 +131,9 @@ def test_get_invited_info_expired_link( def test_invited_guest_can_post_information(client: APIClient, invited_person): person, invitation_link = invited_person # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) person = invitation_link.invitation.role.person assert person.private_mobile is None @@ -138,7 +161,9 @@ def test_post_invited_info_expired_session( client, invitation_link, invitation_expired_date ): # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Set expire link to expire long ago invlink = InvitationLink.objects.get(uuid=invitation_link.uuid) invlink.expire = invitation_expired_date @@ -152,7 +177,9 @@ def test_post_invited_info_expired_session( @pytest.mark.django_db def test_post_invited_info_deleted_inv_link(client, invitation_link): # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Delete link invlink = InvitationLink.objects.get(uuid=invitation_link.uuid) invlink.delete() @@ -284,7 +311,9 @@ def test_name_update_not_allowed_if_feide_identity_is_present( ) invitation = create_invitation(role) invitation_link = create_invitation_link(invitation, invitation_valid_date) - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) person = invitation_link.invitation.role.person data = { @@ -330,7 +359,9 @@ def test_name_update_allowed_if_feide_identity_is_not_present( invitation_link = create_invitation_link( create_invitation(role), invitation_valid_date ) - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) person = invitation_link.invitation.role.person data = { diff --git a/gregui/tests/api/views/test_userinfo.py b/gregui/tests/api/views/test_userinfo.py index cf1f331d48c6ff3ca1fe2d2ec47f4db756ac7c68..df7d3cb44888f333ac0939a4509c0ec88583fb0d 100644 --- a/gregui/tests/api/views/test_userinfo.py +++ b/gregui/tests/api/views/test_userinfo.py @@ -6,7 +6,7 @@ from rest_framework import status @pytest.mark.django_db def test_userinfo_anon_get(client): """Anonymous people should be forbidden.""" - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -16,7 +16,7 @@ def test_userinfo_invited_get(client, invitation_link): session = client.session session["invite_id"] = str(invitation_link.uuid) session.save() - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { "auth_type": "invite", @@ -48,7 +48,7 @@ def test_userinfo_invited_get(client, invitation_link): def test_userinfo_user_no_profile(client, log_in, user_no_profile): """Pure django users should be forbidden""" log_in(user_no_profile) - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -57,7 +57,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor): """Sponsors should get info about themselves""" log_in(user_sponsor) - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { "auth_type": "oidc", @@ -72,7 +72,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor): def test_userinfo_guest_get(client, log_in, user_person): """Logged in guests should get info about themself""" log_in(user_person) - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { "auth_type": "oidc", diff --git a/gregui/urls.py b/gregui/urls.py index 4292a73e4146722848d2b9f7d476f037bd99e87a..a8ff0fbd7bbb2b6dcd7449a5a48e36285ab51308 100644 --- a/gregui/urls.py +++ b/gregui/urls.py @@ -5,18 +5,9 @@ from django.urls import path from django.urls.resolvers import URLResolver from gregui.api import urls as api_urls -from gregui.api.views.userinfo import UserInfoView -from . import views urlpatterns: List[URLResolver] = [ path( "api/ui/v1/", include((api_urls.urlpatterns, "gregui"), namespace="gregui-v1") ), - path("api/ui/v1/csrf/", views.get_csrf, name="api-csrf"), - path("api/ui/v1/logout/", views.logout_view, name="api-logout"), - path("api/ui/v1/login/", views.login_view, name="api-login"), - path("api/ui/v1/session/", views.SessionView.as_view(), name="api-session"), - path("api/ui/v1/testmail/", views.send_test_email, name="api-testmail"), - path("api/ui/v1/whoami/", views.WhoAmIView.as_view(), name="api-whoami"), - path("api/ui/v1/userinfo/", UserInfoView.as_view(), name="api-userinfo"), # type: ignore ] diff --git a/gregui/views.py b/gregui/views.py deleted file mode 100644 index a60d3b760e25993ea4005b78fad4d6850fc472f9..0000000000000000000000000000000000000000 --- a/gregui/views.py +++ /dev/null @@ -1,60 +0,0 @@ -from django.contrib.auth import logout -from django.http import JsonResponse -from django.middleware.csrf import get_token -from django.shortcuts import redirect -from rest_framework.authentication import SessionAuthentication, BasicAuthentication -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView - -from gregui import mailutils - - -def get_csrf(request): - response = JsonResponse({"detail": "CSRF cookie set"}) - response["X-CSRFToken"] = get_token(request) - return response - - -def logout_view(request): - if not request.user.is_authenticated: - return JsonResponse({"detail": "You're not logged in."}, status=400) - - logout(request) - return JsonResponse({"detail": "Successfully logged out."}) - - -def login_view(request): - """ - View for pointing login links to - - Sesame will take the query string automatically and use it to create a session for - the user, so all this needs to do is redirect the user wherever they're supposed to - go after successfully logging in. - """ - # TODO: redirect to whatever path the frontend ends up living at (prob '/') - return redirect("/api/ui/v1/whoami/") - - -def send_test_email(request): - mailutils.send_registration_mail("test@example.no", "Foo Bar") - return JsonResponse({"detail": "Created task to send test mail."}) - - -class SessionView(APIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated] - - @staticmethod - # pylint: disable=W0622 - def get(request, format=None): - return JsonResponse({"isAuthenticated": True}) - - -class WhoAmIView(APIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated] - - @staticmethod - # pylint: disable=W0622 - def get(request, format=None): - return JsonResponse({"username": request.user.username})